#include "../include/QuickMedia.hpp" #include "../plugins/Manganelo.hpp" #include "../plugins/Mangatown.hpp" #include "../plugins/Mangadex.hpp" #include "../plugins/Youtube.hpp" #include "../plugins/Pornhub.hpp" #include "../plugins/Fourchan.hpp" #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/Pleroma.hpp" #include "../plugins/FileManager.hpp" #include "../include/Scale.hpp" #include "../include/Program.hpp" #include "../include/VideoPlayer.hpp" #include "../include/StringUtils.hpp" #include "../include/GoogleCaptcha.hpp" #include "../include/Notification.hpp" #include "../include/ImageViewer.hpp" #include "../include/ImageUtils.hpp" #include "../include/base64_url.hpp" #include "../include/Entry.hpp" #include "../include/NetUtils.hpp" #include "../include/SfmlFixes.hpp" #include "../include/ResourceLoader.hpp" #include "../include/AsyncTask.hpp" #include "../external/hash-library/sha256.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static const sf::Color back_color(21, 25, 30); static const std::string fourchan_google_captcha_api_key = "6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc"; static const float tab_text_size = 16.0f; static const float tab_height = tab_text_size + 10.0f; static const sf::Color tab_selected_color(55, 60, 68); static const float tab_margin_x = 10.0f; // Prevent writing to broken pipe from exiting the program static void sigpipe_handler(int) { } static int x_error_handler(Display*, XErrorEvent*) { return 0; } static int x_io_error_handler(Display*) { return 0; } static int get_monitor_max_hz(Display *display) { XRRScreenResources *screen_res = XRRGetScreenResources(display, DefaultRootWindow(display)); if(screen_res) { unsigned long max_hz = 0; for(int i = 0; i < screen_res->nmode; ++i) { unsigned long total = screen_res->modes[i].hTotal*screen_res->modes[i].vTotal; if(total > 0) max_hz = std::max(max_hz, screen_res->modes[i].dotClock/total); } XRRFreeScreenResources(screen_res); if(max_hz == 0) max_hz = 60; return std::min(max_hz, 144UL); } return 60; } static void get_screen_resolution(Display *display, int *width, int *height) { *width = DefaultScreenOfDisplay(display)->width; *height = DefaultScreenOfDisplay(display)->height; } static bool has_gl_ext(Display *disp, const char *ext) { const char *extensions = glXQueryExtensionsString(disp, DefaultScreen(disp)); if(!extensions) return false; int ext_len = strlen(ext); while(true) { const char *loc = strstr(extensions, ext); if(!loc) return false; const char *terminator = loc + ext_len; if((loc == extensions || *(loc - 1) == ' ') && (*terminator == ' ' || *terminator == '\0')) return true; extensions = terminator; } } static PFNGLXSWAPINTERVALMESAPROC glXSwapIntervalMESA = nullptr; static PFNGLXSWAPINTERVALSGIPROC glXSwapIntervalSGI = nullptr; static PFNGLXSWAPINTERVALEXTPROC glXSwapIntervalEXT = nullptr; static bool vsync_loaded = false; static bool vsync_set = false; static bool test_vsync(Display *disp, Window window) { unsigned int swap = 0; glXQueryDrawable(disp, window, GLX_SWAP_INTERVAL_EXT, &swap); fprintf(stderr, "The swap interval is %u\n", swap); return swap == 1; } static bool enable_vsync(Display *disp, Window window) { if(vsync_loaded) { if(glXSwapIntervalMESA) return glXSwapIntervalMESA(1) == 0; if(glXSwapIntervalSGI) return glXSwapIntervalSGI(1) == 0; if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return true; } return false; } vsync_loaded = true; if(has_gl_ext(disp, "GLX_MESA_swap_control")) { fprintf(stderr, "vsync method: GLX_MESA_swap_control\n"); glXSwapIntervalMESA = (PFNGLXSWAPINTERVALMESAPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalMESA"); if(glXSwapIntervalMESA && glXSwapIntervalMESA(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_SGI_swap_control")) { fprintf(stderr, "vsync method: GLX_SGI_swap_control\n"); glXSwapIntervalSGI = (PFNGLXSWAPINTERVALSGIPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalSGI"); if(glXSwapIntervalSGI && glXSwapIntervalSGI(1) == 0 && test_vsync(disp, window)) return true; } if(has_gl_ext(disp, "GLX_EXT_swap_control")) { fprintf(stderr, "vsync method: GLX_EXT_swap_control\n"); glXSwapIntervalEXT = (PFNGLXSWAPINTERVALEXTPROC)glXGetProcAddress((const GLubyte*)"glXSwapIntervalEXT"); if(glXSwapIntervalEXT) { glXSwapIntervalEXT(disp, window, 1); return test_vsync(disp, window); } } fprintf(stderr, "vsync method: none\n"); return false; } static sf::Color interpolate_colors(sf::Color source, sf::Color target, double progress) { int diff_r = (int)target.r - (int)source.r; int diff_g = (int)target.g - (int)source.g; int diff_b = (int)target.b - (int)source.b; int diff_a = (int)target.a - (int)source.a; return sf::Color( source.r + diff_r * progress, source.g + diff_g * progress, source.b + diff_b * progress, source.a + diff_a * progress); } namespace QuickMedia { enum class HistoryType { YOUTUBE, MANGA }; class HistoryPage : public Page { public: HistoryPage(Program *program, Page *search_page, SearchBar *search_bar, HistoryType history_type) : Page(program), search_page(search_page), search_bar(search_bar), history_type(history_type) {} const char* get_title() const override { return "History"; } PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override { return search_page->submit(title, url, result_tabs); } void on_navigate_to_page(Body *body) override { body->clear_items(); switch(history_type) { case HistoryType::YOUTUBE: program->youtube_get_watch_history(body->items); break; case HistoryType::MANGA: program->manga_get_watch_history(program->get_plugin_name(), body->items); break; } body->filter_search_fuzzy(search_bar->get_text()); } private: Page *search_page; SearchBar *search_bar; HistoryType history_type; }; class RecommendedPage : public Page { public: RecommendedPage(Program *program, Page *search_page) : Page(program), search_page(search_page) {} const char* get_title() const override { return "Recommended"; } PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override { return search_page->submit(title, url, result_tabs); } private: Page *search_page; }; template static bool is_future_ready(const std::future &future) { return future.valid() && future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; } static Path get_recommended_filepath(const char *plugin_name) { Path video_history_dir = get_storage_dir().join("recommended"); if(create_directory_recursive(video_history_dir) != 0) { std::string err_msg = "Failed to create recommended directory "; err_msg += video_history_dir.data; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); exit(1); } Path video_history_filepath = video_history_dir; return video_history_filepath.join(plugin_name).append(".json"); } // TODO: Make asynchronous static void fill_recommended_items_from_json(const char *plugin_name, const Json::Value &recommended_json, BodyItems &body_items) { assert(recommended_json.isObject()); const int64_t recommendations_autodelete_period = 60*60*24*20; // 20 days time_t time_now = time(NULL); int num_items_deleted = 0; std::vector> recommended_items(recommended_json.size()); /* TODO: Optimize member access */ for(auto &member_name : recommended_json.getMemberNames()) { Json::Value recommended_item = recommended_json[member_name]; if(recommended_item.isObject()) { Json::Value recommended_timestamp_json = recommended_item.get("recommended_timestamp", Json::Value::nullSingleton()); Json::Value watched_timestamp_json = recommended_item.get("watched_timestamp", Json::Value::nullSingleton()); if(watched_timestamp_json.isNumeric() && time_now - watched_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNumeric() && time_now - recommended_timestamp_json.asInt64() >= recommendations_autodelete_period) { ++num_items_deleted; } else if(recommended_timestamp_json.isNull() && watched_timestamp_json.isNull()) { ++num_items_deleted; } else { recommended_items.push_back(std::make_pair(member_name, std::move(recommended_item))); } } } if(num_items_deleted > 0) { // TODO: Is there a better way? Json::Value new_recommendations(Json::objectValue); for(auto &recommended : recommended_items) { new_recommendations[recommended.first] = recommended.second; } fprintf(stderr, "Number of old recommendations to delete: %d\n", num_items_deleted); save_json_to_file_atomic(get_recommended_filepath(plugin_name), new_recommendations); } /* TODO: Better algorithm for recommendations */ std::sort(recommended_items.begin(), recommended_items.end(), [](std::pair &a, std::pair &b) { Json::Value &a_timestamp_json = a.second["recommended_timestamp"]; Json::Value &b_timestamp_json = b.second["recommended_timestamp"]; int64_t a_timestamp = 0; int64_t b_timestamp = 0; if(a_timestamp_json.isNumeric()) a_timestamp = a_timestamp_json.asInt64(); if(b_timestamp_json.isNumeric()) b_timestamp = b_timestamp_json.asInt64(); Json::Value &a_recommended_count_json = a.second["recommended_count"]; Json::Value &b_recommended_count_json = b.second["recommended_count"]; int64_t a_recommended_count = 0; int64_t b_recommended_count = 0; if(a_recommended_count_json.isNumeric()) a_recommended_count = a_recommended_count_json.asInt64(); if(b_recommended_count_json.isNumeric()) b_recommended_count = b_recommended_count_json.asInt64(); /* Put frequently recommended videos on top of recommendations. Each recommendation count is worth 5 minutes */ a_timestamp += (300 * a_recommended_count); b_timestamp += (300 * b_recommended_count); return a_timestamp > b_timestamp; }); for(auto it = recommended_items.begin(); it != recommended_items.end(); ++it) { const std::string &recommended_item_id = it->first; Json::Value &recommended_item = it->second; int64_t watched_count = 0; const Json::Value &watched_count_json = recommended_item["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); /* TODO: Improve recommendations with some kind of algorithm. Videos we have seen should be recommended in some cases */ if(watched_count != 0) continue; const Json::Value &recommended_title_json = recommended_item["title"]; if(!recommended_title_json.isString()) continue; auto body_item = BodyItem::create(recommended_title_json.asString()); body_item->url = "https://www.youtube.com/watch?v=" + recommended_item_id; body_item->thumbnail_url = "https://img.youtube.com/vi/" + recommended_item_id + "/hqdefault.jpg"; body_item->thumbnail_size = sf::Vector2i(175, 131); body_items.push_back(std::move(body_item)); // We dont want more than 150 recommendations if(body_items.size() == 150) break; } std::random_shuffle(body_items.begin(), body_items.end()); } Program::Program() : disp(nullptr), window(sf::VideoMode(1280, 720), "QuickMedia", sf::Style::Default), window_size(1280, 720), current_page(PageType::EXIT), image_index(0) { disp = XOpenDisplay(NULL); if (!disp) throw std::runtime_error("Failed to open display to X11 server"); resources_root = "/usr/share/quickmedia/"; if(get_file_type("../../../images/manganelo_logo.png") == FileType::REGULAR) { resources_root = "../../../"; } set_resource_loader_root_path(resources_root.c_str()); if(!circle_mask_shader.loadFromFile(resources_root + "shaders/circle_mask.glsl", sf::Shader::Type::Fragment)) { fprintf(stderr, "Failed to load %s/shaders/circle_mask.glsl", resources_root.c_str()); abort(); } if(!loading_icon.loadFromFile(resources_root + "images/loading_icon.png")) { fprintf(stderr, "Failed to load %s/images/loading_icon.png", resources_root.c_str()); abort(); } loading_icon.setSmooth(true); load_sprite.setTexture(loading_icon, true); sf::Vector2u loading_icon_size = loading_icon.getSize(); load_sprite.setOrigin(loading_icon_size.x * 0.5f, loading_icon_size.y * 0.5f); struct sigaction action; action.sa_handler = sigpipe_handler; sigemptyset(&action.sa_mask); action.sa_flags = 0; sigaction(SIGPIPE, &action, NULL); XSetErrorHandler(x_error_handler); XSetIOErrorHandler(x_io_error_handler); window.setFramerateLimit(0); monitor_hz = get_monitor_max_hz(disp); if(enable_vsync(disp, window.getSystemHandle())) { vsync_set = true; } else { fprintf(stderr, "Failed to enable vsync, fallback to frame limiting\n"); window.setFramerateLimit(monitor_hz); } fprintf(stderr, "Monitor hz: %d\n", monitor_hz); if(create_directory_recursive(get_cache_dir().join("thumbnails")) != 0) { fprintf(stderr, "Failed to create thumbnails directory\n"); } main_thread_id = std::this_thread::get_id(); } Program::~Program() { images_to_upscale_queue.close(); if(image_upscale_thead.joinable()) image_upscale_thead.join(); if(matrix) delete matrix; if(disp) XCloseDisplay(disp); } static void usage() { fprintf(stderr, "usage: QuickMedia [--tor] [--no-video] [--use-system-mpv-config] [--dir ]\n"); fprintf(stderr, "OPTIONS:\n"); fprintf(stderr, " plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, pornhub, youtube, nyaa.si, matrix or file-manager\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --tor Use tor. Disabled by default\n"); fprintf(stderr, " --use-system-mpv-config Use system mpv config instead of no config. Disabled by default\n"); fprintf(stderr, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); fprintf(stderr, " --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); fprintf(stderr, " --dir Set the start directory when using file-manager\n"); fprintf(stderr, "EXAMPLES:\n"); fprintf(stderr, "QuickMedia manganelo\n"); fprintf(stderr, "QuickMedia youtube --tor\n"); } static bool is_manga_plugin(const char *plugin_name) { return strcmp(plugin_name, "manganelo") == 0 || strcmp(plugin_name, "mangatown") == 0 || strcmp(plugin_name, "mangadex") == 0; } int Program::run(int argc, char **argv) { if(argc < 2) { usage(); return -1; } std::string plugin_logo_path; const char *start_dir = nullptr; std::vector tabs; for(int i = 1; i < argc; ++i) { if(!plugin_name) { if(strcmp(argv[i], "manganelo") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/manganelo_logo.png"; } else if(strcmp(argv[i], "mangatown") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/mangatown_logo.png"; } else if(strcmp(argv[i], "mangadex") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/mangadex_logo.png"; } else if(strcmp(argv[i], "youtube") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/yt_logo_rgb_dark_small.png"; } else if(strcmp(argv[i], "pornhub") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/pornhub_logo.png"; plugin_name = argv[i]; } else if(strcmp(argv[i], "4chan") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/4chan_logo.png"; } else if(strcmp(argv[i], "nyaa.si") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/nyaa_si_logo.png"; } else if(strcmp(argv[i], "matrix") == 0) { plugin_name = argv[i]; matrix = new Matrix(); plugin_logo_path = resources_root + "images/matrix_logo.png"; } else if(strcmp(argv[i], "mastodon") == 0 || strcmp(argv[i], "pleroma") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/pleroma_logo.png"; } else if(strcmp(argv[i], "file-manager") == 0) { plugin_name = argv[i]; } } if(strcmp(argv[i], "--tor") == 0) { use_tor = true; } else if(strcmp(argv[i], "--no-video") == 0) { no_video = true; } else if(strcmp(argv[i], "--use-system-mpv-config") == 0) { use_system_mpv_config = true; } else if(strcmp(argv[i], "--upscale-images") == 0) { upscale_image_action = UpscaleImageAction::LOW_RESOLUTION; } else if(strcmp(argv[i], "--upscale-images-force") == 0 || strcmp(argv[i], "--upscale-images-always") == 0) { upscale_image_action = UpscaleImageAction::FORCE; } else if(strcmp(argv[i], "--dir") == 0) { if(i < argc - 1) { start_dir = argv[i + 1]; ++i; } else { fprintf(stderr, "Missing directory after --dir argument\n"); usage(); return -1; } } else if(argv[i][0] == '-') { fprintf(stderr, "Invalid option %s\n", argv[i]); usage(); return -1; } } if(!plugin_name) { fprintf(stderr, "Missing plugin argument\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; } if(upscale_image_action != UpscaleImageAction::NO) { if(!is_manga_plugin(plugin_name)) { fprintf(stderr, "Option --upscale-images/-upscale-images-force is only valid for manganelo, mangatown and mangadex\n"); return -2; } if(!is_program_executable_by_name("waifu2x-ncnn-vulkan")) { fprintf(stderr, "waifu2x-ncnn-vulkan needs to be installed (and accessible from PATH environment variable) when using the --upscale-images/--upscale-images-always option\n"); return -2; } image_upscale_thead = std::thread([this]{ std::optional copy_op_opt; while(true) { copy_op_opt = images_to_upscale_queue.pop_wait(); if(!copy_op_opt) break; CopyOp ©_op = copy_op_opt.value(); Path tmp_file = copy_op.source; tmp_file.append(".tmp.png"); fprintf(stderr, "Upscaling %s\n", copy_op.source.data.c_str()); const char *args[] = { "waifu2x-ncnn-vulkan", "-i", copy_op.source.data.c_str(), "-o", tmp_file.data.c_str(), nullptr }; if(exec_program(args, nullptr, nullptr) != 0) { fprintf(stderr, "Warning: failed to upscale %s with waifu2x-ncnn-vulkan\n", copy_op.source.data.c_str()); // No conversion, but we need the file to have the destination name to see that the operation completed (and read it) if(rename(copy_op.source.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); continue; } if(rename(tmp_file.data.c_str(), copy_op.destination.data.c_str()) != 0) perror(tmp_file.data.c_str()); copy_op.destination.append(".upscaled"); file_overwrite(copy_op.destination.data.c_str(), "1"); } }); } if(strcmp(plugin_name, "file-manager") != 0 && start_dir) { fprintf(stderr, "Option --dir is only valid with file-manager\n"); usage(); return -1; } window.setTitle("QuickMedia - " + std::string(plugin_name)); if(!plugin_logo_path.empty()) { if(!plugin_logo.loadFromFile(plugin_logo_path)) { fprintf(stderr, "Failed to load plugin logo, path: %s\n", plugin_logo_path.c_str()); return -2; } plugin_logo.generateMipmap(); plugin_logo.setSmooth(true); } if(strcmp(plugin_name, "manganelo") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 200)}); auto history_body = create_body(); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{std::move(history_body), std::move(history_page), std::move(search_bar)}); } else if(strcmp(plugin_name, "mangatown") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 200)}); auto history_body = create_body(); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{std::move(history_body), std::move(history_page), std::move(search_bar)}); } else if(strcmp(plugin_name, "mangadex") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 300)}); auto history_body = create_body(); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::MANGA); tabs.push_back(Tab{std::move(history_body), std::move(history_page), std::move(search_bar)}); } else if(strcmp(plugin_name, "nyaa.si") == 0) { auto category_page = std::make_unique(this); auto categories_body = create_body(); category_page->get_categories(categories_body->items); tabs.push_back(Tab{std::move(categories_body), std::move(category_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "4chan") == 0) { auto boards_page = std::make_unique(this, resources_root); auto boards_body = create_body(); boards_page->get_boards(boards_body->items); tabs.push_back(Tab{std::move(boards_body), std::move(boards_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "file-manager") == 0) { auto file_manager_page = std::make_unique(this); if(start_dir && !file_manager_page->set_current_directory(start_dir)) { fprintf(stderr, "Invalid directory provided with --dir: %s\n", start_dir); return -3; } auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_body->items); tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "youtube") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 350)}); auto history_body = create_body(); auto search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto history_page = std::make_unique(this, tabs.front().page.get(), search_bar.get(), HistoryType::YOUTUBE); tabs.push_back(Tab{std::move(history_body), std::move(history_page), std::move(search_bar)}); auto recommended_body = create_body(); fill_recommended_items_from_json(plugin_name, load_recommended_json(), recommended_body->items); tabs.push_back(Tab{std::move(recommended_body), std::make_unique(this, tabs.front().page.get()), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } else if(strcmp(plugin_name, "pornhub") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 500)}); } else if(strcmp(plugin_name, "mastodon") == 0 || strcmp(plugin_name, "pleroma") == 0) { auto pleroma = std::make_shared(); auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this, pleroma), create_search_bar("Search...", 350)}); } if(!tabs.empty()) { page_loop(tabs); return exit_code; } if(matrix) { matrix->use_tor = use_tor; if(matrix->load_cached_session() == PluginResult::OK) { current_page = PageType::CHAT; } else { fprintf(stderr, "Failed to load session cache, redirecting to login page\n"); current_page = PageType::CHAT_LOGIN; chat_login_page(); } after_matrix_login_page(); } return exit_code; } void Program::base_event_handler(sf::Event &event, PageType previous_page, Body *body, SearchBar *search_bar, bool handle_keypress, bool handle_searchbar) { if (event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(handle_keypress && event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = previous_page; } } else if(handle_searchbar) { assert(search_bar); search_bar->on_event(event); } } static std::string base64_encode(const std::string &data) { return base64_url::encode(data); } static std::string base64_decode(const std::string &data) { return base64_url::decode(data); } enum class SearchSuggestionTab { ALL, HISTORY, RECOMMENDED, LOGIN }; // Returns relative time as a string (approximation) static std::string timestamp_to_relative_time_str(time_t seconds) { time_t minutes = seconds / 60; time_t hours = minutes / 60; time_t days = hours / 24; time_t months = days / 30; time_t years = days / 365; if(years >= 1) return std::to_string(years) + " year" + (years == 1 ? "" : "s") + " ago"; else if(months >= 1) return std::to_string(months) + " month" + (months == 1 ? "" : "s") + " ago"; else if(days >= 1) return std::to_string(days) + " day" + (days == 1 ? "" : "s") + " ago"; else if(hours >= 1) return std::to_string(hours) + " hour" + (hours == 1 ? "" : "s") + " ago"; else if(minutes >= 1) return std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s") + " ago"; else return std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s") + " ago"; } // TODO: Make asynchronous static void fill_history_items_from_json(const Json::Value &history_json, BodyItems &history_items) { assert(history_json.isArray()); BodyItems body_items; time_t time_now = time(NULL); for(const Json::Value &item : history_json) { if(!item.isObject()) continue; const Json::Value &video_id = item["id"]; if(!video_id.isString()) continue; std::string video_id_str = video_id.asString(); const Json::Value &title = item["title"]; if(!title.isString()) continue; std::string title_str = title.asString(); const Json::Value ×tamp = item["timestamp"]; if(!timestamp.isNumeric()) continue; auto body_item = BodyItem::create(std::move(title_str)); body_item->url = "https://www.youtube.com/watch?v=" + video_id_str; body_item->thumbnail_url = "https://img.youtube.com/vi/" + video_id_str + "/hqdefault.jpg"; body_item->set_description(timestamp_to_relative_time_str(std::max(0l, time_now - timestamp.asInt64()))); body_item->thumbnail_size = sf::Vector2i(175, 131); body_items.push_back(std::move(body_item)); } for(auto it = body_items.rbegin(), end = body_items.rend(); it != end; ++it) { history_items.push_back(std::move(*it)); } } static Path get_video_history_filepath(const char *plugin_name) { Path video_history_dir = get_storage_dir().join("history"); if(create_directory_recursive(video_history_dir) != 0) { std::string err_msg = "Failed to create video history directory "; err_msg += video_history_dir.data; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); exit(1); } Path video_history_filepath = video_history_dir; return video_history_filepath.join(plugin_name).append(".json"); } // This is not cached because we could have multiple instances of QuickMedia running the same plugin! // TODO: Find a way to optimize this Json::Value Program::load_video_history_json() { Path video_history_filepath = get_video_history_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(video_history_filepath, json_result) || !json_result.isArray()) json_result = Json::Value(Json::arrayValue); return json_result; } // This is not cached because we could have multiple instances of QuickMedia running the same plugin! // TODO: Find a way to optimize this Json::Value Program::load_recommended_json() { Path recommended_filepath = get_recommended_filepath(plugin_name); Json::Value json_result; if(!read_file_as_json(recommended_filepath, json_result) || !json_result.isObject()) json_result = Json::Value(Json::objectValue); return json_result; } void Program::manga_get_watch_history(const char *plugin_name, BodyItems &history_items) { // TOOD: Make generic, instead of checking for plugin Path content_storage_dir = get_storage_dir().join(plugin_name); if(create_directory_recursive(content_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_storage_dir.data, Urgency::CRITICAL); exit(1); } Path credentials_storage_dir = get_storage_dir().join("credentials"); if(create_directory_recursive(credentials_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + credentials_storage_dir.data, Urgency::CRITICAL); exit(1); } // TODO: Make asynchronous for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name](const std::filesystem::path &filepath) { // This can happen when QuickMedia crashes/is killed while writing to storage. // In that case, the storage wont be corrupt but there will be .tmp files. // TODO: Remove these .tmp files if they exist during startup if(filepath.extension() == ".tmp") return true; Path fullpath(filepath.c_str()); Json::Value body; if(!read_file_as_json(fullpath, body) || !body.isObject()) { fprintf(stderr, "Failed to read json file: %s\n", fullpath.data.c_str()); return true; } auto filename = filepath.filename(); const Json::Value &manga_name = body["name"]; if(!filename.empty() && manga_name.isString()) { // TODO: Add thumbnail auto body_item = BodyItem::create(manga_name.asString()); if(strcmp(plugin_name, "manganelo") == 0) body_item->url = "https://manganelo.com/manga/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangadex") == 0) body_item->url = "https://mangadex.org/title/" + base64_decode(filename.string()); else if(strcmp(plugin_name, "mangatown") == 0) body_item->url = "https://mangatown.com/manga/" + base64_decode(filename.string()); else fprintf(stderr, "Error: Not implemented: filename to manga chapter list\n"); history_items.push_back(std::move(body_item)); } return true; }); } void Program::youtube_get_watch_history(BodyItems &history_items) { fill_history_items_from_json(load_video_history_json(), history_items); } static void get_body_dimensions(const sf::Vector2f &window_size, SearchBar *search_bar, sf::Vector2f &body_pos, sf::Vector2f &body_size, bool has_tabs = false) { 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 tab_h = tab_height; if(!has_tabs) tab_h = 0.0f; float search_bottom = search_bar ? search_bar->getBottomWithoutShadow() : 0.0f; body_pos = sf::Vector2f(body_padding_horizontal, search_bottom + body_padding_vertical + tab_h); body_size = sf::Vector2f(body_width, window_size.y - search_bottom - body_padding_vertical - tab_h); } bool Program::is_tor_enabled() { return use_tor; } std::unique_ptr Program::create_body() { auto body = std::make_unique(this, loading_icon); body->thumbnail_mask_shader = &circle_mask_shader; return body; } std::unique_ptr Program::create_search_bar(const std::string &placeholder, int search_delay) { auto search_bar = std::make_unique(&plugin_logo, placeholder); search_bar->text_autosearch_delay = search_delay; return search_bar; } bool Program::load_manga_content_storage(const char *service_name, const std::string &manga_title, const std::string &manga_id) { Path content_storage_dir = get_storage_dir().join(service_name); manga_id_base64 = base64_encode(manga_id); content_storage_file = content_storage_dir.join(manga_id_base64); content_storage_json.clear(); content_storage_json["name"] = manga_title; FileType file_type = get_file_type(content_storage_file); if(file_type == FileType::REGULAR) { if(read_file_as_json(content_storage_file, content_storage_json) && content_storage_json.isObject()) return true; return false; } else { return true; } } void Program::select_file(const std::string &filepath) { puts(filepath.c_str()); selected_files.push_back(filepath); } bool Program::is_window_focused() { return window.hasFocus(); } RoomData* Program::get_current_chat_room() { return current_chat_room; } void Program::set_go_to_previous_page() { go_to_previous_page = true; } static void select_body_item_by_room(Body *body, RoomData *room) { for(size_t i = 0; i < body->items.size(); ++i) { auto &body_item = body->items[i]; if(body_item->userdata == room) { body->set_selected_item(i, false); return; } } } void Program::page_loop(std::vector &tabs, int start_tab_index, std::function after_submit_handler) { if(tabs.empty()) { show_notification("QuickMedia", "No tabs provided!", Urgency::CRITICAL); return; } for(Tab &tab : tabs) { tab.body->thumbnail_max_size = tab.page->get_thumbnail_max_size(); tab.page->on_navigate_to_page(tab.body.get()); } const Json::Value *json_chapters = &Json::Value::nullSingleton(); if(content_storage_json.isObject()) { const Json::Value &chapters_json = content_storage_json["chapters"]; if(chapters_json.isObject()) json_chapters = &chapters_json; } enum class FetchType { SEARCH, LAZY }; struct FetchResult { BodyItems body_items; PluginResult result; }; struct TabAssociatedData { std::string update_search_text; bool search_text_updated = false; FetchStatus fetch_status = FetchStatus::NONE; bool lazy_fetch_finished = false; FetchType fetch_type; bool typing = false; bool fetching_next_page_running = false; int fetched_page = 0; sf::Text search_result_text; std::future fetch_future; std::future next_page_future; }; std::vector tab_associated_data; for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData data; data.search_result_text = sf::Text("", *FontLoader::get_font(FontLoader::FontType::LATIN), 30); tab_associated_data.push_back(std::move(data)); } //std::string autocomplete_text; //bool autocomplete_running = false; double gradient_inc = 0.0; const float gradient_height = 5.0f; sf::Vertex gradient_points[4]; sf::Text tab_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), tab_text_size); int selected_tab = std::min(std::max(0, start_tab_index), (int)tabs.size() - 1); bool loop_running = true; bool redraw = true; auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; auto submit_handler = [this, &after_submit_handler, &json_chapters, &tabs, &selected_tab, &loop_running, &redraw]() { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(!selected_item) return; std::vector new_tabs; PluginResult submit_result = tabs[selected_tab].page->submit(selected_item->get_title(), selected_item->url, new_tabs); if(submit_result != PluginResult::OK) { // TODO: Show the exact cause of error (get error message from curl). show_notification("QuickMedia", std::string("Submit failed for page ") + tabs[selected_tab].page->get_title(), Urgency::CRITICAL); return; } if(after_submit_handler) after_submit_handler(); if(tabs[selected_tab].page->clear_search_after_submit() && tabs[selected_tab].search_bar) { if(!tabs[selected_tab].search_bar->get_text().empty()) { tabs[selected_tab].search_bar->clear(); tabs[selected_tab].search_bar->onTextUpdateCallback(""); } else { int selected_item_index = tabs[selected_tab].body->get_selected_item(); tabs[selected_tab].body->select_first_item(); tabs[selected_tab].body->set_selected_item(selected_item_index, false); } } if(tabs[selected_tab].page->is_single_page()) { tabs[selected_tab].search_bar->clear(); if(new_tabs.size() == 1) tabs[selected_tab].body = std::move(new_tabs[0].body); else loop_running = false; return; } if(new_tabs.empty()) return; for(Tab &tab : tabs) { tab.body->clear_cache(); } if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::MANGA_IMAGES) { select_episode(selected_item, false); Body *chapters_body = tabs[selected_tab].body.get(); chapters_body->filter_search_fuzzy(""); // Needed (or not really) to go to the next chapter when reaching the last page of a chapter MangaImagesPage *manga_images_page = static_cast(new_tabs[0].page.get()); window.setKeyRepeatEnabled(false); while(true) { if(current_page == PageType::IMAGES) { window.setFramerateLimit(20); while(current_page == PageType::IMAGES) { int page_navigation = image_page(manga_images_page, chapters_body); if(page_navigation == -1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_next_item(); select_episode(chapters_body->get_selected(), true); image_index = 99999; // Start at the page that shows we are at the end of the chapter manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } else if(page_navigation == 1) { // TODO: Make this work if the list is sorted differently than from newest to oldest. chapters_body->select_previous_item(); select_episode(chapters_body->get_selected(), true); manga_images_page->change_chapter(chapters_body->get_selected()->get_title(), chapters_body->get_selected()->url); } } if(vsync_set) window.setFramerateLimit(0); else window.setFramerateLimit(monitor_hz); } else if(current_page == PageType::IMAGES_CONTINUOUS) { image_continuous_page(manga_images_page); } else { break; } } window.setKeyRepeatEnabled(true); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::IMAGE_BOARD_THREAD) { current_page = PageType::IMAGE_BOARD_THREAD; image_board_thread_page(static_cast(new_tabs[0].page.get()), new_tabs[0].body.get()); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::VIDEO) { current_page = PageType::VIDEO_CONTENT; video_content_page(static_cast(new_tabs[0].page.get()), selected_item->url, selected_item->get_title(), false); } else if(new_tabs.size() == 1 && new_tabs[0].page->get_type() == PageTypez::CHAT) { current_page = PageType::CHAT; current_chat_room = matrix->get_room_by_id(selected_item->url); chat_page(static_cast(new_tabs[0].page.get()), current_chat_room); //select_body_item_by_room(tabs[selected_tab].body.get(), current_chat_room); current_chat_room = nullptr; } else { page_loop(new_tabs); } for(Tab &tab : tabs) { tab.page->on_navigate_to_page(tab.body.get()); } if(content_storage_json.isObject()) { const Json::Value &chapters_json = content_storage_json["chapters"]; if(chapters_json.isObject()) json_chapters = &chapters_json; } redraw = true; }; for(size_t i = 0; i < tabs.size(); ++i) { Tab &tab = tabs[i]; TabAssociatedData &associated_data = tab_associated_data[i]; if(!tab.search_bar) continue; // tab.search_bar->autocomplete_search_delay = current_plugin->get_autocomplete_delay(); // tab.search_bar->onAutocompleteRequestCallback = [this, &tabs, &selected_tab, &autocomplete_text](const std::string &text) { // if(tabs[selected_tab].body == body && !current_plugin->search_is_filter()) // autocomplete_text = text; // }; tab.search_bar->onTextUpdateCallback = [&associated_data, &tabs, i](const std::string &text) { if(!tabs[i].page->search_is_filter()) { associated_data.update_search_text = text; associated_data.search_text_updated = true; } else { tabs[i].body->filter_search_fuzzy(text); tabs[i].body->select_first_item(); } associated_data.typing = false; }; tab.search_bar->onTextSubmitCallback = [&submit_handler, &associated_data](const std::string&) { if(associated_data.typing) return; submit_handler(); }; } sf::Vector2f body_pos; sf::Vector2f body_size; sf::Event event; const float tab_spacer_height = 0.0f; sf::RectangleShape tab_shade; tab_shade.setFillColor(sf::Color(33, 38, 44)); sf::RoundedRectangleShape tab_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10); tab_background.setFillColor(tab_selected_color); sf::Clock frame_timer; while (window.isOpen() && loop_running) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { if (event.type == sf::Event::Closed) { window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } if(tabs[selected_tab].search_bar) { tabs[selected_tab].search_bar->on_event(event); } 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::Down || event.key.code == sf::Keyboard::PageDown || event.key.code == sf::Keyboard::End || (event.key.control && event.key.code == sf::Keyboard::J)) { bool hit_bottom = false; switch(event.key.code) { case sf::Keyboard::Down: hit_bottom = !tabs[selected_tab].body->select_next_item(); break; case sf::Keyboard::J: hit_bottom = !tabs[selected_tab].body->select_next_item(); break; case sf::Keyboard::PageDown: hit_bottom = !tabs[selected_tab].body->select_next_page(); break; case sf::Keyboard::End: tabs[selected_tab].body->select_last_item(); hit_bottom = true; break; default: hit_bottom = false; break; } if(hit_bottom && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].fetching_next_page_running && tabs[selected_tab].page && (!tabs[selected_tab].page->is_lazy_fetch_page() || tab_associated_data[selected_tab].lazy_fetch_finished)) { gradient_inc = 0.0; tab_associated_data[selected_tab].fetching_next_page_running = true; int next_page = tab_associated_data[selected_tab].fetched_page + 1; Page *page = tabs[selected_tab].page.get(); std::string update_search_text = tab_associated_data[selected_tab].update_search_text; tab_associated_data[selected_tab].next_page_future = std::async(std::launch::async, [update_search_text, next_page, page]() { BodyItems result_items; if(page->get_page(update_search_text, next_page, result_items) != PluginResult::OK) fprintf(stderr, "Failed to get next page (page %d)\n", next_page); return result_items; }); } } else if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { tabs[selected_tab].body->select_previous_item(); } else if(event.key.code == sf::Keyboard::PageUp) { tabs[selected_tab].body->select_previous_page(); } else if(event.key.code == sf::Keyboard::Home) { tabs[selected_tab].body->select_first_item(); } else if(event.key.code == sf::Keyboard::Escape) { goto page_end; } else if(event.key.code == sf::Keyboard::Left || (event.key.control && event.key.code == sf::Keyboard::H)) { if(selected_tab > 0) { tabs[selected_tab].body->clear_cache(); --selected_tab; redraw = true; } } else if(event.key.code == sf::Keyboard::Right || (event.key.control && event.key.code == sf::Keyboard::L)) { if(selected_tab < (int)tabs.size() - 1) { tabs[selected_tab].body->clear_cache(); ++selected_tab; redraw = true; } } else if(event.key.code == sf::Keyboard::Tab) { if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->set_to_autocomplete(); } else if(event.key.code == sf::Keyboard::Enter) { if(!tabs[selected_tab].search_bar) submit_handler(); } else if(event.key.code == sf::Keyboard::T && event.key.control) { BodyItem *selected_item = tabs[selected_tab].body->get_selected(); if(selected_item && tabs[selected_tab].page && tabs[selected_tab].page->is_trackable()) { TrackablePage *trackable_page = static_cast(tabs[selected_tab].page.get()); TrackResult track_result = trackable_page->track(selected_item->get_title()); // TODO: Show proper error message when this fails. For example if we are already tracking the manga if(track_result == TrackResult::OK) { show_notification("QuickMedia", "You are now tracking \"" + trackable_page->content_title + "\" after \"" + selected_item->get_title() + "\"", Urgency::LOW); } else { show_notification("QuickMedia", "Failed to track media \"" + trackable_page->content_title + "\", chapter: \"" + selected_item->get_title() + "\"", Urgency::CRITICAL); } } } } } if(redraw) { redraw = false; if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->onWindowResize(window_size); // TODO: Dont show tabs if there is only one tab get_body_dimensions(window_size, tabs[selected_tab].search_bar.get(), body_pos, body_size, true); gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = window_size.y - gradient_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = window_size.y - gradient_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = window_size.y; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = window_size.y; } if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->update(); if(tabs[selected_tab].page->is_lazy_fetch_page() && tab_associated_data[selected_tab].fetch_status == FetchStatus::NONE && !tab_associated_data[selected_tab].lazy_fetch_finished) { tab_associated_data[selected_tab].fetch_status = FetchStatus::LOADING; tab_associated_data[selected_tab].fetch_type = FetchType::LAZY; tab_associated_data[selected_tab].search_result_text.setString("Fetching page..."); LazyFetchPage *lazy_fetch_page = static_cast(tabs[selected_tab].page.get()); tab_associated_data[selected_tab].fetch_future = std::async(std::launch::async, [lazy_fetch_page]() { FetchResult fetch_result; fetch_result.result = lazy_fetch_page->lazy_fetch(fetch_result.body_items); return fetch_result; }); } for(size_t i = 0; i < tabs.size(); ++i) { TabAssociatedData &associated_data = tab_associated_data[i]; tabs[i].page->update(); if(associated_data.fetching_next_page_running && is_future_ready(associated_data.next_page_future)) { BodyItems new_body_items = associated_data.next_page_future.get(); fprintf(stderr, "Finished fetching page %d, num new messages: %zu\n", associated_data.fetched_page + 1, new_body_items.size()); size_t num_new_messages = new_body_items.size(); if(num_new_messages > 0) { tabs[i].body->append_items(std::move(new_body_items)); associated_data.fetched_page++; } associated_data.fetching_next_page_running = false; } if(associated_data.search_text_updated && associated_data.fetch_status == FetchStatus::NONE && !associated_data.fetching_next_page_running) { std::string update_search_text = associated_data.update_search_text; associated_data.search_text_updated = false; associated_data.fetch_status = FetchStatus::LOADING; associated_data.fetch_type = FetchType::SEARCH; associated_data.search_result_text.setString("Searching..."); Page *page = tabs[i].page.get(); associated_data.fetch_future = std::async(std::launch::async, [update_search_text, page]() { FetchResult fetch_result; fetch_result.result = search_result_to_plugin_result(page->search(update_search_text, fetch_result.body_items)); return fetch_result; }); } if(associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::SEARCH && is_future_ready(associated_data.fetch_future)) { if(!associated_data.search_text_updated) { FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->items = std::move(fetch_result.body_items); tabs[i].body->select_first_item(); associated_data.fetched_page = 0; if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.setString("Search failed!"); else if(tabs[i].body->items.empty()) associated_data.search_result_text.setString("No results found"); else associated_data.search_result_text.setString(""); } else { associated_data.fetch_future.get(); } associated_data.fetch_status = FetchStatus::NONE; } if(associated_data.fetch_status == FetchStatus::LOADING && associated_data.fetch_type == FetchType::LAZY && is_future_ready(associated_data.fetch_future)) { associated_data.lazy_fetch_finished = true; FetchResult fetch_result = associated_data.fetch_future.get(); tabs[i].body->items = std::move(fetch_result.body_items); tabs[i].body->filter_search_fuzzy(tabs[i].search_bar->get_text()); if(fetch_result.result != PluginResult::OK) associated_data.search_result_text.setString("Failed to fetch page!"); else if(tabs[i].body->items.empty()) associated_data.search_result_text.setString("No results found"); else associated_data.search_result_text.setString(""); associated_data.fetch_status = FetchStatus::NONE; } } window.clear(back_color); if(tabs[selected_tab].search_bar) tabs[selected_tab].search_bar->draw(window, false); { float shade_extra_height = 0.0f; if(!tabs[selected_tab].search_bar) shade_extra_height = 10.0f; const float width_per_tab = window_size.x / tabs.size(); tab_background.setSize(sf::Vector2f(std::floor(width_per_tab - tab_margin_x * 2.0f), tab_height)); float tab_vertical_offset = tabs[selected_tab].search_bar ? tabs[selected_tab].search_bar->getBottomWithoutShadow() : 0.0f; tabs[selected_tab].body->draw(window, body_pos, body_size, *json_chapters); const float tab_y = tab_spacer_height + std::floor(tab_vertical_offset + tab_height * 0.5f - (tab_text_size + 5.0f) * 0.5f) + shade_extra_height; tab_shade.setPosition(0.0f, tab_spacer_height + std::floor(tab_vertical_offset)); tab_shade.setSize(sf::Vector2f(window_size.x, shade_extra_height + tab_height + 10.0f)); window.draw(tab_shade); int i = 0; // TODO: Dont show tabs if there is only one tab for(Tab &tab : tabs) { if(i == selected_tab) { tab_background.setPosition(std::floor(i * width_per_tab + tab_margin_x), tab_spacer_height + std::floor(tab_vertical_offset) + shade_extra_height); window.draw(tab_background); } const float center = (i * width_per_tab) + (width_per_tab * 0.5f); // TODO: Optimize. Only set once for each tab! tab_text.setString(tab.page->get_title()); tab_text.setPosition(std::floor(center - tab_text.getLocalBounds().width * 0.5f), tab_y); window.draw(tab_text); ++i; } } if(tab_associated_data[selected_tab].fetching_next_page_running) { double progress = 0.5 + std::sin(std::fmod(gradient_inc, 360.0) * 0.017453292519943295 - 1.5707963267948966*0.5) * 0.5; gradient_inc += (frame_time_ms * 0.5); sf::Color bottom_color = interpolate_colors(back_color, sf::Color(175, 180, 188), progress); gradient_points[0].color = back_color; gradient_points[1].color = back_color; gradient_points[2].color = bottom_color; gradient_points[3].color = bottom_color; window.draw(gradient_points, 4, sf::Quads); // Note: sf::Quads doesn't work with egl } if(!tab_associated_data[selected_tab].search_result_text.getString().isEmpty()) { auto search_result_text_bounds = tab_associated_data[selected_tab].search_result_text.getLocalBounds(); tab_associated_data[selected_tab].search_result_text.setPosition( std::floor(body_pos.x + body_size.x * 0.5f - search_result_text_bounds.width * 0.5f), std::floor(body_pos.y + body_size.y * 0.5f - search_result_text_bounds.height * 0.5f)); window.draw(tab_associated_data[selected_tab].search_result_text); } if(matrix && !matrix->is_initial_sync_finished()) { // if(is_login_sync) { load_sprite.setPosition(body_pos.x + body_size.x * 0.5f, body_pos.y + body_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); // } std::string err_msg; if(matrix->did_initial_sync_fail(err_msg)) { show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); window.close(); goto page_end; } } window.display(); if(go_to_previous_page) { go_to_previous_page = false; goto page_end; } } page_end: // TODO: This is needed, because you cant terminate futures without causing an exception to be thrown and its not safe anyways. // Need a way to solve this, we dont want to wait for a search to finish when navigating backwards for(TabAssociatedData &associated_data : tab_associated_data) { if(associated_data.next_page_future.valid()) associated_data.next_page_future.get(); if(associated_data.fetch_future.valid()) associated_data.fetch_future.get(); } } static bool youtube_url_extract_id(const std::string &youtube_url, std::string &youtube_video_id) { size_t index = youtube_url.find("youtube.com/watch?v="); if(index != std::string::npos) { index += 20; size_t end_index = youtube_url.find("&", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } index = youtube_url.find("youtu.be/"); if(index != std::string::npos) { index += 9; size_t end_index = youtube_url.find("?", index); if(end_index == std::string::npos) end_index = youtube_url.size(); youtube_video_id = youtube_url.substr(index, end_index - index); return true; } return false; } static int watch_history_get_item_by_id(const Json::Value &video_history_json, const char *id) { assert(video_history_json.isArray()); int index = -1; for(const Json::Value &item : video_history_json) { ++index; if(!item.isObject()) continue; const Json::Value &id_json = item["id"]; if(!id_json.isString()) continue; if(strcmp(id, id_json.asCString()) == 0) return index; } return -1; } enum class WindowFullscreenState { UNSET, SET, TOGGLE }; static bool window_set_fullscreen(Display *display, Window window, WindowFullscreenState state) { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { fprintf(stderr, "Failed to fullscreen window. The window manager doesn't support fullscreening windows.\n"); return false; } XEvent xev; xev.type = ClientMessage; xev.xclient.window = window; xev.xclient.message_type = wm_state_atom; xev.xclient.format = 32; xev.xclient.data.l[0] = (int)state; xev.xclient.data.l[1] = wm_state_fullscreen_atom; xev.xclient.data.l[2] = 0; xev.xclient.data.l[3] = 1; xev.xclient.data.l[4] = 0; if(!XSendEvent(display, XDefaultRootWindow(display), 0, SubstructureRedirectMask | SubstructureNotifyMask, &xev)) { fprintf(stderr, "Failed to fullscreen window\n"); return false; } XFlush(display); return true; } void Program::save_recommendations_from_related_videos(const std::string &video_url, const std::string &video_title, const BodyItems &related_media_body_items) { std::string video_id; if(!youtube_url_extract_id(video_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_url; err_msg + ", video wont be saved in recommendations"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value recommended_json = load_recommended_json(); time_t time_now = time(NULL); Json::Value &existing_recommended_json = recommended_json[video_id]; if(existing_recommended_json.isObject()) { int64_t watched_count = 0; Json::Value &watched_count_json = existing_recommended_json["watched_count"]; if(watched_count_json.isNumeric()) watched_count = watched_count_json.asInt64(); existing_recommended_json["watched_count"] = watched_count + 1; existing_recommended_json["watched_timestamp"] = time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = video_title; new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; new_content_object["watched_count"] = 1; new_content_object["watched_timestamp"] = time_now; recommended_json[video_id] = std::move(new_content_object); } int saved_recommendation_count = 0; for(const auto &body_item : related_media_body_items) { std::string recommended_video_id; if(youtube_url_extract_id(body_item->url, recommended_video_id)) { Json::Value &existing_recommendation = recommended_json[recommended_video_id]; if(existing_recommendation.isObject()) { int64_t recommended_count = 0; Json::Value &count_json = existing_recommendation["recommended_count"]; if(count_json.isNumeric()) recommended_count = count_json.asInt64(); existing_recommendation["recommended_count"] = recommended_count + 1; existing_recommendation["recommended_timestamp"] = time_now; } else { Json::Value new_content_object(Json::objectValue); new_content_object["title"] = body_item->get_title(); new_content_object["recommended_timestamp"] = time_now; new_content_object["recommended_count"] = 1; recommended_json[recommended_video_id] = std::move(new_content_object); saved_recommendation_count++; /* TODO: Save more than the first 3 video that hasn't been watched yet? */ if(saved_recommendation_count == 3) break; } } else { fprintf(stderr, "Failed to extract id of youtube url %s, video wont be saved in recommendations\n", video_url.c_str()); } } save_json_to_file_atomic(get_recommended_filepath(plugin_name), recommended_json); } static const char *useragent_str = "user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"; static int accumulate_string_limit_head(char *data, int size, void *userdata) { std::string *str = (std::string*)userdata; str->append(data, size); if(str->size() >= 42) return 1; return 0; } static bool video_url_is_non_streamable_mp4(const char *url) { std::string result; const char *args[] = { "curl", "-sLf", "-r", "0-40", "-H", useragent_str, "--", url, nullptr }; exec_program(args, accumulate_string_limit_head, &result, 42); return (result.size() >= 42) && (memcmp(&result[4], "ftypisom", 8) == 0 || memcmp(&result[4], "ftypmp42", 8) == 0 || memcmp(&result[4], "ftymp42", 7) == 0 || memcmp(&result[4], "fty3gp5", 7) == 0) && (memmem(&result[0], result.size(), "moov", 4) == NULL); } const char* Program::get_plugin_name() const { return plugin_name; } TaskResult Program::run_task_with_loading_screen(std::function callback) { assert(std::this_thread::get_id() == main_thread_id); std::promise result_promise; std::future future = result_promise.get_future(); std::thread task_thread([](std::promise &&promise, std::function callback) { promise.set_value(callback()); }, std::move(result_promise), std::move(callback)); window_size.x = window.getSize().x; window_size.y = window.getSize().y; sf::Event event; while(window.isOpen()) { while(window.pollEvent(event)) { if(event.type == sf::Event::Closed) { program_kill_in_thread(task_thread.get_id()); task_thread.join(); future.get(); current_page = PageType::EXIT; window.close(); return TaskResult::CANCEL; } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { program_kill_in_thread(task_thread.get_id()); task_thread.join(); future.get(); return TaskResult::CANCEL; } } if(is_future_ready(future)) { task_thread.join(); if(!future.get()) return TaskResult::FALSE; break; } window.clear(back_color); load_sprite.setPosition(window_size.x * 0.5f, window_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); window.display(); } return TaskResult::TRUE; } static bool video_url_supports_timestamp(const std::string &url) { std::string dummy_id; if(youtube_url_extract_id(url, dummy_id)) return true; if(url.find("pornhub.com/view_video.php") != std::string::npos) return true; return false; } #define CLEANMASK(mask) ((mask) & (ShiftMask|ControlMask|Mod1Mask|Mod4Mask|Mod5Mask)) void Program::video_content_page(VideoPage *video_page, std::string video_url, std::string video_title, bool download_if_streaming_fails) { sf::Clock time_watched_timer; bool added_recommendations = false; bool video_loaded = false; const bool is_youtube = strcmp(plugin_name, "youtube") == 0; const bool is_matrix = strcmp(plugin_name, "matrix") == 0; PageType previous_page = pop_page_stack(); bool video_url_is_local = false; if(download_if_streaming_fails) { Path video_cache_dir = get_cache_dir().join("video"); Path video_path = video_cache_dir; SHA256 sha256; sha256.add(video_url.data(), video_url.size()); video_path.join(sha256.getHash()); if(get_file_type(video_path) == FileType::REGULAR) { fprintf(stderr, "%s is found in cache. Playing from cache...\n", video_url.c_str()); video_url = std::move(video_path.data); video_url_is_local = true; } else { TaskResult video_is_not_streamble_result = run_task_with_loading_screen([video_url]() { return video_url_is_non_streamable_mp4(video_url.c_str()); }); if(video_is_not_streamble_result == TaskResult::TRUE) { fprintf(stderr, "%s is detected to be a non-streamable mp4 file, downloading it before playing it...\n", video_url.c_str()); if(create_directory_recursive(video_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create video cache directory", Urgency::CRITICAL); current_page = previous_page; return; } TaskResult download_file_result = run_task_with_loading_screen([this, &video_path, video_url]() { return download_to_file(video_url, video_path.data, {}, use_tor, true) == DownloadResult::OK; }); switch(download_file_result) { case TaskResult::TRUE: { video_url = std::move(video_path.data); video_url_is_local = true; break; } case TaskResult::FALSE: { show_notification("QuickMedia", "Failed to download " + video_url, Urgency::CRITICAL); current_page = previous_page; return; } case TaskResult::CANCEL: { current_page = previous_page; return; } } } else if(video_is_not_streamble_result == TaskResult::CANCEL) { current_page = previous_page; return; } } } time_watched_timer.restart(); std::unique_ptr video_player; BodyItems related_videos; std::string channel_url; sf::WindowHandle video_player_window = None; auto on_window_create = [this, &video_player_window](sf::WindowHandle _video_player_window) mutable { video_player_window = _video_player_window; XSelectInput(disp, video_player_window, KeyPressMask | PointerMotionMask); XSync(disp, False); }; std::function video_event_callback; auto load_video_error_check = [this, &related_videos, &channel_url, &video_url, &video_title, &video_player, previous_page, &time_watched_timer, &video_loaded, &added_recommendations, video_page, &video_event_callback, &on_window_create, &video_player_window, is_matrix](bool resume_video) mutable { time_watched_timer.restart(); video_loaded = false; video_player_window = None; added_recommendations = false; watched_videos.insert(video_url); std::string video_url_converted; std::string dummy_id; if(youtube_url_extract_id(video_url, dummy_id)) { video_url_converted = "ytdl://" + video_url; } else { video_url_converted = video_url; } video_player = std::make_unique(use_tor, no_video, use_system_mpv_config, resume_video, is_matrix, video_event_callback, on_window_create, resources_root); VideoPlayer::Error err = video_player->load_video(video_url_converted.c_str(), window.getSystemHandle(), plugin_name); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_url; show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; } else { channel_url.clear(); // TODO: Make async. What if the server is frozen? related_videos = video_page->get_related_media(video_url, channel_url); // TODO: Make this also work for other video plugins if(strcmp(plugin_name, "youtube") != 0) return; std::string video_id; if(!youtube_url_extract_id(video_url, video_id)) { std::string err_msg = "Failed to extract id of youtube url "; err_msg += video_url; err_msg + ", video wont be saved in history"; show_notification("QuickMedia", err_msg.c_str(), Urgency::LOW); return; } Json::Value video_history_json = load_video_history_json(); int existing_index = watch_history_get_item_by_id(video_history_json, video_id.c_str()); if(existing_index != -1) { Json::Value removed; /* TODO: Optimize. This is slow */ video_history_json.removeIndex(existing_index, &removed); } time_t time_now = time(NULL); Json::Value new_content_object(Json::objectValue); new_content_object["id"] = video_id; new_content_object["title"] = video_title; new_content_object["timestamp"] = time_now; video_history_json.append(std::move(new_content_object)); Path video_history_filepath = get_video_history_filepath(plugin_name); save_json_to_file_atomic(video_history_filepath, video_history_json); } }; video_event_callback = [&video_player, &time_watched_timer, &video_loaded](const char *event_name) mutable { if(strcmp(event_name, "pause") == 0) { //double time_remaining = 9999.0; //if(video_player->get_time_remaining(&time_remaining) == VideoPlayer::Error::OK && time_remaining <= 1.0) // end_of_file = true; } else if(strcmp(event_name, "playback-restart") == 0) { //video_player->set_paused(false); } else if(strcmp(event_name, "file-loaded") == 0) { time_watched_timer.restart(); if(!video_loaded) video_player->set_property("no-resume-playback", true); video_loaded = true; } else if(strcmp(event_name, "video-reconfig") == 0 || strcmp(event_name, "audio-reconfig") == 0) { if(!video_loaded) { video_loaded = true; time_watched_timer.restart(); } } fprintf(stderr, "event name: %s\n", event_name); }; load_video_error_check(false); sf::Event event; XEvent xev; bool cursor_visible = true; sf::Clock cursor_hide_timer; auto save_video_url_to_clipboard = [&video_url_is_local, &video_url, &video_player_window, &video_player]() { if(!video_player_window || video_url_is_local) return; if(video_url_supports_timestamp(video_url)) { // TODO: Remove timestamp (&t= or ?t=) from video_url double time_in_file; if(video_player->get_time_in_file(&time_in_file) != VideoPlayer::Error::OK) time_in_file = 0.0; sf::Clipboard::setString(video_url + "&t=" + std::to_string((int)time_in_file)); } else { sf::Clipboard::setString(video_url); } }; while (current_page == PageType::VIDEO_CONTENT && window.isOpen()) { while (window.pollEvent(event)) { if (event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } else if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { current_page = previous_page; } } 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 || pressed_keysym == XK_BackSpace || pressed_keysym == XK_q) { current_page = previous_page; break; } else if(pressed_keysym == XK_f && pressing_ctrl) { window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::TOGGLE); } else if(pressed_keysym == XK_r && pressing_ctrl) { if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; int search_delay = 0; auto search_page = video_page->create_search_page(this, search_delay); auto related_videos_page = video_page->create_related_videos_page(this, video_url, video_title); auto channels_page = video_page->create_channels_page(this, channel_url); if(search_page || related_videos_page || channels_page) { XUnmapWindow(disp, video_player_window); XSync(disp, False); std::vector tabs; if(search_page) { tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", search_delay)}); } if(related_videos_page) { auto related_videos_body = create_body(); related_videos_body->items = related_videos; tabs.push_back(Tab{std::move(related_videos_body), std::move(related_videos_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } if(channels_page) { tabs.push_back(Tab{create_body(), std::move(channels_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); } bool page_changed = false; page_loop(tabs, 1, [this, &video_player, &page_changed]() { window.setMouseCursorVisible(true); if(video_player) { video_player->quit_and_save_watch_later(); while(true) { VideoPlayer::Error update_err = video_player->update(); if(update_err != VideoPlayer::Error::OK) break; std::this_thread::sleep_for(std::chrono::milliseconds(20)); } video_player.reset(); } page_changed = true; }); if(page_changed) { current_page = PageType::VIDEO_CONTENT; //video_player = std::make_unique(use_tor, no_video, use_system_mpv_config, true, video_event_callback, on_window_create, resources_root); load_video_error_check(true); } else { XMapWindow(disp, video_player_window); XSync(disp, False); } } } else if(pressed_keysym == XK_c && pressing_ctrl) { save_video_url_to_clipboard(); } } if(video_player_window && XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)) { while(XCheckTypedWindowEvent(disp, video_player_window, MotionNotify, &xev)); cursor_hide_timer.restart(); if(!cursor_visible) window.setMouseCursorVisible(true); cursor_visible = true; } VideoPlayer::Error update_err = video_player->update(); if(update_err == VideoPlayer::Error::FAIL_TO_CONNECT_TIMEOUT) { show_notification("QuickMedia", "Failed to connect to mpv ipc after 10 seconds", Urgency::CRITICAL); current_page = previous_page; break; } else if(update_err == VideoPlayer::Error::EXITED && video_player->exit_status == 0 && !is_matrix) { std::string new_video_url; std::string new_video_title; // Find video that hasn't been played before in this video session // TODO: Remove duplicates for(auto it = related_videos.begin(), end = related_videos.end(); it != end; ++it) { if(watched_videos.find((*it)->url) == watched_videos.end()) { new_video_url = (*it)->url; new_video_title = (*it)->get_title(); break; } } // If there are no videos to play, then dont play any... if(new_video_url.empty()) { show_notification("QuickMedia", "No more related videos to play"); current_page = previous_page; break; } video_url = std::move(new_video_url); video_title = std::move(new_video_title); load_video_error_check(false); } else if(update_err != VideoPlayer::Error::OK) { show_notification("QuickMedia", "Failed to play the video (error code " + std::to_string((int)update_err) + ")", Urgency::CRITICAL); current_page = previous_page; break; } if(!video_loaded) { window.clear(back_color); load_sprite.setPosition(window_size.x * 0.5f, window_size.y * 0.5f); load_sprite.setRotation(load_sprite_timer.getElapsedTime().asSeconds() * 400.0); window.draw(load_sprite); window.display(); continue; } /* Only save recommendations for the video if we have been watching it for 15 seconds */ if(is_youtube && video_loaded && !added_recommendations && time_watched_timer.getElapsedTime().asSeconds() >= 15) { added_recommendations = true; save_recommendations_from_related_videos(video_url, video_title, related_videos); } if(video_player_window) { if(!cursor_visible) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } const int UI_HIDE_TIMEOUT = 2500; if(cursor_hide_timer.getElapsedTime().asMilliseconds() > UI_HIDE_TIMEOUT) { cursor_visible = false; window.setMouseCursorVisible(false); } } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } window.setMouseCursorVisible(true); window_set_fullscreen(disp, window.getSystemHandle(), WindowFullscreenState::UNSET); auto window_size_u = window.getSize(); window_size.x = window_size_u.x; window_size.y = window_size_u.y; } void Program::select_episode(BodyItem *item, bool start_from_beginning) { image_index = 0; switch(image_view_mode) { case ImageViewMode::SINGLE: current_page = PageType::IMAGES; break; case ImageViewMode::SCROLL: current_page = PageType::IMAGES_CONTINUOUS; break; } if(start_from_beginning) return; const Json::Value &json_chapters = content_storage_json["chapters"]; if(json_chapters.isObject()) { const Json::Value &json_chapter = json_chapters[item->get_title()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) image_index = current.asInt() - 1; } } } // TODO: Remove PageType Program::pop_page_stack() { if(!page_stack.empty()) { PageType previous_page = page_stack.top(); page_stack.pop(); return previous_page; } return PageType::EXIT; } // TODO: Optimize this somehow. One image alone uses more than 20mb ram! Total ram usage for viewing one image // becomes 40mb (private memory, almost 100mb in total!) Unacceptable! Program::LoadImageResult Program::load_image_by_index(int image_index, sf::Texture &image_texture, sf::String &error_message) { Path image_path = content_cache_dir; image_path.join(std::to_string(image_index + 1)); bool upscaled_ok = true; if(upscale_image_action != UpscaleImageAction::NO) { Path image_filepath_upcaled = image_path; image_filepath_upcaled.append(".upscaled"); if(get_file_type(image_filepath_upcaled) == FileType::FILE_NOT_FOUND && image_upscale_status[image_index] == 0) upscaled_ok = false; } if(get_file_type(image_path) == FileType::REGULAR && upscaled_ok) { sf::Image image; if(load_image_from_file(image, image_path.data)) { if(image_texture.loadFromImage(image)) { image_texture.setSmooth(true); image_texture.generateMipmap(); return LoadImageResult::OK; } else { error_message = std::string("Failed to load image for page ") + std::to_string(image_index + 1); return LoadImageResult::FAILED; } } else { show_notification("QuickMedia", "Failed to load image for page " + std::to_string(image_index + 1) + ". Image filepath: " + image_path.data, Urgency::CRITICAL); error_message = std::string("Failed to load image for page ") + std::to_string(image_index + 1); return LoadImageResult::FAILED; } } else { error_message = "Downloading page " + std::to_string(image_index + 1) + "..."; return LoadImageResult::DOWNLOAD_IN_PROGRESS; } } void Program::download_chapter_images_if_needed(MangaImagesPage *images_page) { if(downloading_chapter_url == images_page->get_url()) return; downloading_chapter_url = images_page->get_url(); if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_cancel = true; image_download_future.get(); image_download_cancel = false; } Path content_cache_dir_ = content_cache_dir; image_download_future = std::async(std::launch::async, [images_page, content_cache_dir_, this]() { // TODO: Download images in parallel int page = 1; images_page->for_each_page_in_chapter([content_cache_dir_, &page, images_page, this](const std::string &url) { if(image_download_cancel) return false; int image_index = page - 1; // TODO: Save image with the file extension that url says it has? right now the file is saved without any extension Path image_filepath = content_cache_dir_; image_filepath.join(std::to_string(page++)); bool upscaled_ok = true; if(upscale_image_action != UpscaleImageAction::NO) { Path image_filepath_upcaled = image_filepath; image_filepath_upcaled.append(".upscaled"); if(get_file_type(image_filepath_upcaled) == FileType::FILE_NOT_FOUND && image_upscale_status[image_index] == 0) upscaled_ok = false; } if(get_file_type(image_filepath) != FileType::FILE_NOT_FOUND && upscaled_ok) return true; std::vector extra_args; if(strcmp(images_page->get_service_name(), "manganelo") == 0) { extra_args = { CommandArg { "-H", "accept: image/png,image/*,*/*;q=0.8" }, CommandArg { "-H", "sec-fetch-site: cross-site" }, CommandArg { "-H", "sec-fetch-mode: no-cors" }, CommandArg { "-H", "sec-fetch-dest: image" }, CommandArg { "-H", "referer: https://manganelo.com/" } }; } // TODO: Download directly to file instead. TODO: Move to page std::string image_content; if(download_to_string(url, image_content, extra_args, is_tor_enabled(), true) != DownloadResult::OK || image_content.size() <= 255) { if(strcmp(images_page->get_service_name(), "manganelo") == 0) { bool try_backup_url = false; std::string new_url = url; if(string_replace_all(new_url, "s3.mkklcdnv3.com", "bu.mkklcdnbuv1.com") > 0) { try_backup_url = true; } else { try_backup_url = (string_replace_all(new_url, "s41.mkklcdnv41.com", "bu.mkklcdnbuv1.com") > 0); } if(try_backup_url) { image_content.clear(); if(download_to_string(new_url, image_content, extra_args, is_tor_enabled(), true) != DownloadResult::OK || image_content.size() <= 255) { show_notification("QuickMedia", "Failed to download image: " + new_url, Urgency::CRITICAL); return true; } } else { show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); return true; } } else { show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); return true; } } Path image_filepath_tmp(image_filepath.data + ".tmp"); if(file_overwrite(image_filepath_tmp, image_content) != 0) { show_notification("QuickMedia", "Failed to save image to file: " + image_filepath_tmp.data, Urgency::CRITICAL); return true; } bool rename_immediately = true; if(upscale_image_action == UpscaleImageAction::LOW_RESOLUTION) { int screen_width, screen_height; get_screen_resolution(disp, &screen_width, &screen_height); int image_width, image_height; if(image_get_resolution(image_filepath_tmp, &image_width, &image_height)) { if(image_height < screen_height * 0.65) { rename_immediately = false; CopyOp copy_op; copy_op.source = image_filepath_tmp; copy_op.destination = image_filepath; images_to_upscale_queue.push(std::move(copy_op)); } else { fprintf(stderr, "Info: not upscaling %s because the file is already large on your monitor (screen height: %d, image height: %d)\n", image_filepath_tmp.data.c_str(), screen_height, image_height); image_upscale_status[image_index] = 1; } } else { fprintf(stderr, "Warning: failed to upscale %s because QuickMedia failed to recognize the resolution of the image\n", image_filepath_tmp.data.c_str()); image_upscale_status[image_index] = 1; } } else if(upscale_image_action == UpscaleImageAction::FORCE) { rename_immediately = false; CopyOp copy_op; copy_op.source = image_filepath_tmp; copy_op.destination = image_filepath; images_to_upscale_queue.push(std::move(copy_op)); } if(rename_immediately) { if(rename(image_filepath_tmp.data.c_str(), image_filepath.data.c_str()) != 0) { perror(image_filepath_tmp.data.c_str()); show_notification("QuickMedia", "Failed to save image to file: " + image_filepath.data, Urgency::CRITICAL); return true; } } return true; }); }); } int Program::image_page(MangaImagesPage *images_page, Body *chapters_body) { int page_navigation = 0; image_download_cancel = false; sf::Texture image_texture; sf::Sprite image; sf::Text error_message("", *FontLoader::get_font(FontLoader::FontType::LATIN), 30); error_message.setFillColor(sf::Color::White); bool download_in_progress = false; content_cache_dir = get_cache_dir().join(images_page->get_service_name()).join(manga_id_base64).join(base64_encode(images_page->get_chapter_name())); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return 0; } int num_images = 0; if(images_page->get_number_of_images(num_images) != ImageResult::OK) { show_notification("QuickMedia", "Failed to get number of images", Urgency::CRITICAL); current_page = pop_page_stack(); return 0; } image_index = std::min(image_index, num_images); if(num_images != (int)image_upscale_status.size()) image_upscale_status.resize(num_images); download_chapter_images_if_needed(images_page); if(image_index < num_images) { sf::String error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) image.setTexture(image_texture, true); else if(load_image_result == LoadImageResult::DOWNLOAD_IN_PROGRESS) download_in_progress = true; error_message.setString(error_msg); } else if(image_index == num_images) { error_message.setString("End of " + images_page->get_chapter_name()); } // TODO: Dont do this every time we change page? Json::Value &json_chapters = content_storage_json["chapters"]; Json::Value json_chapter; int latest_read = image_index + 1; if(json_chapters.isObject()) { json_chapter = json_chapters[images_page->get_chapter_name()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) latest_read = std::max(latest_read, current.asInt()); } else { json_chapter = Json::Value(Json::objectValue); } } else { json_chapters = Json::Value(Json::objectValue); json_chapter = Json::Value(Json::objectValue); } json_chapter["current"] = std::min(latest_read, num_images); json_chapter["total"] = num_images; json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } bool error = !error_message.getString().isEmpty(); bool redraw = true; sf::Text chapter_text(images_page->manga_name + " | " + images_page->get_chapter_name() + " | Page " + std::to_string(image_index + 1) + "/" + std::to_string(num_images), *FontLoader::get_font(FontLoader::FontType::LATIN), 14); if(image_index == num_images) chapter_text.setString(images_page->manga_name + " | " + images_page->get_chapter_name() + " | End"); chapter_text.setFillColor(sf::Color::White); sf::RectangleShape chapter_text_background; chapter_text_background.setFillColor(sf::Color(0, 0, 0, 150)); sf::Vector2u texture_size; sf::Vector2f texture_size_f; if(!error) { texture_size = image.getTexture()->getSize(); texture_size_f = sf::Vector2f(texture_size.x, texture_size.y); } sf::Clock check_downloaded_timer; const sf::Int32 check_downloaded_timeout_ms = 500; sf::Event event; // Consume events sent during above call to get_number_of_images which sends a request to server which may take a while. We dont want pages to be skipped when pressing arrow up/down while(window.pollEvent(event)) {} // TODO: Show to user if a certain page is missing (by checking page name (number) and checking if some are skipped) while (current_page == PageType::IMAGES && window.isOpen()) { while(window.pollEvent(event)) { if (event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); redraw = true; } else if(event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { if(image_index > 0) { --image_index; goto end_of_images_page; } else if(image_index == 0 && chapters_body->get_selected_item() < (int)chapters_body->items.size() - 1) { page_navigation = -1; goto end_of_images_page; } } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { if(image_index < num_images) { ++image_index; goto end_of_images_page; } else if(image_index == num_images && chapters_body->get_selected_item() > 0) { page_navigation = 1; goto end_of_images_page; } } else if(event.key.code == sf::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == sf::Keyboard::I) { current_page = PageType::IMAGES_CONTINUOUS; image_view_mode = ImageViewMode::SCROLL; } else if(event.key.code == sf::Keyboard::F) { fit_image_to_window = !fit_image_to_window; redraw = true; } } } if(download_in_progress && check_downloaded_timer.getElapsedTime().asMilliseconds() >= check_downloaded_timeout_ms) { sf::String error_msg; LoadImageResult load_image_result = load_image_by_index(image_index, image_texture, error_msg); if(load_image_result == LoadImageResult::OK) { image.setTexture(image_texture, true); download_in_progress = false; error = false; texture_size = image.getTexture()->getSize(); texture_size_f = sf::Vector2f(texture_size.x, texture_size.y); } else if(load_image_result == LoadImageResult::FAILED) { download_in_progress = false; error = true; } error_message.setString(error_msg); redraw = true; check_downloaded_timer.restart(); } const float font_height = chapter_text.getCharacterSize() + 8.0f; const float background_height = font_height + 6.0f; sf::Vector2f content_size; content_size.x = window_size.x; content_size.y = window_size.y - background_height; if(redraw) { redraw = false; if(error) { auto bounds = error_message.getLocalBounds(); error_message.setPosition(std::floor(content_size.x * 0.5f - bounds.width * 0.5f), std::floor(content_size.y * 0.5f - bounds.height)); } else { sf::Vector2f image_scale; if(fit_image_to_window) image_scale = get_ratio(texture_size_f, wrap_to_size(texture_size_f, content_size)); else image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); image.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; image.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); } window.clear(back_color); if(error) { window.draw(error_message); } else { window.draw(image); } chapter_text_background.setSize(sf::Vector2f(window_size.x, background_height)); chapter_text_background.setPosition(0.0f, std::floor(window_size.y - background_height)); window.draw(chapter_text_background); auto text_bounds = chapter_text.getLocalBounds(); chapter_text.setPosition(std::floor(window_size.x * 0.5f - text_bounds.width * 0.5f), std::floor(window_size.y - background_height * 0.5f - font_height * 0.5f)); window.draw(chapter_text); } window.display(); } end_of_images_page: if(current_page != PageType::IMAGES && current_page != PageType::IMAGES_CONTINUOUS) { image_download_cancel = true; if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_future.get(); image_download_cancel = false; } images_to_upscale_queue.clear(); image_upscale_status.clear(); } return page_navigation; } void Program::image_continuous_page(MangaImagesPage *images_page) { image_download_cancel = false; content_cache_dir = get_cache_dir().join(images_page->get_service_name()).join(manga_id_base64).join(base64_encode(images_page->get_chapter_name())); if(create_directory_recursive(content_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_cache_dir.data, Urgency::CRITICAL); current_page = pop_page_stack(); return; } int num_images = 0; if(images_page->get_number_of_images(num_images) != ImageResult::OK) { show_notification("QuickMedia", "Failed to get number of images", Urgency::CRITICAL); current_page = pop_page_stack(); return; } if(num_images != (int)image_upscale_status.size()) image_upscale_status.resize(num_images); download_chapter_images_if_needed(images_page); Json::Value &json_chapters = content_storage_json["chapters"]; Json::Value json_chapter; int latest_read = 1 + image_index; if(json_chapters.isObject()) { json_chapter = json_chapters[images_page->get_chapter_name()]; if(json_chapter.isObject()) { const Json::Value ¤t = json_chapter["current"]; if(current.isNumeric()) latest_read = std::max(latest_read, current.asInt()); } else { json_chapter = Json::Value(Json::objectValue); } } else { json_chapters = Json::Value(Json::objectValue); json_chapter = Json::Value(Json::objectValue); } ImageViewer image_viewer(images_page, images_page->manga_name, images_page->get_chapter_name(), image_index, content_cache_dir); json_chapter["current"] = std::min(latest_read, image_viewer.get_num_pages()); json_chapter["total"] = image_viewer.get_num_pages(); json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } while(current_page == PageType::IMAGES_CONTINUOUS && window.isOpen()) { window.clear(back_color); ImageViewerAction action = image_viewer.draw(window); switch(action) { case ImageViewerAction::NONE: break; case ImageViewerAction::RETURN: current_page = pop_page_stack(); break; case ImageViewerAction::SWITCH_TO_SINGLE_IMAGE_MODE: image_view_mode = ImageViewMode::SINGLE; current_page = PageType::IMAGES; break; } window.display(); int focused_page = image_viewer.get_focused_page(); image_index = focused_page - 1; if(focused_page > latest_read) { latest_read = focused_page; json_chapter["current"] = latest_read; json_chapters[images_page->get_chapter_name()] = json_chapter; if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", "Failed to save manga progress", Urgency::CRITICAL); } } } if(current_page != PageType::IMAGES && current_page != PageType::IMAGES_CONTINUOUS) { image_download_cancel = true; if(image_download_future.valid()) { // TODO: Cancel download instead of waiting for the last page to finish image_download_future.get(); image_download_cancel = false; } images_to_upscale_queue.clear(); image_upscale_status.clear(); } } static bool is_url_video(const std::string &url) { return string_ends_with(url, ".webm") || string_ends_with(url, ".mp4") || string_ends_with(url, ".gif"); } void Program::image_board_thread_page(ImageBoardThreadPage *thread_page, Body *thread_body) { // TODO: Instead of using stage here, use different pages for each stage enum class NavigationStage { VIEWING_COMMENTS, REPLYING, SOLVING_POST_CAPTCHA, POSTING_SOLUTION, POSTING_COMMENT, VIEWING_ATTACHED_IMAGE }; NavigationStage navigation_stage = NavigationStage::VIEWING_COMMENTS; std::future captcha_request_future; std::future captcha_post_solution_future; std::future post_comment_future; std::future load_image_future; bool downloading_image = false; sf::Texture captcha_texture; sf::Sprite captcha_sprite; std::mutex captcha_image_mutex; auto attached_image_texture = std::make_unique(); sf::Sprite attached_image_sprite; GoogleCaptchaChallengeInfo challenge_info; sf::Text challenge_description_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), 24); challenge_description_text.setFillColor(sf::Color::White); const size_t captcha_num_columns = 3; const size_t captcha_num_rows = 3; std::array selected_captcha_images; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } sf::RectangleShape captcha_selection_rect; captcha_selection_rect.setOutlineThickness(5.0f); captcha_selection_rect.setOutlineColor(sf::Color::Red); // TODO: Draw only the outline instead of a transparent rectangle captcha_selection_rect.setFillColor(sf::Color::Transparent); // Valid for 2 minutes after solving a captcha std::string captcha_post_id; sf::Clock captcha_solved_time; std::string comment_to_post; // TODO: Show a white image with "Loading..." text while the captcha image is downloading // TODO: Make google captcha images load texture in the main thread, otherwise high cpu usage. I guess its fine right now because of only 1 image? // TODO: Make this work with other sites than 4chan auto request_google_captcha_image = [this, &captcha_texture, &captcha_image_mutex, &navigation_stage, &captcha_sprite, &challenge_description_text](GoogleCaptchaChallengeInfo &challenge_info) { std::string payload_image_data; DownloadResult download_image_result = download_to_string(challenge_info.payload_url, payload_image_data, {}, is_tor_enabled()); if(download_image_result == DownloadResult::OK) { std::lock_guard lock(captcha_image_mutex); sf::Image captcha_image; if(load_image_from_memory(captcha_image, payload_image_data.data(), payload_image_data.size()) && captcha_texture.loadFromImage(captcha_image)) { captcha_texture.setSmooth(true); captcha_sprite.setTexture(captcha_texture, true); challenge_description_text.setString(challenge_info.description); } else { show_notification("QuickMedia", "Failed to load downloaded captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } } else { show_notification("QuickMedia", "Failed to download captcha image", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; auto request_new_google_captcha_challenge = [this, &selected_captcha_images, &navigation_stage, &captcha_request_future, &request_google_captcha_image, &challenge_info]() { fprintf(stderr, "Solving captcha!\n"); navigation_stage = NavigationStage::SOLVING_POST_CAPTCHA; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } const std::string referer = "https://boards.4chan.org/"; captcha_request_future = google_captcha_request_challenge(fourchan_google_captcha_api_key, referer, [&navigation_stage, &request_google_captcha_image, &challenge_info](std::optional new_challenge_info) { if(navigation_stage != NavigationStage::SOLVING_POST_CAPTCHA) return; if(new_challenge_info) { challenge_info = new_challenge_info.value(); request_google_captcha_image(challenge_info); } else { show_notification("QuickMedia", "Failed to get captcha challenge", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }, is_tor_enabled()); }; Entry comment_input("Press i to begin writing a comment..."); comment_input.draw_background = false; comment_input.set_editable(false); auto post_comment = [this, &comment_input, &navigation_stage, &thread_page, &captcha_post_id, &comment_to_post, &request_new_google_captcha_challenge]() { comment_input.set_editable(false); navigation_stage = NavigationStage::POSTING_COMMENT; PostResult post_result = thread_page->post_comment(captcha_post_id, comment_to_post); if(post_result == PostResult::OK) { show_notification("QuickMedia", "Comment posted!"); navigation_stage = NavigationStage::VIEWING_COMMENTS; // TODO: Append posted comment to the thread so the user can see their posted comment. // TODO: Asynchronously update the thread periodically to show new comments. } else if(post_result == PostResult::TRY_AGAIN) { show_notification("QuickMedia", "Error while posting, did the captcha expire? Please try again"); // TODO: Check if the response contains a new captcha instead of requesting a new one manually request_new_google_captcha_challenge(); } else if(post_result == PostResult::BANNED) { show_notification("QuickMedia", "Failed to post comment because you are banned", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(post_result == PostResult::ERR) { show_notification("QuickMedia", "Failed to post comment. Is " + std::string(plugin_name) + " down or is your internet down?", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } else { assert(false && "Unhandled post result"); show_notification("QuickMedia", "Failed to post comment. Unknown error", Urgency::CRITICAL); navigation_stage = NavigationStage::VIEWING_COMMENTS; } }; comment_input.on_submit_callback = [&post_comment_future, &navigation_stage, &request_new_google_captcha_challenge, &comment_to_post, &captcha_post_id, &captcha_solved_time, &post_comment, &thread_page](std::string text) -> bool { if(text.empty()) return false; assert(navigation_stage == NavigationStage::REPLYING); comment_to_post = std::move(text); if(!captcha_post_id.empty() && captcha_solved_time.getElapsedTime().asSeconds() < 120) { post_comment_future = std::async(std::launch::async, [&post_comment]() -> bool { post_comment(); return true; }); } else if(thread_page->get_pass_id().empty()) { request_new_google_captcha_challenge(); } else if(!thread_page->get_pass_id().empty()) { post_comment_future = std::async(std::launch::async, [&post_comment]() -> bool { post_comment(); return true; }); } return true; }; sf::RectangleShape comment_input_shade; comment_input_shade.setFillColor(sf::Color(33, 38, 44)); sf::Sprite logo_sprite(plugin_logo); logo_sprite.setScale(0.8f, 0.8f); sf::Vector2f logo_size(plugin_logo.getSize().x * logo_sprite.getScale().x, plugin_logo.getSize().y * logo_sprite.getScale().y); float prev_chat_height = comment_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = 15.0f; const float chat_input_padding_x = 10.0f; const float chat_input_padding_y = 10.0f; sf::Vector2f body_pos; sf::Vector2f body_size; bool redraw = true; bool frame_skip_text_entry = false; sf::Event event; std::stack comment_navigation_stack; std::stack comment_page_scroll_stack; while (current_page == PageType::IMAGE_BOARD_THREAD && window.isOpen()) { while (window.pollEvent(event)) { if(navigation_stage == NavigationStage::REPLYING && !frame_skip_text_entry) { comment_input.process_event(event); // To prevent pressing enter in comment_input text submit from also immediately sending captcha solution.. is there no better solution? if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Enter) break; if(event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Escape) { comment_input.set_editable(false); navigation_stage = NavigationStage::VIEWING_COMMENTS; break; } } if(event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); } if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) redraw = true; else if(navigation_stage == NavigationStage::VIEWING_COMMENTS && event.type == sf::Event::KeyPressed) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { thread_body->select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { thread_body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { thread_body->select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { thread_body->select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { thread_body->select_first_item(); } else if(event.key.code == sf::Keyboard::End) { thread_body->select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { current_page = pop_page_stack(); } else if(event.key.code == sf::Keyboard::P) { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->attached_content_url.empty()) { if(is_url_video(selected_item->attached_content_url)) { page_stack.push(PageType::IMAGE_BOARD_THREAD); current_page = PageType::VIDEO_CONTENT; watched_videos.clear(); // TODO: Use real title video_content_page(thread_page, selected_item->attached_content_url, "No title.webm", true); redraw = true; } else { if(downloading_image && load_image_future.valid()) load_image_future.get(); downloading_image = true; navigation_stage = NavigationStage::VIEWING_ATTACHED_IMAGE; load_image_future = std::async(std::launch::async, [this, &thread_body]() { std::string image_data; BodyItem *selected_item = thread_body->get_selected(); if(!selected_item || selected_item->attached_content_url.empty()) { return image_data; } if(download_to_string_cache(selected_item->attached_content_url, image_data, {}, is_tor_enabled()) != DownloadResult::OK) { show_notification("QuickMedia", "Failed to download image: " + selected_item->attached_content_url, Urgency::CRITICAL); image_data.clear(); } return image_data; }); } } } BodyItem *selected_item = thread_body->get_selected(); if(event.key.code == sf::Keyboard::Enter && selected_item && (comment_navigation_stack.empty() || thread_body->get_selected_item() != comment_navigation_stack.top()) && !selected_item->replies.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = false; } selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } comment_navigation_stack.push(thread_body->get_selected_item()); comment_page_scroll_stack.push(thread_body->get_page_scroll()); thread_body->clamp_selection(); thread_body->set_page_scroll(0.0f); } else if(event.key.code == sf::Keyboard::BackSpace && !comment_navigation_stack.empty()) { size_t previous_selected = comment_navigation_stack.top(); float previous_page_scroll = comment_page_scroll_stack.top(); comment_navigation_stack.pop(); comment_page_scroll_stack.pop(); if(comment_navigation_stack.empty()) { for(auto &body_item : thread_body->items) { body_item->visible = true; } thread_body->set_selected_item(previous_selected); thread_body->clamp_selection(); } else { for(auto &body_item : thread_body->items) { body_item->visible = false; } thread_body->set_selected_item(previous_selected); selected_item = thread_body->items[comment_navigation_stack.top()].get(); selected_item->visible = true; for(size_t reply_index : selected_item->replies) { thread_body->items[reply_index]->visible = true; } thread_body->clamp_selection(); } thread_body->set_page_scroll(previous_page_scroll); } else if(event.key.code == sf::Keyboard::R && selected_item) { std::string text_to_add = ">>" + selected_item->post_number + "\n"; comment_input.append_text(std::move(text_to_add)); comment_input.move_caret_to_end(); } if(event.key.code == sf::Keyboard::I) { frame_skip_text_entry = true; navigation_stage = NavigationStage::REPLYING; comment_input.set_editable(true); comment_input.move_caret_to_end(); } } if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA) { int num = -1; if(event.key.code >= sf::Keyboard::Num1 && event.key.code <= sf::Keyboard::Num9) { num = event.key.code - sf::Keyboard::Num1; } else if(event.key.code >= sf::Keyboard::Numpad1 && event.key.code <= sf::Keyboard::Numpad9) { num = event.key.code - sf::Keyboard::Numpad1; } constexpr int select_map[9] = { 6, 7, 8, 3, 4, 5, 0, 1, 2 }; if(num != -1) { int index = select_map[num]; selected_captcha_images[index] = !selected_captcha_images[index]; } if(event.key.code == sf::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } else if(event.key.code == sf::Keyboard::Enter) { navigation_stage = NavigationStage::POSTING_SOLUTION; captcha_post_solution_future = google_captcha_post_solution(fourchan_google_captcha_api_key, challenge_info.id, selected_captcha_images, [&navigation_stage, &captcha_post_id, &captcha_solved_time, &selected_captcha_images, &challenge_info, &request_google_captcha_image, &post_comment](std::optional new_captcha_post_id, std::optional new_challenge_info) { if(navigation_stage != NavigationStage::POSTING_SOLUTION) return; if(new_captcha_post_id) { captcha_post_id = new_captcha_post_id.value(); captcha_solved_time.restart(); post_comment(); } else if(new_challenge_info) { show_notification("QuickMedia", "Failed to solve captcha, please try again"); challenge_info = new_challenge_info.value(); navigation_stage = NavigationStage::SOLVING_POST_CAPTCHA; for(size_t i = 0; i < selected_captcha_images.size(); ++i) { selected_captcha_images[i] = false; } request_google_captcha_image(challenge_info); } }, is_tor_enabled()); } } if(event.type == sf::Event::KeyPressed && (navigation_stage == NavigationStage::POSTING_SOLUTION || navigation_stage == NavigationStage::POSTING_COMMENT)) { if(event.key.code == sf::Keyboard::Escape) { navigation_stage = NavigationStage::VIEWING_COMMENTS; } } if(event.type == sf::Event::KeyPressed && navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { if(event.key.code == sf::Keyboard::Escape || event.key.code == sf::Keyboard::BackSpace) { navigation_stage = NavigationStage::VIEWING_COMMENTS; attached_image_texture.reset(new sf::Texture()); } } } frame_skip_text_entry = false; chat_input_height_full = comment_input.get_height() + chat_input_padding_y * 2.0f; const float chat_height = comment_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; comment_input.set_max_width(window_size.x); comment_input.set_max_width(window_size.x - (logo_padding_x + logo_size.x + chat_input_padding_x + logo_padding_x)); comment_input.set_position(sf::Vector2f(std::floor(logo_padding_x + logo_size.x + chat_input_padding_x), chat_input_padding_y)); float body_padding_horizontal = 25.0f; float body_padding_vertical = 5.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; } comment_input_shade.setSize(sf::Vector2f(window_size.x, chat_input_height_full)); comment_input_shade.setPosition(0.0f, 0.0f); body_pos = sf::Vector2f(body_padding_horizontal, comment_input_shade.getSize().y + body_padding_vertical); body_size = sf::Vector2f(body_width, window_size.y - comment_input_shade.getSize().y - body_padding_vertical); logo_sprite.setPosition(logo_padding_x, std::floor(comment_input_shade.getSize().y * 0.5f - logo_size.y * 0.5f)); } //comment_input.update(); window.clear(back_color); if(navigation_stage == NavigationStage::SOLVING_POST_CAPTCHA) { std::lock_guard lock(captcha_image_mutex); if(captcha_texture.getNativeHandle() != 0) { const float challenge_description_height = challenge_description_text.getCharacterSize() + 10.0f; sf::Vector2f content_size = window_size; content_size.y -= challenge_description_height; sf::Vector2u captcha_texture_size = captcha_texture.getSize(); sf::Vector2f captcha_texture_size_f(captcha_texture_size.x, captcha_texture_size.y); auto image_scale = get_ratio(captcha_texture_size_f, clamp_to_size(captcha_texture_size_f, content_size)); captcha_sprite.setScale(image_scale); auto image_size = captcha_texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; captcha_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(challenge_description_height + content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(captcha_sprite); challenge_description_text.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x * 0.5f, 0.0f) - sf::Vector2f(challenge_description_text.getLocalBounds().width * 0.5f, challenge_description_height)); window.draw(challenge_description_text); for(size_t column = 0; column < captcha_num_columns; ++column) { for(size_t row = 0; row < captcha_num_rows; ++row) { if(selected_captcha_images[column + captcha_num_columns * row]) { captcha_selection_rect.setPosition(captcha_sprite.getPosition() + sf::Vector2f(image_size.x / captcha_num_columns * column, image_size.y / captcha_num_rows * row)); captcha_selection_rect.setSize(sf::Vector2f(image_size.x / captcha_num_columns, image_size.y / captcha_num_rows)); window.draw(captcha_selection_rect); } } } } } else if(navigation_stage == NavigationStage::POSTING_SOLUTION) { // TODO: Show "Posting..." when posting solution } else if(navigation_stage == NavigationStage::POSTING_COMMENT) { // TODO: Show "Posting..." when posting comment } else if(navigation_stage == NavigationStage::VIEWING_ATTACHED_IMAGE) { // TODO: Use image instead of data with string. texture->loadFromMemory creates a temporary image anyways that parses the string. std::string image_data; if(downloading_image && is_future_ready(load_image_future)) { downloading_image = false; image_data = load_image_future.get(); sf::Image attached_image; if(load_image_from_memory(attached_image, image_data.data(), image_data.size()) && attached_image_texture->loadFromImage(attached_image)) { attached_image_texture->setSmooth(true); //attached_image_texture->generateMipmap(); attached_image_sprite.setTexture(*attached_image_texture, true); } else { BodyItem *selected_item = thread_body->get_selected(); std::string selected_item_attached_url; if(selected_item) selected_item_attached_url = selected_item->attached_content_url; show_notification("QuickMedia", "Failed to load image downloaded from url: " + selected_item->attached_content_url, Urgency::CRITICAL); } } // TODO: Show a white image with the text "Downloading..." while the image is downloading and loading if(attached_image_texture->getNativeHandle() != 0) { auto content_size = window_size; sf::Vector2u texture_size = attached_image_texture->getSize(); sf::Vector2f texture_size_f(texture_size.x, texture_size.y); auto image_scale = get_ratio(texture_size_f, clamp_to_size(texture_size_f, content_size)); attached_image_sprite.setScale(image_scale); auto image_size = texture_size_f; image_size.x *= image_scale.x; image_size.y *= image_scale.y; attached_image_sprite.setPosition(std::floor(content_size.x * 0.5f - image_size.x * 0.5f), std::floor(content_size.y * 0.5f - image_size.y * 0.5f)); window.draw(attached_image_sprite); } else { sf::RectangleShape rect(sf::Vector2f(640.0f, 480.0f)); rect.setFillColor(sf::Color::White); auto content_size = window_size; auto rect_size = clamp_to_size(rect.getSize(), content_size); rect.setSize(rect_size); rect.setPosition(std::floor(content_size.x * 0.5f - rect_size.x * 0.5f), std::floor(content_size.y * 0.5f - rect_size.y * 0.5f)); window.draw(rect); } } else if(navigation_stage == NavigationStage::REPLYING) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } else if(navigation_stage == NavigationStage::VIEWING_COMMENTS) { window.draw(comment_input_shade); window.draw(logo_sprite); comment_input.draw(window); thread_body->draw(window, body_pos, body_size); } window.display(); } // TODO: Instead of waiting for them, kill them somehow if(captcha_request_future.valid()) captcha_request_future.get(); if(captcha_post_solution_future.valid()) captcha_post_solution_future.get(); if(post_comment_future.valid()) post_comment_future.get(); if(load_image_future.valid()) load_image_future.get(); } void Program::chat_login_page() { assert(strcmp(plugin_name, "matrix") == 0); SearchBar login_input(nullptr, "Username"); SearchBar password_input(nullptr, "Password", true); SearchBar homeserver_input(nullptr, "Homeserver"); sf::Text status_text("", *FontLoader::get_font(FontLoader::FontType::LATIN), 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&) { 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; } } 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 = PageType::CHAT; } else { status_text.setString("Failed to login, error: " + err_msg); } return; }; 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; auto body = create_body(); while (current_page == PageType::CHAT_LOGIN && window.isOpen()) { while (window.pollEvent(event)) { if (event.type == sf::Event::Closed) { current_page = PageType::EXIT; window.close(); } else if(event.type == sf::Event::Resized) { window_size.x = event.size.width; window_size.y = event.size.height; sf::FloatRect visible_area(0, 0, window_size.x, window_size.y); window.setView(sf::View(visible_area)); redraw = true; } else if(event.type == sf::Event::GainedFocus) { redraw = true; } 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; get_body_dimensions(window_size, nullptr, 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(); } } struct ChatTab { std::unique_ptr body; std::future future; sf::Text text; }; static const sf::Vector2i CHAT_MESSAGE_THUMBNAIL_MAX_SIZE(600, 337); static std::shared_ptr message_to_body_item(RoomData *room, Message *message, const std::string &my_display_name, const std::string &my_user_id) { auto body_item = BodyItem::create(""); body_item->set_author(room->get_user_display_name(message->user)); body_item->set_description(message_get_body_remove_formatting(message)); body_item->set_timestamp(message->timestamp); if(!message->thumbnail_url.empty()) { body_item->thumbnail_url = message->thumbnail_url; body_item->thumbnail_size = message->thumbnail_size; } else if(!message->url.empty() && message->type == MessageType::IMAGE) { body_item->thumbnail_url = message->url; body_item->thumbnail_size = message->thumbnail_size; } else { body_item->thumbnail_url = room->get_user_avatar_url(message->user); body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that body_item->thumbnail_size = sf::Vector2i(32, 32); } // TODO: Show image thumbnail inline instead of url to image and showing it as the thumbnail of the body item body_item->url = message->url; body_item->set_author_color(message->user->display_name_color); body_item->userdata = (void*)message; // Note: message has to be valid as long as body_item is used! if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT || message->related_event_type == RelatedEventType::REACTION) body_item->visible = false; if(message_contains_user_mention(message->body, my_display_name) || message_contains_user_mention(message->body, my_user_id)) body_item->set_description_color(sf::Color(255, 100, 100)); return body_item; } static BodyItems messages_to_body_items(RoomData *room, const Messages &messages, const std::string &my_display_name, const std::string &my_user_id) { BodyItems result_items(messages.size()); for(size_t i = 0; i < messages.size(); ++i) { result_items[i] = message_to_body_item(room, messages[i].get(), my_display_name, my_user_id); } return result_items; } static bool is_state_message_type(const Message *message) { if(!message) return true; switch(message->type) { case MessageType::TEXT: return false; case MessageType::IMAGE: return false; case MessageType::VIDEO: return false; case MessageType::AUDIO: return false; case MessageType::FILE: return false; default: return true; } return true; } struct PinnedEventData { std::string event_id; FetchStatus status = FetchStatus::NONE; Message *message = nullptr; }; void Program::chat_page(MatrixChatPage *matrix_chat_page, RoomData *current_room) { assert(strcmp(plugin_name, "matrix") == 0); window.setTitle("QuickMedia - matrix - " + current_room->get_name()); auto video_page = std::make_unique(this); std::vector tabs; ChatTab pinned_tab; pinned_tab.body = std::make_unique(this, loading_icon); pinned_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; pinned_tab.body->thumbnail_mask_shader = &circle_mask_shader; pinned_tab.body->attach_side = AttachSide::BOTTOM; //pinned_tab.body->line_separator_color = sf::Color::Transparent; pinned_tab.text = sf::Text("Pinned messages", *FontLoader::get_font(FontLoader::FontType::LATIN), tab_text_size); tabs.push_back(std::move(pinned_tab)); ChatTab messages_tab; messages_tab.body = std::make_unique(this, loading_icon); messages_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; messages_tab.body->thumbnail_mask_shader = &circle_mask_shader; messages_tab.body->attach_side = AttachSide::BOTTOM; //messages_tab.body->line_separator_color = sf::Color::Transparent; messages_tab.text = sf::Text("Messages", *FontLoader::get_font(FontLoader::FontType::LATIN), tab_text_size); tabs.push_back(std::move(messages_tab)); // ChatTab users_tab; // users_tab.body = std::make_unique(this, loading_icon); // users_tab.body->thumbnail_max_size = CHAT_MESSAGE_THUMBNAIL_MAX_SIZE; // users_tab.body->thumbnail_mask_shader = &circle_mask_shader; // users_tab.body->attach_side = AttachSide::TOP; // //users_tab.body->line_separator_color = sf::Color::Transparent; // users_tab.text = sf::Text("Users", *FontLoader::get_font(FontLoader::FontType::LATIN), tab_text_size); // tabs.push_back(std::move(users_tab)); const int PINNED_TAB_INDEX = 0; const int MESSAGES_TAB_INDEX = 1; //const int USERS_TAB_INDEX = 2; int selected_tab = MESSAGES_TAB_INDEX; bool is_window_focused = window.hasFocus(); enum class ChatState { NAVIGATING, TYPING_MESSAGE, REPLYING, EDITING, URL_SELECTION }; PageType new_page = PageType::CHAT; ChatState chat_state = ChatState::NAVIGATING; std::shared_ptr currently_operating_on_item; sf::Text replying_to_text("Replying to:", *FontLoader::get_font(FontLoader::FontType::LATIN), 18); sf::Sprite logo_sprite(plugin_logo); logo_sprite.setScale(0.8f, 0.8f); sf::Vector2f logo_size(plugin_logo.getSize().x * logo_sprite.getScale().x, plugin_logo.getSize().y * logo_sprite.getScale().y); sf::Text room_name_text("", *FontLoader::get_font(FontLoader::FontType::LATIN_BOLD), 18); 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; sf::Sprite room_avatar_sprite; auto room_avatar_thumbnail_data = std::make_shared(); sf::Clock read_marker_timer; const sf::Int32 read_marker_timeout_ms_default = 3000; sf::Int32 read_marker_timeout_ms = 0; AsyncTask set_read_marker_future; bool setting_read_marker = false; bool redraw = true; // TODO: Optimize with hash map? auto find_body_item_by_event_id = [](std::shared_ptr *body_items, size_t num_body_items, const std::string &event_id, size_t *index_result = nullptr) -> std::shared_ptr { for(size_t i = 0; i < num_body_items; ++i) { auto &body_item = body_items[i]; if(body_item->userdata && static_cast(body_item->userdata)->event_id == event_id) { if(index_result) *index_result = i; return body_item; } } return nullptr; }; // TODO: What if these never end up referencing events? clean up automatically after a while? Messages unreferenced_events; auto set_body_as_deleted = [¤t_room](Message *message, BodyItem *body_item) { //body_item->embedded_item = nullptr; //body_item->embedded_item_status = FetchStatus::NONE; message->type = MessageType::REDACTION; //message->related_event_id.clear(); //message->related_event_type = RelatedEventType::NONE; Message *original_message = static_cast(body_item->userdata); if(original_message) { body_item->thumbnail_url = current_room->get_user_avatar_url(original_message->user); body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; } body_item->set_description("Message deleted"); body_item->set_description_color(sf::Color::White); body_item->thumbnail_size = sf::Vector2i(32, 32); }; // TODO: Optimize with hash map? auto resolve_unreferenced_events_with_body_items = [&set_body_as_deleted, &unreferenced_events, &find_body_item_by_event_id](std::shared_ptr *body_items, size_t num_body_items) { for(auto it = unreferenced_events.begin(); it != unreferenced_events.end(); ) { auto &message = *it; // TODO: Make redacted/edited events as (redacted)/(edited) in the body if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT) { auto body_item = find_body_item_by_event_id(body_items, num_body_items, message->related_event_id); if(body_item) { // TODO: Append the new message to the body item so the body item should have a list of edit events //body_item->userdata = message.get(); if(message->related_event_type == RelatedEventType::REDACTION) { set_body_as_deleted(message.get(), body_item.get()); } else { body_item->set_description(message_get_body_remove_formatting(message.get())); body_item->set_description_color(sf::Color::White); } it = unreferenced_events.erase(it); } else { ++it; } } else { ++it; } } }; // TODO: Optimize find_body_item_by_event_id hash map? auto modify_related_messages_in_current_room = [&set_body_as_deleted, &unreferenced_events, &find_body_item_by_event_id, &tabs](Messages &messages) { if(messages.empty()) return; auto &body_items = tabs[MESSAGES_TAB_INDEX].body->items; for(auto &message : messages) { // TODO: Make redacted/edited events as (redacted)/(edited) in the body if(message->related_event_type == RelatedEventType::REDACTION || message->related_event_type == RelatedEventType::EDIT) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item) { // TODO: Append the new message to the body item so the body item should have a list of edit events //body_item->userdata = message.get(); if(message->related_event_type == RelatedEventType::REDACTION) { set_body_as_deleted(message.get(), body_item.get()); } else { body_item->set_description(message_get_body_remove_formatting(message.get())); body_item->set_description_color(sf::Color::White); } } else { unreferenced_events.push_back(message); } } } }; std::vector> unresolved_reactions; // TODO: Optimize find_body_item_by_event_id hash map? auto process_reactions = [&tabs, &find_body_item_by_event_id, &unresolved_reactions, ¤t_room](Messages &messages) { if(messages.empty()) return; auto &body_items = tabs[MESSAGES_TAB_INDEX].body->items; // TODO: Check in |messages| instead for(auto it = unresolved_reactions.begin(); it != unresolved_reactions.end();) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), (*it)->related_event_id); if(body_item) { body_item->add_reaction(current_room->get_user_display_name((*it)->user) + ": " + (*it)->body, (*it).get()); it = unresolved_reactions.erase(it); } else { ++it; } } for(auto &message : messages) { if(message->type == MessageType::REACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item) body_item->add_reaction(current_room->get_user_display_name(message->user) + ": " + message->body, message.get()); else unresolved_reactions.push_back(message); } else if(message->type == MessageType::REDACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), message->related_event_id); if(body_item && static_cast(body_item->userdata)) { Message *reaction_message = static_cast(body_item->userdata); if(reaction_message->type == MessageType::REACTION) { auto body_item = find_body_item_by_event_id(body_items.data(), body_items.size(), reaction_message->related_event_id); if(body_item) body_item->remove_reaction_by_userdata(reaction_message); } } else { for(auto it = unresolved_reactions.begin(); it != unresolved_reactions.end(); ++it) { if(message->related_event_id == (*it)->event_id) { unresolved_reactions.erase(it); break; } } } } } }; auto pinned_body_items_contains_event = [&tabs](const std::string &event_id) { for(auto &body_item : tabs[PINNED_TAB_INDEX].body->items) { if(static_cast(body_item->userdata)->event_id == event_id) return true; } return false; }; auto process_pinned_events = [&tabs, &pinned_body_items_contains_event](const std::optional> &pinned_events) { if(!pinned_events) return; bool empty_before = tabs[PINNED_TAB_INDEX].body->items.empty(); int selected_before = tabs[PINNED_TAB_INDEX].body->get_selected_item(); auto prev_pinned_body_items = tabs[PINNED_TAB_INDEX].body->items; tabs[PINNED_TAB_INDEX].body->clear_items(); // TODO: Add message to rooms messages when there are new pinned events for(const std::string &event : pinned_events.value()) { if(pinned_body_items_contains_event(event)) continue; auto body = BodyItem::create(""); body->set_description("Loading message..."); PinnedEventData *event_data = new PinnedEventData(); event_data->event_id = event; event_data->status = FetchStatus::NONE; event_data->message = nullptr; body->userdata = event_data; tabs[PINNED_TAB_INDEX].body->items.push_back(std::move(body)); } for(auto &prev_body_item : prev_pinned_body_items) { if(!pinned_body_items_contains_event(static_cast(prev_body_item->userdata)->event_id)) delete (PinnedEventData*)prev_body_item->userdata; } if(empty_before) tabs[PINNED_TAB_INDEX].body->select_last_item(); else tabs[PINNED_TAB_INDEX].body->set_selected_item(selected_before); tabs[PINNED_TAB_INDEX].text.setString("Pinned messages (" + std::to_string(tabs[PINNED_TAB_INDEX].body->items.size()) + ")"); }; Body url_selection_body(this, loading_icon); std::unordered_set fetched_messages_set; auto filter_existing_messages = [&fetched_messages_set](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { auto res = fetched_messages_set.insert((*it)->event_id); if(!res.second) it = messages.erase(it); else ++it; } }; Messages all_messages; matrix->get_all_synced_room_messages(current_room, all_messages); for(auto &message : all_messages) { fetched_messages_set.insert(message->event_id); } auto me = matrix->get_me(current_room); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(messages_to_body_items(current_room, all_messages, current_room->get_user_display_name(me), me->user_id)); modify_related_messages_in_current_room(all_messages); process_reactions(all_messages); tabs[MESSAGES_TAB_INDEX].body->select_last_item(); if(!all_messages.empty() && current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; } std::vector pinned_events; matrix->get_all_pinned_events(current_room, pinned_events); process_pinned_events(std::move(pinned_events)); tabs[PINNED_TAB_INDEX].body->select_last_item(); room_name_text.setString(static_cast(current_room->userdata)->get_title()); room_avatar_thumbnail_data = std::make_shared(); read_marker_timeout_ms = 0; redraw = true; Entry chat_input("Press i to begin writing a message..."); chat_input.draw_background = false; chat_input.set_editable(false); struct ProvisionalMessage { std::shared_ptr body_item; std::shared_ptr message; std::string event_id; }; // This is needed to keep the message shared ptr alive. TODO: Remove this shit, maybe even use raw pointers. std::unordered_map sent_messages; // |event_id| is always empty in this. Use |message->event_id| instead std::optional provisional_message; MessageQueue provisional_message_queue; MessageQueue> post_task_queue; auto post_thread_handler = [&provisional_message_queue, &post_task_queue]() { while(true) { std::optional> post_task_opt = post_task_queue.pop_wait(); if(!post_task_opt) break; provisional_message_queue.push(post_task_opt.value()()); } }; std::thread post_thread(post_thread_handler); auto filter_sent_messages = [&sent_messages](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { if(sent_messages.find((*it)->event_id) != sent_messages.end()) it = messages.erase(it); else ++it; } }; auto upload_file = [this, &tabs, ¤t_room](const std::string &filepath) { TaskResult post_file_result = run_task_with_loading_screen([this, ¤t_room, filepath]() { std::string event_id_response; std::string err_msg; if(matrix->post_file(current_room, filepath, event_id_response, err_msg) == PluginResult::OK) { return true; } else { show_notification("QuickMedia", "Failed to upload media to room, error: " + err_msg, Urgency::CRITICAL); return false; } }); if(post_file_result == TaskResult::TRUE) { if(tabs[MESSAGES_TAB_INDEX].body->is_last_item_fully_visible()) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } }; chat_input.on_submit_callback = [this, &tabs, &me, &chat_input, &selected_tab, ¤t_room, &new_page, &chat_state, ¤tly_operating_on_item, &post_task_queue, &process_reactions, &find_body_item_by_event_id](std::string text) mutable { if(!current_room) return false; if(selected_tab == MESSAGES_TAB_INDEX) { if(text.empty()) return false; std::string msgtype; if(chat_state == ChatState::TYPING_MESSAGE && text[0] == '/') { if(text == "/upload") { new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(text == "/logout") { new_page = PageType::CHAT_LOGIN; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; return true; } else if(text == "/leave") { TaskResult task_result = run_task_with_loading_screen([this, ¤t_room]() { return matrix->leave_room(current_room->id) == PluginResult::OK; }); if(task_result != TaskResult::FALSE) { go_to_previous_page = true; chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; } return true; } else if(strncmp(text.c_str(), "/me ", 4) == 0) { msgtype = "m.emote"; text.erase(text.begin(), text.begin() + 4); } else if(strncmp(text.c_str(), "/react ", 7) == 0) { msgtype = "m.reaction"; text.erase(text.begin(), text.begin() + 7); } else { fprintf(stderr, "Error: invalid command: %s, expected /upload, /logout, /me or /react\n", text.c_str()); return false; } } auto message = std::make_shared(); message->user = matrix->get_me(current_room); if(msgtype == "m.emote") message->body = "*" + current_room->get_user_display_name(me) + "* " + text; else message->body = text; message->type = MessageType::TEXT; message->timestamp = time(NULL) * 1000; const sf::Color provisional_message_color(171, 175, 180); int num_items = tabs[MESSAGES_TAB_INDEX].body->items.size(); bool scroll_to_end = num_items == 0; if(tabs[MESSAGES_TAB_INDEX].body->is_selected_item_last_visible_item() && selected_tab == MESSAGES_TAB_INDEX) scroll_to_end = true; if(chat_state == ChatState::TYPING_MESSAGE) { BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); if(msgtype == "m.reaction" && selected_item) { void *related_to_message = selected_item->userdata; message->type = MessageType::REACTION; message->related_event_type = RelatedEventType::REACTION; message->related_event_id = static_cast(related_to_message)->event_id; auto body_item = message_to_body_item(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); Messages messages; messages.push_back(message); process_reactions(messages); post_task_queue.push([this, ¤t_room, text, body_item, message, related_to_message]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_reaction(current_room, text, related_to_message, provisional_message.event_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix reaction\n"); return provisional_message; }); } else { auto body_item = message_to_body_item(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(provisional_message_color); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); post_task_queue.push([this, ¤t_room, text, msgtype, body_item, message]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_message(current_room, text, provisional_message.event_id, std::nullopt, std::nullopt, msgtype) != PluginResult::OK) fprintf(stderr, "Failed to post matrix message\n"); return provisional_message; }); } chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); return true; } else if(chat_state == ChatState::REPLYING) { void *related_to_message = currently_operating_on_item->userdata; message->related_event_type = RelatedEventType::REPLY; message->related_event_id = static_cast(related_to_message)->event_id; auto body_item = message_to_body_item(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); body_item->set_description_color(provisional_message_color); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({body_item}); post_task_queue.push([this, ¤t_room, text, related_to_message, body_item, message]() { ProvisionalMessage provisional_message; provisional_message.body_item = body_item; provisional_message.message = message; if(matrix->post_reply(current_room, text, related_to_message, provisional_message.event_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix reply\n"); return provisional_message; }); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; if(scroll_to_end) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); return true; } else if(chat_state == ChatState::EDITING) { void *related_to_message = currently_operating_on_item->userdata; message->related_event_type = RelatedEventType::EDIT; message->related_event_id = static_cast(related_to_message)->event_id; size_t body_item_index = 0; auto body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), message->related_event_id, &body_item_index); if(body_item) { auto body_item_shared_ptr = tabs[MESSAGES_TAB_INDEX].body->items[body_item_index]; body_item_shared_ptr->set_description(text); body_item_shared_ptr->set_description_color(provisional_message_color); auto edit_body_item = message_to_body_item(current_room, message.get(), current_room->get_user_avatar_url(me), me->user_id); edit_body_item->visible = false; tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps({edit_body_item}); //unreferenced_events.push_back(message); post_task_queue.push([this, ¤t_room, text, related_to_message, message, body_item_shared_ptr]() { ProvisionalMessage provisional_message; provisional_message.message = message; provisional_message.body_item = body_item_shared_ptr; if(matrix->post_edit(current_room, text, related_to_message, provisional_message.event_id) != PluginResult::OK) fprintf(stderr, "Failed to post matrix edit\n"); return provisional_message; }); chat_input.set_editable(false); chat_state = ChatState::NAVIGATING; currently_operating_on_item = nullptr; return true; } else { show_notification("QuickMedia", "Failed to edit message. Message refers to a non-existing message", Urgency::CRITICAL); return false; } } } return false; }; AsyncTask previous_messages_future; enum class FetchMessageType { MESSAGE, USER_UPDATE }; struct FetchMessageResult { FetchMessageType type; std::shared_ptr message; }; //const int num_fetch_message_threads = 4; AsyncTask fetch_users_future; AsyncTask fetch_message_future; Message *fetch_message = nullptr; BodyItem *fetch_body_item = nullptr; int fetch_message_tab = -1; // TODO: How about instead fetching all messages we have, not only the visible ones? also fetch with multiple threads. tabs[PINNED_TAB_INDEX].body->body_item_render_callback = [this, ¤t_room, &me, &fetch_message_future, &tabs, &fetch_message, &find_body_item_by_event_id, &fetch_body_item, &fetch_message_tab](BodyItem *body_item) { if(fetch_message_future.valid()) return; PinnedEventData *event_data = static_cast(body_item->userdata); if(!event_data) return; #if 0 if(event_data->message->user->resolve_state == UserResolveState::NOT_RESOLVED) { fetch_message = event_data->message; event_data->message->user->resolve_state = UserResolveState::RESOLVING; std::string user_id = event_data->message->user->user_id; fetch_message_future = [this, ¤t_room, user_id]() { matrix->update_user_with_latest_state(current_room, user_id); return FetchMessageResult{FetchMessageType::USER_UPDATE, nullptr}; }; return; } else if(event_data->message->user->resolve_state == UserResolveState::RESOLVING) { return; } #endif if(event_data->status == FetchStatus::FINISHED_LOADING && event_data->message) { if(event_data->message->related_event_id.empty() || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; // Check if we already have the referenced message as a body item, so we dont create a new one. // TODO: Optimize from linear search to hash map auto related_body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), event_data->message->related_event_id); if(related_body_item) { if(related_body_item->userdata && static_cast(related_body_item->userdata)->user == me) body_item->set_description_color(sf::Color(255, 100, 100)); body_item->embedded_item = std::make_shared(""); *body_item->embedded_item = *related_body_item; body_item->reactions.clear(); body_item->embedded_item_status = FetchStatus::FINISHED_LOADING; return; } std::string message_event_id = event_data->message->related_event_id; fetch_body_item = body_item; body_item->embedded_item_status = FetchStatus::LOADING; fetch_message_tab = MESSAGES_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = [this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }; return; } if(event_data->status != FetchStatus::NONE) return; // Check if we already have the referenced message as a body item in the messages list, so we dont create a new one. // TODO: Optimize from linear search to hash map auto related_body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), event_data->event_id); if(related_body_item) { if(related_body_item->userdata && static_cast(related_body_item->userdata)->user == me) body_item->set_description_color(sf::Color(255, 100, 100)); *body_item = *related_body_item; body_item->reactions.clear(); event_data->status = FetchStatus::FINISHED_LOADING; event_data->message = static_cast(related_body_item->userdata); body_item->userdata = event_data; return; } std::string message_event_id = event_data->event_id; fetch_body_item = body_item; event_data->status = FetchStatus::LOADING; fetch_message_tab = PINNED_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = [this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }; }; // TODO: How about instead fetching all messages we have, not only the visible ones? also fetch with multiple threads. tabs[MESSAGES_TAB_INDEX].body->body_item_render_callback = [this, ¤t_room, &me, &fetch_message_future, &tabs, &fetch_message, &find_body_item_by_event_id, &fetch_body_item, &fetch_message_tab](BodyItem *body_item) { Message *message = static_cast(body_item->userdata); if(!message) return; #if 0 if(message->user->resolve_state == UserResolveState::NOT_RESOLVED) { fetch_message = message; message->user->resolve_state = UserResolveState::RESOLVING; std::string user_id = message->user->user_id; fetch_message_future = [this, ¤t_room, user_id]() { matrix->update_user_with_latest_state(current_room, user_id); return FetchMessageResult{FetchMessageType::USER_UPDATE, nullptr}; }; return; } else if(message->user->resolve_state == UserResolveState::RESOLVING) { return; } #endif if(message->related_event_id.empty() || (body_item->embedded_item_status != FetchStatus::NONE && body_item->embedded_item_status != FetchStatus::QUEUED_LOADING)) return; if(fetch_message_future.valid()) { body_item->embedded_item_status = FetchStatus::QUEUED_LOADING; return; } // Check if we already have the referenced message as a body item, so we dont create a new one. // TODO: Optimize from linear search to hash map auto related_body_item = find_body_item_by_event_id(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size(), message->related_event_id); if(related_body_item) { if(related_body_item->userdata && static_cast(related_body_item->userdata)->user == me) body_item->set_description_color(sf::Color(255, 100, 100)); body_item->embedded_item = std::make_shared(""); *body_item->embedded_item = *related_body_item; body_item->reactions.clear(); body_item->embedded_item_status = FetchStatus::FINISHED_LOADING; return; } std::string message_event_id = message->related_event_id; fetch_body_item = body_item; body_item->embedded_item_status = FetchStatus::LOADING; fetch_message_tab = MESSAGES_TAB_INDEX; // TODO: Check if the message is already cached before calling async? is this needed? is async creation expensive? fetch_message_future = [this, ¤t_room, message_event_id]() { return FetchMessageResult{FetchMessageType::MESSAGE, matrix->get_message_by_id(current_room, message_event_id)}; }; }; const float tab_spacer_height = 0.0f; sf::Vector2f body_pos; sf::Vector2f body_size; sf::Event event; sf::RectangleShape tab_shade; tab_shade.setFillColor(sf::Color(33, 38, 44)); sf::RoundedRectangleShape tab_background(sf::Vector2f(1.0f, 1.0f), 10.0f, 10); tab_background.setFillColor(tab_selected_color); const float gradient_height = 5.0f; sf::Vertex gradient_points[4]; double gradient_inc = 0; //tabs[MESSAGES_TAB_INDEX].body->set_page_scroll(window_size.y); bool fetched_enough_messages = false; auto fetch_more_previous_messages_if_needed = [this, &tabs, ¤t_room, &fetched_enough_messages, &previous_messages_future]() { if(!fetched_enough_messages && !previous_messages_future.valid()) { if(tabs[MESSAGES_TAB_INDEX].body->items.size() < 30) { previous_messages_future = [this, ¤t_room]() { Messages messages; if(matrix->get_previous_room_messages(current_room, messages, true) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); return messages; }; } } }; sf::RectangleShape more_messages_below_rect; more_messages_below_rect.setFillColor(sf::Color(128, 50, 50)); sf::RectangleShape chat_input_shade; chat_input_shade.setFillColor(sf::Color(33, 38, 44)); sf::Clock start_typing_timer; const double typing_timeout_seconds = 3.0; bool typing = false; float tab_vertical_offset = 0.0f; MessageQueue typing_state_queue; auto typing_state_handler = [this, ¤t_room, &typing_state_queue]() { while(true) { std::optional state_opt = typing_state_queue.pop_wait(); if(!state_opt) break; bool state = state_opt.value(); if(state) matrix->on_start_typing(current_room); else matrix->on_stop_typing(current_room); } }; std::thread typing_state_thread(typing_state_handler); sf::Clock frame_timer; float prev_chat_height = chat_input.get_height(); float chat_input_height_full = 0.0f; const float logo_padding_x = 15.0f; const float chat_input_padding_x = 10.0f; const float chat_input_padding_y = 10.0f; auto launch_url = [this, &video_page, &redraw](const std::string &url) mutable { if(url.empty()) return; std::string video_id; if(youtube_url_extract_id(url, video_id)) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; // TODO: Add title video_content_page(video_page.get(), url, "No title", false); redraw = true; } else { const char *launch_program = "xdg-open"; if(!is_program_executable_by_name("xdg-open")) { launch_program = getenv("BROWSER"); if(!launch_program) { show_notification("QuickMedia", "xdg-utils which provides xdg-open needs to be installed to open urls. Alternatively set the $BROWSER environment variable to a browser", Urgency::CRITICAL); return; } } std::string url_modified = url; if(strncmp(url.c_str(), "http://", 7) != 0 && strncmp(url.c_str(), "https://", 8) != 0) url_modified = "https://" + url; const char *args[] = { launch_program, url_modified.c_str(), nullptr }; exec_program_async(args, nullptr); } }; auto filter_provisional_messages = [](Messages &messages) { for(auto it = messages.begin(); it != messages.end();) { if((*it)->provisional) it = messages.erase(it); else ++it; } }; auto add_new_messages_to_current_room = [&me, &tabs, &selected_tab, ¤t_room](Messages &messages) { if(messages.empty()) return; int num_items = tabs[MESSAGES_TAB_INDEX].body->items.size(); bool scroll_to_end = num_items == 0; if(selected_tab == MESSAGES_TAB_INDEX && (tabs[MESSAGES_TAB_INDEX].body->is_selected_item_last_visible_item() || !tabs[MESSAGES_TAB_INDEX].body->get_selected())) scroll_to_end = true; if(current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; if(selected_tab == MESSAGES_TAB_INDEX) scroll_to_end = true; } BodyItem *selected_item = tabs[MESSAGES_TAB_INDEX].body->get_selected(); tabs[MESSAGES_TAB_INDEX].body->insert_items_by_timestamps(messages_to_body_items(current_room, messages, current_room->get_user_display_name(me), me->user_id)); if(selected_item && !scroll_to_end) { int selected_item_index = tabs[MESSAGES_TAB_INDEX].body->get_index_by_body_item(selected_item); if(selected_item_index != -1) tabs[MESSAGES_TAB_INDEX].body->set_selected_item(selected_item_index); } else if(scroll_to_end) { tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } }; auto display_url_or_image = [this, &selected_tab, &redraw, &video_page, &launch_url, &chat_state, &url_selection_body](BodyItem *selected) { if(!selected) return false; Message *selected_item_message = nullptr; if(selected_tab == MESSAGES_TAB_INDEX) { selected_item_message = static_cast(selected->userdata); } else if(selected_tab == PINNED_TAB_INDEX && static_cast(selected->userdata)->status == FetchStatus::FINISHED_LOADING) { selected_item_message = static_cast(selected->userdata)->message; } if(selected_item_message) { MessageType message_type = selected_item_message->type; std::string *selected_url = &selected->url; if(!selected_url->empty()) { if(message_type == MessageType::VIDEO || message_type == MessageType::IMAGE || message_type == MessageType::AUDIO) { page_stack.push(PageType::CHAT); watched_videos.clear(); current_page = PageType::VIDEO_CONTENT; bool is_audio = (message_type == MessageType::AUDIO); bool prev_no_video = no_video; no_video = is_audio; // TODO: Add title video_content_page(video_page.get(), *selected_url, "No title", message_type == MessageType::VIDEO); no_video = prev_no_video; redraw = true; return true; } launch_url(*selected_url); return true; } } // TODO: If content type is a file, show file-manager prompt where it should be saved and asynchronously save it instead std::vector urls; extract_urls(selected->get_description(), urls); if(urls.size() == 1) { launch_url(urls[0]); return true; } else if(urls.size() > 1) { chat_state = ChatState::URL_SELECTION; url_selection_body.clear_items(); for(const std::string &url : urls) { auto body_item = BodyItem::create(url); url_selection_body.items.push_back(std::move(body_item)); } return true; } return false; }; auto update_pinned_messages_author = [&tabs, ¤t_room](const std::shared_ptr &user) { fprintf(stderr, "updated pinned messages author for user: %s\n", user->user_id.c_str()); std::string user_display_name = current_room->get_user_display_name(user); std::string user_avatar_url = current_room->get_user_avatar_url(user); for(auto &pinned_body_item : tabs[PINNED_TAB_INDEX].body->items) { Message *message = static_cast(pinned_body_item->userdata)->message; // Its fine if we dont set it now. When the message is fetches, it will have updated user info since its fetched later if(!message || message->user != user) continue; pinned_body_item->set_author(user_display_name); if(!is_visual_media_message_type(message->type)) { pinned_body_item->thumbnail_url = user_avatar_url; pinned_body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that pinned_body_item->thumbnail_size = sf::Vector2i(32, 32); } } }; auto update_messages_author = [&tabs, ¤t_room](const std::shared_ptr &user) { fprintf(stderr, "updated messages author for user: %s\n", user->user_id.c_str()); std::string user_display_name = current_room->get_user_display_name(user); std::string user_avatar_url = current_room->get_user_avatar_url(user); for(auto &message_body_items : tabs[MESSAGES_TAB_INDEX].body->items) { Message *message = static_cast(message_body_items->userdata); if(!message || message->user != user) continue; message_body_items->set_author(user_display_name); if(!is_visual_media_message_type(message->type)) { message_body_items->thumbnail_url = user_avatar_url; message_body_items->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that message_body_items->thumbnail_size = sf::Vector2i(32, 32); } } }; // TODO: Optimize auto update_pinned_messages_authors = [&tabs, ¤t_room]() { fprintf(stderr, "updated pinned messages author for all users in room: %s\n", current_room->id.c_str()); for(auto &pinned_body_item : tabs[PINNED_TAB_INDEX].body->items) { Message *message = static_cast(pinned_body_item->userdata)->message; // Its fine if we dont set it now. When the message is fetches, it will have updated user info since its fetched later if(!message) continue; pinned_body_item->set_author(current_room->get_user_display_name(message->user)); if(!is_visual_media_message_type(message->type)) { pinned_body_item->thumbnail_url = current_room->get_user_avatar_url(message->user); pinned_body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that pinned_body_item->thumbnail_size = sf::Vector2i(32, 32); } } }; // TODO: Optimize auto update_messages_authors = [&tabs, ¤t_room]() { fprintf(stderr, "updated messages author for all users in room: %s\n", current_room->id.c_str()); for(auto &message_body_items : tabs[MESSAGES_TAB_INDEX].body->items) { Message *message = static_cast(message_body_items->userdata); if(!message) continue; message_body_items->set_author(current_room->get_user_display_name(message->user)); if(!is_visual_media_message_type(message->type)) { message_body_items->thumbnail_url = current_room->get_user_avatar_url(message->user); message_body_items->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; // if construct is not configured to use ImageMagic then it wont give thumbnails of size 32x32 even when requested and the spec says that the server SHOULD do that message_body_items->thumbnail_size = sf::Vector2i(32, 32); } } }; auto cleanup_tasks = [&set_read_marker_future, &fetch_message_future, &fetch_users_future, &typing_state_queue, &typing_state_thread, &post_task_queue, &provisional_message_queue, &fetched_messages_set, &sent_messages, &post_thread, &tabs]() { set_read_marker_future.cancel(); fetch_message_future.cancel(); fetch_users_future.cancel(); typing_state_queue.close(); if(typing_state_thread.joinable()) { program_kill_in_thread(typing_state_thread.get_id()); typing_state_thread.join(); } post_task_queue.close(); if(post_thread.joinable()) { program_kill_in_thread(post_thread.get_id()); post_thread.join(); } provisional_message_queue.clear(); fetched_messages_set.clear(); sent_messages.clear(); //unreferenced_event_by_room.clear(); if(!tabs.empty()) { for(auto &body_item : tabs[PINNED_TAB_INDEX].body->items) { delete (PinnedEventData*)body_item->userdata; } tabs[PINNED_TAB_INDEX].body->clear_items(); } //tabs.clear(); }; // TODO: Remove this once synapse bug has been resolved where /sync does not include user info for new messages when using message filter that limits number of messages for initial sync, // and then only call this when viewing the users tab for the first time. // Note that this is not needed when new users join the room, as those will be included in the sync timeline (with membership events) if(current_room->users_fetched) { //TODO BLABLA //update_ } else { // TODO: Race condition? maybe use matrix /members instead which has a since parameter to make the members list match current sync fetch_users_future = [this, ¤t_room]() { matrix->update_room_users(current_room); return true; }; } float tab_shade_height = 0.0f; bool frame_skip_text_entry = false; SyncData sync_data; while (current_page == PageType::CHAT && window.isOpen()) { sf::Int32 frame_time_ms = frame_timer.restart().asMilliseconds(); while (window.pollEvent(event)) { base_event_handler(event, PageType::EXIT, tabs[selected_tab].body.get(), nullptr, false, false); if(event.type == sf::Event::GainedFocus) { is_window_focused = true; redraw = true; } else if(event.type == sf::Event::LostFocus) { is_window_focused = false; } else if(event.type == sf::Event::Resized || event.type == sf::Event::GainedFocus) { redraw = true; } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::NAVIGATING) { if(event.key.code == sf::Keyboard::Up || event.key.code == sf::Keyboard::PageUp || event.key.code == sf::Keyboard::Home || (event.key.control && event.key.code == sf::Keyboard::K)){ bool hit_top = false; switch(event.key.code) { case sf::Keyboard::Up: hit_top = !tabs[selected_tab].body->select_previous_item(); break; case sf::Keyboard::K: hit_top = !tabs[selected_tab].body->select_previous_item(); break; case sf::Keyboard::PageUp: hit_top = !tabs[selected_tab].body->select_previous_page(); break; case sf::Keyboard::Home: tabs[selected_tab].body->select_first_item(); hit_top = true; break; default: hit_top = false; break; } if(hit_top && !previous_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX && current_room) { gradient_inc = 0; previous_messages_future = [this, ¤t_room]() { Messages messages; if(matrix->get_previous_room_messages(current_room, messages) != PluginResult::OK) fprintf(stderr, "Failed to get previous matrix messages in room: %s\n", current_room->id.c_str()); return messages; }; } } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { tabs[selected_tab].body->select_next_item(); } else if(event.key.code == sf::Keyboard::PageDown) { tabs[selected_tab].body->select_next_page(); } else if(event.key.code == sf::Keyboard::End) { tabs[selected_tab].body->select_last_item(); } else if((event.key.code == sf::Keyboard::Left || (event.key.control && event.key.code == sf::Keyboard::H)) && selected_tab > 0) { tabs[selected_tab].body->clear_cache(); --selected_tab; read_marker_timer.restart(); redraw = true; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } else if((event.key.code == sf::Keyboard::Right || (event.key.control && event.key.code == sf::Keyboard::L)) && selected_tab < (int)tabs.size() - 1) { tabs[selected_tab].body->clear_cache(); ++selected_tab; read_marker_timer.restart(); redraw = true; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } else if(event.key.code == sf::Keyboard::Escape) { goto chat_page_end; } if((selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) && event.key.code == sf::Keyboard::Enter) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!display_url_or_image(selected)) display_url_or_image(selected->embedded_item.get()); } } if(current_room) { if(event.key.control && event.key.code == sf::Keyboard::C) { BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) sf::Clipboard::setString(selected->get_description()); } } if(selected_tab == MESSAGES_TAB_INDEX && current_room) { if(event.key.code == sf::Keyboard::U) { frame_skip_text_entry = true; new_page = PageType::FILE_MANAGER; chat_input.set_editable(false); } if(event.key.code == sf::Keyboard::I) { frame_skip_text_entry = true; chat_input.set_editable(true); chat_state = ChatState::TYPING_MESSAGE; } if(event.key.control && event.key.code == sf::Keyboard::V) { frame_skip_text_entry = true; // TODO: Upload multiple files. upload_file(sf::Clipboard::getString()); } if(event.key.code == sf::Keyboard::R) { frame_skip_text_entry = true; std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't reply to a message that hasn't been sent yet"); } else { chat_state = ChatState::REPLYING; 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"); } } if(event.key.code == sf::Keyboard::E) { frame_skip_text_entry = true; std::shared_ptr selected = tabs[selected_tab].body->get_selected_shared(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't edit a message that hasn't been sent yet"); } else if(!selected->url.empty()) { // cant edit messages that are image/video posts // TODO: Show inline notification show_notification("QuickMedia", "You can't edit messages with files attached to them"); } else if(!matrix->was_message_posted_by_me(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"); } } if(event.key.control && event.key.code == sf::Keyboard::D) { frame_skip_text_entry = true; BodyItem *selected = tabs[selected_tab].body->get_selected(); if(selected) { if(!is_state_message_type(static_cast(selected->userdata))) { if(static_cast(selected->userdata)->event_id.empty()) { // TODO: Show inline notification show_notification("QuickMedia", "You can't delete a message that hasn't been sent yet"); } else { //set_body_as_deleted(static_cast(selected->userdata), selected); void *selected_message = selected->userdata; post_task_queue.push([this, ¤t_room, selected_message]() { ProvisionalMessage provisional_message; std::string err_msg; if(matrix->delete_message(current_room, selected_message, err_msg) != PluginResult::OK) { // TODO: Show inline notification fprintf(stderr, "Failed to delete message, reason: %s\n", err_msg.c_str()); } return provisional_message; }); } } } else { // TODO: Show inline notification show_notification("QuickMedia", "No message selected for deletion"); } } } } else if(event.type == sf::Event::KeyPressed && chat_state == ChatState::URL_SELECTION) { if(event.key.code == sf::Keyboard::Up || (event.key.control && event.key.code == sf::Keyboard::K)) { url_selection_body.select_previous_item(); } else if(event.key.code == sf::Keyboard::Down || (event.key.control && event.key.code == sf::Keyboard::J)) { url_selection_body.select_next_item(); } else if(event.key.code == sf::Keyboard::PageUp) { url_selection_body.select_previous_page(); } else if(event.key.code == sf::Keyboard::PageDown) { url_selection_body.select_next_page(); } else if(event.key.code == sf::Keyboard::Home) { url_selection_body.select_first_item(); } else if(event.key.code == sf::Keyboard::End) { url_selection_body.select_last_item(); } else if(event.key.code == sf::Keyboard::Escape) { url_selection_body.clear_items(); chat_state = ChatState::NAVIGATING; } else if(event.key.code == sf::Keyboard::Enter) { BodyItem *selected_item = url_selection_body.get_selected(); if(!selected_item) continue; launch_url(selected_item->get_title()); } } if((chat_state == ChatState::TYPING_MESSAGE || chat_state == ChatState::REPLYING || chat_state == ChatState::EDITING) && selected_tab == MESSAGES_TAB_INDEX && !frame_skip_text_entry) { frame_skip_text_entry = false; if(event.type == sf::Event::TextEntered) { // TODO: Also show typing event when ctrl+v pasting? if(event.text.unicode != 13) { // Return key start_typing_timer.restart(); if(!typing && current_room) { fprintf(stderr, "Started typing\n"); typing_state_queue.push(true); } typing = true; } } 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; currently_operating_on_item = nullptr; if(typing && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } } //chat_input.on_event(event); chat_input.process_event(event); } } frame_skip_text_entry = false; matrix_chat_page->update(); switch(new_page) { case PageType::FILE_MANAGER: { new_page = PageType::CHAT; if(current_room) { for(ChatTab &tab : tabs) { tab.body->clear_cache(); } auto file_manager_page = std::make_unique(this); file_manager_page->set_current_directory(get_home_dir().data); auto file_manager_body = create_body(); file_manager_page->get_files_in_directory(file_manager_body->items); std::vector file_manager_tabs; file_manager_tabs.push_back(Tab{std::move(file_manager_body), std::move(file_manager_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); selected_files.clear(); page_loop(file_manager_tabs); if(selected_files.empty()) { fprintf(stderr, "No files selected!\n"); } else { // TODO: Upload multiple files. upload_file(selected_files[0]); } redraw = true; } break; } case PageType::CHAT_LOGIN: { previous_messages_future.cancel(); cleanup_tasks(); tabs.clear(); unreferenced_events.clear(); unresolved_reactions.clear(); all_messages.clear(); new_page = PageType::CHAT; matrix->stop_sync(); matrix->logout(); // TODO: Instead of doing this, exit this current function and navigate to chat login page instead. //delete current_plugin; //current_plugin = new Matrix(); window.setTitle("QuickMedia - matrix"); current_page = PageType::CHAT_LOGIN; chat_login_page(); if(current_page == PageType::CHAT) after_matrix_login_page(); exit(0); break; } default: break; } if(typing && start_typing_timer.getElapsedTime().asSeconds() >= typing_timeout_seconds && current_room) { fprintf(stderr, "Stopped typing\n"); typing = false; typing_state_queue.push(false); } if(current_room && current_room->userdata && room_avatar_thumbnail_data->loading_state == LoadingState::NOT_LOADED) AsyncImageLoader::get_instance().load_thumbnail(static_cast(current_room->userdata)->thumbnail_url, false, sf::Vector2i(32, 32), use_tor, room_avatar_thumbnail_data); if(room_avatar_thumbnail_data->loading_state == LoadingState::FINISHED_LOADING && room_avatar_thumbnail_data->image->getSize().x > 0 && room_avatar_thumbnail_data->image->getSize().y > 0) { if(!room_avatar_thumbnail_data->texture.loadFromImage(*room_avatar_thumbnail_data->image)) fprintf(stderr, "Warning: failed to load texture for room avatar\n"); room_avatar_thumbnail_data->texture.setSmooth(true); //room_avatar_thumbnail_data->texture.generateMipmap(); room_avatar_thumbnail_data->image.reset(); room_avatar_thumbnail_data->loading_state = LoadingState::APPLIED_TO_TEXTURE; room_avatar_sprite.setTexture(room_avatar_thumbnail_data->texture, true); auto texture_size = room_avatar_sprite.getTexture()->getSize(); if(texture_size.x > 0 && texture_size.y > 0) { float width_ratio = (float)texture_size.x / (float)texture_size.y; float height_scale = room_avatar_height / (float)texture_size.y; float width_scale = height_scale * width_ratio; room_avatar_sprite.setScale(width_scale, height_scale); } } float room_name_padding_y = 0.0f; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) room_name_padding_y = room_name_total_height; chat_input_height_full = chat_input.get_height() + chat_input_padding_y * 2.0f; if(selected_tab != MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) chat_input_height_full = 0.0f; const float chat_height = chat_input.get_height(); if(std::abs(chat_height - prev_chat_height) > 1.0f) { prev_chat_height = chat_height; redraw = true; } if(redraw) { redraw = false; float room_name_padding_y = 0.0f; float padding_bottom = 0.0f; if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) { room_name_padding_y = 10.0f + room_name_total_height; tab_vertical_offset = 10.0f; } tab_shade_height = tab_spacer_height + std::floor(tab_vertical_offset) + tab_height + room_name_padding_y + padding_bottom; chat_input.set_max_width(window_size.x - (logo_padding_x + logo_size.x + chat_input_padding_x + logo_padding_x)); chat_input.set_position(sf::Vector2f(std::floor(logo_padding_x + logo_size.x + chat_input_padding_x), window_size.y - chat_height - chat_input_padding_y)); float body_padding_horizontal = 25.0f; float body_padding_vertical = 5.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; } 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); body_pos = sf::Vector2f(body_padding_horizontal, body_padding_vertical + tab_shade_height); body_size = sf::Vector2f(body_width, window_size.y - chat_input_shade.getSize().y - body_padding_vertical - tab_shade_height); more_messages_below_rect.setSize(sf::Vector2f(window_size.x, gradient_height)); more_messages_below_rect.setPosition(0.0f, std::floor(window_size.y - chat_input_shade.getSize().y - gradient_height)); logo_sprite.setPosition(logo_padding_x, std::floor(window_size.y - chat_input_shade.getSize().y * 0.5f - logo_size.y * 0.5f)); } while((provisional_message = provisional_message_queue.pop_if_available()) != std::nullopt) { if(!provisional_message->body_item || !provisional_message->message) continue; if(!provisional_message->event_id.empty()) { provisional_message->message->event_id = std::move(provisional_message->event_id); provisional_message->body_item->set_description_color(sf::Color::White); sent_messages[provisional_message->message->event_id] = std::move(provisional_message.value()); } else if(provisional_message->body_item) { provisional_message->body_item->set_description("Failed to send: " + provisional_message->body_item->get_description()); provisional_message->body_item->set_description_color(sf::Color::Red); provisional_message->body_item->userdata = nullptr; } } sync_data.messages.clear(); sync_data.pinned_events = std::nullopt; matrix->get_room_sync_data(current_room, sync_data); if(!sync_data.messages.empty()) { all_messages.insert(all_messages.end(), sync_data.messages.begin(), sync_data.messages.end()); filter_existing_messages(sync_data.messages); } filter_provisional_messages(sync_data.messages); add_new_messages_to_current_room(sync_data.messages); modify_related_messages_in_current_room(sync_data.messages); process_reactions(sync_data.messages); process_pinned_events(sync_data.pinned_events); if(set_read_marker_future.ready()) { set_read_marker_future.get(); read_marker_timer.restart(); setting_read_marker = false; } if(previous_messages_future.ready()) { Messages new_messages = previous_messages_future.get(); all_messages.insert(all_messages.end(), new_messages.begin(), new_messages.end()); if(new_messages.empty()) fetched_enough_messages = true; filter_sent_messages(new_messages); filter_existing_messages(new_messages); fprintf(stderr, "Finished fetching older messages, num new messages: %zu\n", new_messages.size()); size_t num_new_messages = new_messages.size(); if(num_new_messages > 0) { add_new_messages_to_current_room(new_messages); modify_related_messages_in_current_room(new_messages); process_reactions(new_messages); // TODO: Do not loop all items, only loop the new items resolve_unreferenced_events_with_body_items(tabs[MESSAGES_TAB_INDEX].body->items.data(), tabs[MESSAGES_TAB_INDEX].body->items.size()); } if(num_new_messages > 0 && current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; if(selected_tab == MESSAGES_TAB_INDEX) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } } if(fetch_users_future.ready()) { fetch_users_future.get(); current_room->users_fetched = true; update_pinned_messages_authors(); update_messages_authors(); } if(fetch_message_future.ready()) { FetchMessageResult fetch_message_result = fetch_message_future.get(); if(fetch_message_result.type == FetchMessageType::USER_UPDATE) { update_pinned_messages_author(fetch_message->user); update_messages_author(fetch_message->user); fetch_message = nullptr; } else if(fetch_message_result.type == FetchMessageType::MESSAGE) { fprintf(stderr, "Finished fetching message: %s\n", fetch_message_result.message ? fetch_message_result.message->event_id.c_str() : "(null)"); if(fetch_message_tab == PINNED_TAB_INDEX) { PinnedEventData *event_data = static_cast(fetch_body_item->userdata); if(fetch_message_result.message) { *fetch_body_item = *message_to_body_item(current_room, fetch_message_result.message.get(), current_room->get_user_display_name(me), me->user_id); event_data->status = FetchStatus::FINISHED_LOADING; event_data->message = fetch_message_result.message.get(); fetch_body_item->userdata = event_data; } else { fetch_body_item->set_description("Failed to load message!"); event_data->status = FetchStatus::FAILED_TO_LOAD; } } else if(fetch_message_tab == MESSAGES_TAB_INDEX) { if(fetch_message_result.message) { if(fetch_message_result.message->user == me) fetch_body_item->set_description_color(sf::Color(255, 100, 100)); fetch_body_item->embedded_item = message_to_body_item(current_room, fetch_message_result.message.get(), current_room->get_user_display_name(me), me->user_id); fetch_body_item->embedded_item_status = FetchStatus::FINISHED_LOADING; } else { fetch_body_item->embedded_item_status = FetchStatus::FAILED_TO_LOAD; } } } fetch_message_tab = -1; } //chat_input.update(); window.clear(back_color); const float width_per_tab = window_size.x / tabs.size(); tab_background.setSize(sf::Vector2f(std::floor(width_per_tab - tab_margin_x * 2.0f), tab_height)); if(chat_state == ChatState::URL_SELECTION) url_selection_body.draw(window, body_pos, body_size); else 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) + room_name_padding_y; tab_shade.setSize(sf::Vector2f(window_size.x, tab_shade_height)); window.draw(tab_shade); if(selected_tab == MESSAGES_TAB_INDEX || selected_tab == PINNED_TAB_INDEX) { float room_name_text_offset_x = 0.0f; if(room_avatar_sprite.getTexture() && room_avatar_sprite.getTexture()->getNativeHandle() != 0) { auto room_avatar_texture_size = room_avatar_sprite.getTexture()->getSize(); room_avatar_texture_size.x *= room_avatar_sprite.getScale().x; room_avatar_texture_size.y *= room_avatar_sprite.getScale().y; room_avatar_sprite.setPosition(body_pos.x, room_name_total_height * 0.5f - room_avatar_texture_size.y * 0.5f + 5.0f); window.draw(room_avatar_sprite, &circle_mask_shader); room_name_text_offset_x += room_avatar_texture_size.x + 10.0f; } room_name_text.setPosition(body_pos.x + room_name_text_offset_x, room_name_text_padding_y + 4.0f); window.draw(room_name_text); } int i = 0; for(ChatTab &tab : tabs) { if(i == selected_tab) { tab_background.setPosition(std::floor(i * width_per_tab + tab_margin_x), tab_spacer_height + std::floor(tab_vertical_offset) + room_name_padding_y); 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; } // TODO: Have one for each room. Also add bottom one? for fetching new messages (currently not implemented, is it needed?) if(previous_messages_future.valid() && selected_tab == MESSAGES_TAB_INDEX) { double progress = 0.5 + std::sin(std::fmod(gradient_inc, 360.0) * 0.017453292519943295 - 1.5707963267948966*0.5) * 0.5; gradient_inc += (frame_time_ms * 0.5); sf::Color top_color = interpolate_colors(back_color, sf::Color(175, 180, 188), progress); gradient_points[0].position.x = 0.0f; gradient_points[0].position.y = tab_shade_height; gradient_points[1].position.x = window_size.x; gradient_points[1].position.y = tab_shade_height; gradient_points[2].position.x = window_size.x; gradient_points[2].position.y = tab_shade_height + gradient_height; gradient_points[3].position.x = 0.0f; gradient_points[3].position.y = tab_shade_height + gradient_height; gradient_points[0].color = top_color; gradient_points[1].color = top_color; gradient_points[2].color = back_color; gradient_points[3].color = back_color; window.draw(gradient_points, 4, sf::Quads); // Note: sf::Quads doesn't work with egl } 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(currently_operating_on_item.get(), body_size.x) + 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); overlay.setFillColor(sf::Color(0, 0, 0, 240)); window.draw(overlay); sf::Vector2f body_item_pos(body_pos.x, window_size.y - chat_input_height_full - item_height); sf::Vector2f body_item_size(body_size.x, item_height); sf::RectangleShape item_background(sf::Vector2f(window_size.x, body_item_size.y + replying_to_text_height + margin)); item_background.setPosition(sf::Vector2f(0.0f, body_item_pos.y - replying_to_text_height - margin)); item_background.setFillColor(back_color); window.draw(item_background); 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, currently_operating_on_item.get(), body_item_pos, body_item_size); } if(selected_tab == MESSAGES_TAB_INDEX && current_room && current_room->userdata && !current_room->last_message_read) { if(tabs[selected_tab].body->is_last_item_fully_visible()) { BodyItem *current_room_body_item = static_cast(current_room->userdata); std::string room_desc = current_room_body_item->get_description(); if(strncmp(room_desc.c_str(), "Unread: ", 8) == 0) room_desc = room_desc.substr(8); size_t last_line_start = room_desc.rfind('\n'); if(last_line_start != std::string::npos && last_line_start != room_desc.size()) { ++last_line_start; size_t last_line_size = room_desc.size() - last_line_start; if(last_line_size >= 23 && memcmp(&room_desc[last_line_start], "** ", 3) == 0 && memcmp(&room_desc[room_desc.size() - 20], "unread mention(s) **", 20) == 0) room_desc.erase(room_desc.begin() + last_line_start - 1, room_desc.end()); } current_room_body_item->set_description(std::move(room_desc)); // TODO: Show a line like nheko instead for unread messages, or something else current_room_body_item->set_title_color(sf::Color::White); current_room->last_message_read = true; // TODO: Maybe set this instead when the mention is visible on the screen? current_room->unread_notification_count = 0; } else { window.draw(more_messages_below_rect); } } if(selected_tab == MESSAGES_TAB_INDEX && current_room && matrix->is_initial_sync_finished()) { BodyItem *last_visible_item = tabs[selected_tab].body->get_last_fully_visible_item(); if(last_visible_item && tabs[selected_tab].body->is_last_item_fully_visible() && !tabs[selected_tab].body->items.empty()) last_visible_item = tabs[selected_tab].body->items.back().get(); if(is_window_focused && chat_state != ChatState::URL_SELECTION && current_room && last_visible_item && !setting_read_marker && read_marker_timer.getElapsedTime().asMilliseconds() >= read_marker_timeout_ms) { Message *message = (Message*)last_visible_item->userdata; // TODO: What if two messages have the same timestamp? if(message && !message->event_id.empty() && message->timestamp > current_room->last_read_message_timestamp) { //read_marker_timeout_ms = read_marker_timeout_ms_default; current_room->last_read_message_timestamp = message->timestamp; // TODO: What if the message is no longer valid? setting_read_marker = true; RoomData *room = current_room; std::string event_id = message->event_id; set_read_marker_future = [this, room, event_id]() mutable { if(matrix->set_read_marker(room, event_id) != PluginResult::OK) { fprintf(stderr, "Warning: failed to set read marker to %s\n", event_id.c_str()); } }; } } } if(selected_tab == MESSAGES_TAB_INDEX && current_room) { window.draw(chat_input_shade); chat_input.draw(window); //chat_input.draw(window, false); window.draw(logo_sprite); } if(matrix && !matrix->is_initial_sync_finished()) { std::string err_msg; if(matrix->did_initial_sync_fail(err_msg)) { show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); window.close(); goto chat_page_end; } } window.display(); if(selected_tab == MESSAGES_TAB_INDEX) fetch_more_previous_messages_if_needed(); if(matrix_chat_page->should_clear_data) { matrix_chat_page->should_clear_data = false; std::string err_msg; while(!matrix->is_initial_sync_finished()) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); if(matrix->did_initial_sync_fail(err_msg)) { show_notification("QuickMedia", "Initial matrix sync failed, error: " + err_msg, Urgency::CRITICAL); window.close(); goto chat_page_end; } } //all_messages.clear(); tabs[MESSAGES_TAB_INDEX].body->clear_items(); Messages all_messages_new; matrix->get_all_synced_room_messages(current_room, all_messages_new); for(auto &message : all_messages_new) { fetched_messages_set.insert(message->event_id); } all_messages.insert(all_messages.end(), all_messages_new.begin(), all_messages_new.end()); //me = matrix->get_me(current_room); filter_sent_messages(all_messages_new); add_new_messages_to_current_room(all_messages_new); modify_related_messages_in_current_room(all_messages_new); unresolved_reactions.clear(); process_reactions(all_messages_new); if(current_room->initial_prev_messages_fetch) { current_room->initial_prev_messages_fetch = false; if(selected_tab == MESSAGES_TAB_INDEX) tabs[MESSAGES_TAB_INDEX].body->select_last_item(); } std::vector pinned_events; matrix->get_all_pinned_events(current_room, pinned_events); process_pinned_events(std::move(pinned_events)); } if(go_to_previous_page) { go_to_previous_page = false; goto chat_page_end; } } chat_page_end: previous_messages_future.cancel(); cleanup_tasks(); window.setTitle("QuickMedia - matrix"); } void Program::after_matrix_login_page() { if(!window.isOpen()) exit(exit_code); auto rooms_body = create_body(); rooms_body->thumbnail_mask_shader = &circle_mask_shader; auto matrix_rooms_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_rooms_page = std::make_unique(this, rooms_body.get(), "All rooms", nullptr, matrix_rooms_page_search_bar.get()); auto rooms_tags_body = create_body(); rooms_tags_body->thumbnail_mask_shader = &circle_mask_shader; auto matrix_rooms_tage_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_rooms_tag_page = std::make_unique(this, rooms_tags_body.get(), matrix_rooms_tage_page_search_bar.get()); auto invites_body = create_body(); invites_body->thumbnail_mask_shader = &circle_mask_shader; auto matrix_invites_page_search_bar = create_search_bar("Search...", SEARCH_DELAY_FILTER); auto matrix_invites_page = std::make_unique(this, matrix, invites_body.get(), matrix_invites_page_search_bar.get()); MatrixQuickMedia matrix_handler(this, matrix, matrix_rooms_page.get(), matrix_rooms_tag_page.get(), matrix_invites_page.get()); bool sync_cached = false; matrix->start_sync(&matrix_handler, sync_cached); is_login_sync = !sync_cached; std::vector tabs; tabs.push_back(Tab{std::move(rooms_body), std::move(matrix_rooms_page), std::move(matrix_rooms_page_search_bar)}); tabs.push_back(Tab{std::move(rooms_tags_body), std::move(matrix_rooms_tag_page), std::move(matrix_rooms_tage_page_search_bar)}); tabs.push_back(Tab{std::move(invites_body), std::move(matrix_invites_page), std::move(matrix_invites_page_search_bar)}); while(window.isOpen()) { page_loop(tabs); } matrix->stop_sync(); } }