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