Merge branch 'master' of https://github.com/uowuo/abaddon into voice

This commit is contained in:
ouwou
2023-04-13 16:29:56 -04:00
29 changed files with 721 additions and 238 deletions

View File

@@ -67,7 +67,7 @@ jobs:
with: with:
cond: ${{ matrix.mindeps == true }} cond: ${{ matrix.mindeps == true }}
if_true: | if_true: |
cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.buildtype }} -DUSE_LIBHANDY=OFF -DENABLE_VOICE=OFF cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.buildtype }} -DUSE_LIBHANDY=OFF -DENABLE_VOICE=OFF -DENABLE_NOTIFICATION_SOUNDS=OFF
cmake --build build cmake --build build
if_false: | if_false: |
cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.buildtype }} cmake -GNinja -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.buildtype }}

3
.gitmodules vendored
View File

@@ -10,3 +10,6 @@
[submodule "subprojects/keychain"] [submodule "subprojects/keychain"]
path = subprojects/keychain path = subprojects/keychain
url = https://github.com/hrantzsch/keychain url = https://github.com/hrantzsch/keychain
[submodule "subprojects/miniaudio"]
path = subprojects/miniaudio
url = https://github.com/mackron/miniaudio

View File

@@ -10,6 +10,7 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake/")
option(USE_LIBHANDY "Enable features that require libhandy (default)" ON) option(USE_LIBHANDY "Enable features that require libhandy (default)" ON)
option(ENABLE_VOICE "Enable voice suppport" ON) option(ENABLE_VOICE "Enable voice suppport" ON)
option(USE_KEYCHAIN "Store the token in the keychain (default)" ON) option(USE_KEYCHAIN "Store the token in the keychain (default)" ON)
option(ENABLE_NOTIFICATION_SOUNDS "Enable notification sounds (default)" ON)
find_package(nlohmann_json REQUIRED) find_package(nlohmann_json REQUIRED)
find_package(CURL) find_package(CURL)
@@ -49,6 +50,9 @@ file(GLOB_RECURSE ABADDON_SOURCES
"src/*.cpp" "src/*.cpp"
) )
list(FILTER ABADDON_SOURCES EXCLUDE REGEX ".*notifier_gio\\.cpp$")
list(FILTER ABADDON_SOURCES EXCLUDE REGEX ".*notifier_null\\.cpp$")
add_executable(abaddon ${ABADDON_SOURCES}) add_executable(abaddon ${ABADDON_SOURCES})
target_include_directories(abaddon PUBLIC ${PROJECT_SOURCE_DIR}/src) target_include_directories(abaddon PUBLIC ${PROJECT_SOURCE_DIR}/src)
target_include_directories(abaddon PUBLIC ${PROJECT_BINARY_DIR}) target_include_directories(abaddon PUBLIC ${PROJECT_BINARY_DIR})
@@ -65,6 +69,12 @@ if ((CMAKE_CXX_COMPILER_ID STREQUAL "GNU") OR
target_link_libraries(abaddon stdc++fs) target_link_libraries(abaddon stdc++fs)
endif () endif ()
if (NOT WIN32)
target_sources(abaddon PRIVATE src/notifications/notifier_gio.cpp)
else ()
target_sources(abaddon PRIVATE src/notifications/notifier_null.cpp)
endif ()
if (IXWebSocket_LIBRARIES) if (IXWebSocket_LIBRARIES)
target_link_libraries(abaddon ${IXWebSocket_LIBRARIES}) target_link_libraries(abaddon ${IXWebSocket_LIBRARIES})
find_library(MBEDTLS_X509_LIBRARY mbedx509) find_library(MBEDTLS_X509_LIBRARY mbedx509)
@@ -119,12 +129,14 @@ if (USE_KEYCHAIN)
endif () endif ()
endif () endif ()
set(USE_MINIAUDIO FALSE)
if (ENABLE_VOICE) if (ENABLE_VOICE)
target_compile_definitions(abaddon PRIVATE WITH_VOICE) target_compile_definitions(abaddon PRIVATE WITH_VOICE)
find_package(PkgConfig) find_package(PkgConfig)
target_include_directories(abaddon PUBLIC subprojects/miniaudio) set(USE_MINIAUDIO TRUE)
pkg_check_modules(Opus REQUIRED IMPORTED_TARGET opus) pkg_check_modules(Opus REQUIRED IMPORTED_TARGET opus)
target_link_libraries(abaddon PkgConfig::Opus) target_link_libraries(abaddon PkgConfig::Opus)
@@ -133,3 +145,12 @@ if (ENABLE_VOICE)
target_link_libraries(abaddon ${CMAKE_DL_LIBS}) target_link_libraries(abaddon ${CMAKE_DL_LIBS})
endif () endif ()
if (${ENABLE_NOTIFICATION_SOUNDS})
set(USE_MINIAUDIO TRUE)
target_compile_definitions(abaddon PRIVATE ENABLE_NOTIFICATION_SOUNDS)
endif ()
if (USE_MINIAUDIO)
target_include_directories(abaddon PUBLIC subprojects/miniaudio)
endif ()

View File

@@ -14,6 +14,7 @@ Current features:
* Identifies to Discord as the web client unlike other clients so less likely to be falsely flagged as spam<sup>1</sup> * Identifies to Discord as the web client unlike other clients so less likely to be falsely flagged as spam<sup>1</sup>
* Set status * Set status
* Unread and mention indicators * Unread and mention indicators
* Notifications (non-Windows)
* Start new DMs and group DMs * Start new DMs and group DMs
* View user profiles (notes, mutual servers, mutual friends) * View user profiles (notes, mutual servers, mutual friends)
* Kick, ban, and unban members * Kick, ban, and unban members
@@ -134,15 +135,17 @@ spam filter's wrath:
* [gtkmm](https://www.gtkmm.org/en/) * [gtkmm](https://www.gtkmm.org/en/)
* [JSON for Modern C++](https://github.com/nlohmann/json) * [JSON for Modern C++](https://github.com/nlohmann/json)
* [IXWebSocket](https://github.com/machinezone/IXWebSocket) * [IXWebSocket](https://github.com/machinezone/IXWebSocket) (provided as submodule)
* [libcurl](https://curl.se/) * [libcurl](https://curl.se/)
* [zlib](https://zlib.net/) * [zlib](https://zlib.net/)
* [SQLite3](https://www.sqlite.org/index.html) * [SQLite3](https://www.sqlite.org/index.html)
* [libhandy](https://gnome.pages.gitlab.gnome.org/libhandy/) (optional) * [libhandy](https://gnome.pages.gitlab.gnome.org/libhandy/) (optional)
* [keychain](https://github.com/hrantzsch/keychain) (optional, provided as submodule)
* [miniaudio](https://miniaud.io/) (optional, provided as submodule)
### TODO: ### TODO:
* Voice support * Voice support (in progress)
* User activities * User activities
* More server management stuff * More server management stuff
* A bunch of other stuff * A bunch of other stuff
@@ -301,6 +304,13 @@ For example, memory_db would be set by adding `memory_db = true` under the line
| `mentionbadgetextcolor` | string | color to use for number displayed on mention badges | | `mentionbadgetextcolor` | string | color to use for number displayed on mention badges |
| `unreadcolor` | string | color to use for the unread indicator | | `unreadcolor` | string | color to use for the unread indicator |
#### notifications
| Setting | Type | Default | Description |
|-------------|---------|--------------------------|-------------------------------------------------------------------------------|
| `enabled` | boolean | true (if not on Windows) | Enable desktop notifications |
| `playsound` | boolean | true | Enable notification sounds. Requires ENABLE_NOTIFICATION_SOUNDS=TRUE in CMake |
### Environment variables ### Environment variables
| variable | Description | | variable | Description |

View File

@@ -20,6 +20,7 @@
#include "windows/threadswindow.hpp" #include "windows/threadswindow.hpp"
#include "windows/voicewindow.hpp" #include "windows/voicewindow.hpp"
#include "startup.hpp" #include "startup.hpp"
#include "notifications/notifications.hpp"
#ifdef WITH_LIBHANDY #ifdef WITH_LIBHANDY
#include <handy.h> #include <handy.h>
@@ -303,6 +304,14 @@ int Abaddon::StartGTK() {
m_main_window->UpdateMenus(); m_main_window->UpdateMenus();
auto action_go_to_channel = Gio::SimpleAction::create("go-to-channel", Glib::VariantType("s"));
action_go_to_channel->signal_activate().connect([this](const Glib::VariantBase &param) {
const auto id_str = Glib::VariantBase::cast_dynamic<Glib::Variant<Glib::ustring>>(param);
const Snowflake id = id_str.get();
ActionChannelOpened(id, false);
});
m_gtk_app->add_action(action_go_to_channel);
m_gtk_app->hold(); m_gtk_app->hold();
m_main_window->show(); m_main_window->show();
@@ -360,6 +369,7 @@ void Abaddon::DiscordOnReady() {
void Abaddon::DiscordOnMessageCreate(const Message &message) { void Abaddon::DiscordOnMessageCreate(const Message &message) {
m_main_window->UpdateChatNewMessage(message); m_main_window->UpdateChatNewMessage(message);
m_notifications.CheckMessage(message);
} }
void Abaddon::DiscordOnMessageDelete(Snowflake id, Snowflake channel_id) { void Abaddon::DiscordOnMessageDelete(Snowflake id, Snowflake channel_id) {
@@ -774,6 +784,18 @@ std::string Abaddon::GetStateCachePath(const std::string &path) {
return GetStateCachePath() + path; return GetStateCachePath() + path;
} }
Glib::RefPtr<Gtk::Application> Abaddon::GetApp() {
return m_gtk_app;
}
bool Abaddon::IsMainWindowActive() {
return m_main_window->has_toplevel_focus();
}
Snowflake Abaddon::GetActiveChannelID() const noexcept {
return m_main_window->GetChatActiveChannel();
}
void Abaddon::ActionConnect() { void Abaddon::ActionConnect() {
if (!m_discord.IsStarted()) if (!m_discord.IsStarted())
StartDiscord(); StartDiscord();
@@ -803,6 +825,8 @@ void Abaddon::ActionChannelOpened(Snowflake id, bool expand_to) {
} }
if (id == m_main_window->GetChatActiveChannel()) return; if (id == m_main_window->GetChatActiveChannel()) return;
m_notifications.WithdrawChannel(id);
m_main_window->GetChatWindow()->SetTopic(""); m_main_window->GetChatWindow()->SetTopic("");
const auto channel = m_discord.GetChannel(id); const auto channel = m_discord.GetChannel(id);

View File

@@ -3,11 +3,15 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_set> #include <unordered_set>
#include <gtkmm/application.h>
#include <gtkmm/cssprovider.h>
#include <gtkmm/statusicon.h>
#include "discord/discord.hpp" #include "discord/discord.hpp"
#include "windows/mainwindow.hpp" #include "windows/mainwindow.hpp"
#include "settings.hpp" #include "settings.hpp"
#include "imgmanager.hpp" #include "imgmanager.hpp"
#include "emojis.hpp" #include "emojis.hpp"
#include "notifications/notifications.hpp"
#define APP_TITLE "Abaddon" #define APP_TITLE "Abaddon"
@@ -110,6 +114,10 @@ public:
static std::string GetResPath(const std::string &path); static std::string GetResPath(const std::string &path);
static std::string GetStateCachePath(const std::string &path); static std::string GetStateCachePath(const std::string &path);
[[nodiscard]] Glib::RefPtr<Gtk::Application> GetApp();
[[nodiscard]] bool IsMainWindowActive();
[[nodiscard]] Snowflake GetActiveChannelID() const noexcept;
protected: protected:
void RunFirstTimeDiscordStartup(); void RunFirstTimeDiscordStartup();
@@ -172,4 +180,6 @@ private:
Glib::RefPtr<Gtk::CssProvider> m_css_low_provider; // registered with a lower priority to allow better customization Glib::RefPtr<Gtk::CssProvider> m_css_low_provider; // registered with a lower priority to allow better customization
Glib::RefPtr<Gtk::StatusIcon> m_tray; Glib::RefPtr<Gtk::StatusIcon> m_tray;
std::unique_ptr<MainWindow> m_main_window; // wah wah cant create a gtkstylecontext fuck you std::unique_ptr<MainWindow> m_main_window; // wah wah cant create a gtkstylecontext fuck you
Notifications m_notifications;
}; };

View File

@@ -1,14 +1,9 @@
#include "chatmessage.hpp" #include "chatmessage.hpp"
#include "constants.hpp"
#include "lazyimage.hpp" #include "lazyimage.hpp"
#include "misc/chatutil.hpp"
#include <unordered_map> #include <unordered_map>
constexpr static int EmojiSize = 24; // settings eventually
constexpr static int AvatarSize = 32;
constexpr static int EmbedImageWidth = 400;
constexpr static int EmbedImageHeight = 300;
constexpr static int ThumbnailSize = 100;
constexpr static int StickerComponentSize = 160;
ChatMessageItemContainer::ChatMessageItemContainer() ChatMessageItemContainer::ChatMessageItemContainer()
: m_main(Gtk::ORIENTATION_VERTICAL) { : m_main(Gtk::ORIENTATION_VERTICAL) {
add(m_main); add(m_main);
@@ -190,11 +185,11 @@ void ChatMessageItemContainer::UpdateTextComponent(Gtk::TextView *tv) {
case MessageType::DEFAULT: case MessageType::DEFAULT:
case MessageType::INLINE_REPLY: case MessageType::INLINE_REPLY:
b->insert(s, data->Content); b->insert(s, data->Content);
HandleRoleMentions(b); ChatUtil::HandleRoleMentions(b);
HandleUserMentions(b); ChatUtil::HandleUserMentions(b, ChannelID, false);
HandleLinks(*tv); HandleLinks(*tv);
HandleChannelMentions(tv); HandleChannelMentions(tv);
HandleEmojis(*tv); ChatUtil::HandleEmojis(*tv);
break; break;
case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION: case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION:
b->insert_markup(s, "<span color='#999999'><i>[boosted server]</i></span>"); b->insert_markup(s, "<span color='#999999'><i>[boosted server]</i></span>");
@@ -216,10 +211,10 @@ void ChatMessageItemContainer::UpdateTextComponent(Gtk::TextView *tv) {
} }
} else { } else {
b->insert(s, data->Content); b->insert(s, data->Content);
HandleUserMentions(b); ChatUtil::HandleUserMentions(b, ChannelID, false);
HandleLinks(*tv); HandleLinks(*tv);
HandleChannelMentions(tv); HandleChannelMentions(tv);
HandleEmojis(*tv); ChatUtil::HandleEmojis(*tv);
} }
} break; } break;
case MessageType::RECIPIENT_ADD: { case MessageType::RECIPIENT_ADD: {
@@ -711,8 +706,8 @@ Gtk::Widget *ChatMessageItemContainer::CreateReplyComponent(const Message &data)
Gtk::TextBuffer::iterator start, end; Gtk::TextBuffer::iterator start, end;
buf->get_bounds(start, end); buf->get_bounds(start, end);
buf->set_text(referenced.Content); buf->set_text(referenced.Content);
CleanupEmojis(buf); ChatUtil::CleanupEmojis(buf);
HandleUserMentions(buf); ChatUtil::HandleUserMentions(buf, ChannelID, false);
HandleChannelMentions(buf); HandleChannelMentions(buf);
text = Glib::Markup::escape_text(buf->get_text()); text = Glib::Markup::escape_text(buf->get_text());
} }
@@ -734,13 +729,6 @@ Gtk::Widget *ChatMessageItemContainer::CreateReplyComponent(const Message &data)
return box; return box;
} }
Glib::ustring ChatMessageItemContainer::GetText(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
Gtk::TextBuffer::iterator a, b;
buf->get_bounds(a, b);
auto slice = buf->get_slice(a, b, true);
return slice;
}
bool ChatMessageItemContainer::IsEmbedImageOnly(const EmbedData &data) { bool ChatMessageItemContainer::IsEmbedImageOnly(const EmbedData &data) {
if (!data.Thumbnail.has_value()) return false; if (!data.Thumbnail.has_value()) return false;
if (data.Author.has_value()) return false; if (data.Author.has_value()) return false;
@@ -752,199 +740,10 @@ bool ChatMessageItemContainer::IsEmbedImageOnly(const EmbedData &data) {
return data.Thumbnail->ProxyURL.has_value() && data.Thumbnail->URL.has_value() && data.Thumbnail->Width.has_value() && data.Thumbnail->Height.has_value(); return data.Thumbnail->ProxyURL.has_value() && data.Thumbnail->URL.has_value() && data.Thumbnail->Width.has_value() && data.Thumbnail->Height.has_value();
} }
void ChatMessageItemContainer::HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
constexpr static const auto mentions_regex = R"(<@&(\d+)>)";
static auto rgx = Glib::Regex::create(mentions_regex);
Glib::ustring text = GetText(buf);
const auto &discord = Abaddon::Get().GetDiscordClient();
int startpos = 0;
Glib::MatchInfo match;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const Glib::ustring role_id = match.fetch(1);
const auto role = discord.GetRole(role_id);
if (!role.has_value()) {
startpos = mend;
continue;
}
Glib::ustring replacement;
if (role->HasColor()) {
replacement = "<b><span color=\"#" + IntToCSSColor(role->Color) + "\">@" + role->GetEscapedName() + "</span></b>";
} else {
replacement = "<b>@" + role->GetEscapedName() + "</b>";
}
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
const auto start_it = buf->get_iter_at_offset(chars_start);
const auto end_it = buf->get_iter_at_offset(chars_end);
auto it = buf->erase(start_it, end_it);
buf->insert_markup(it, replacement);
text = GetText(buf);
startpos = 0;
}
}
void ChatMessageItemContainer::HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) const {
constexpr static const auto mentions_regex = R"(<@!?(\d+)>)";
static auto rgx = Glib::Regex::create(mentions_regex);
Glib::ustring text = GetText(buf);
const auto &discord = Abaddon::Get().GetDiscordClient();
int startpos = 0;
Glib::MatchInfo match;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const Glib::ustring user_id = match.fetch(1);
const auto user = discord.GetUser(user_id);
const auto channel = discord.GetChannel(ChannelID);
if (!user.has_value() || !channel.has_value()) {
startpos = mend;
continue;
}
Glib::ustring replacement;
if (channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM)
replacement = user->GetEscapedBoldString<true>();
else {
const auto role_id = user->GetHoistedRole(*channel->GuildID, true);
const auto role = discord.GetRole(role_id);
if (!role.has_value())
replacement = user->GetEscapedBoldString<true>();
else
replacement = "<span color=\"#" + IntToCSSColor(role->Color) + "\">" + user->GetEscapedBoldString<true>() + "</span>";
}
// regex returns byte positions and theres no straightforward way in the c++ bindings to deal with that :(
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
const auto start_it = buf->get_iter_at_offset(chars_start);
const auto end_it = buf->get_iter_at_offset(chars_end);
auto it = buf->erase(start_it, end_it);
buf->insert_markup(it, replacement);
text = GetText(buf);
startpos = 0;
}
}
void ChatMessageItemContainer::HandleStockEmojis(Gtk::TextView &tv) {
Abaddon::Get().GetEmojis().ReplaceEmojis(tv.get_buffer(), EmojiSize);
}
void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) {
static auto rgx = Glib::Regex::create(R"(<a?:([\w\d_]+):(\d+)>)");
auto &img = Abaddon::Get().GetImageManager();
auto buf = tv.get_buffer();
auto text = GetText(buf);
Glib::MatchInfo match;
int startpos = 0;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const bool is_animated = match.fetch(0)[1] == 'a';
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
auto start_it = buf->get_iter_at_offset(chars_start);
auto end_it = buf->get_iter_at_offset(chars_end);
startpos = mend;
if (is_animated && Abaddon::Get().GetSettings().ShowAnimations) {
const auto mark_start = buf->create_mark(start_it, false);
end_it.backward_char();
const auto mark_end = buf->create_mark(end_it, false);
const auto cb = [&tv, buf, mark_start, mark_end](const Glib::RefPtr<Gdk::PixbufAnimation> &pixbuf) {
auto start_it = mark_start->get_iter();
auto end_it = mark_end->get_iter();
end_it.forward_char();
buf->delete_mark(mark_start);
buf->delete_mark(mark_end);
auto it = buf->erase(start_it, end_it);
const auto anchor = buf->create_child_anchor(it);
auto img = Gtk::manage(new Gtk::Image(pixbuf));
img->show();
tv.add_child_at_anchor(*img, anchor);
};
img.LoadAnimationFromURL(EmojiData::URLFromID(match.fetch(2), "gif"), EmojiSize, EmojiSize, sigc::track_obj(cb, tv));
} else {
// can't erase before pixbuf is ready or else marks that are in the same pos get mixed up
const auto mark_start = buf->create_mark(start_it, false);
end_it.backward_char();
const auto mark_end = buf->create_mark(end_it, false);
const auto cb = [buf, mark_start, mark_end](const Glib::RefPtr<Gdk::Pixbuf> &pixbuf) {
auto start_it = mark_start->get_iter();
auto end_it = mark_end->get_iter();
end_it.forward_char();
buf->delete_mark(mark_start);
buf->delete_mark(mark_end);
auto it = buf->erase(start_it, end_it);
int width, height;
GetImageDimensions(pixbuf->get_width(), pixbuf->get_height(), width, height, EmojiSize, EmojiSize);
buf->insert_pixbuf(it, pixbuf->scale_simple(width, height, Gdk::INTERP_BILINEAR));
};
img.LoadFromURL(EmojiData::URLFromID(match.fetch(2)), sigc::track_obj(cb, tv));
}
text = GetText(buf);
}
}
void ChatMessageItemContainer::HandleEmojis(Gtk::TextView &tv) {
if (Abaddon::Get().GetSettings().ShowStockEmojis) HandleStockEmojis(tv);
if (Abaddon::Get().GetSettings().ShowCustomEmojis) HandleCustomEmojis(tv);
}
void ChatMessageItemContainer::CleanupEmojis(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
static auto rgx = Glib::Regex::create(R"(<a?:([\w\d_]+):(\d+)>)");
auto text = GetText(buf);
Glib::MatchInfo match;
int startpos = 0;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const auto new_term = ":" + match.fetch(1) + ":";
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
auto start_it = buf->get_iter_at_offset(chars_start);
auto end_it = buf->get_iter_at_offset(chars_end);
startpos = mend;
const auto it = buf->erase(start_it, end_it);
const int alen = static_cast<int>(text.size());
text = GetText(buf);
const int blen = static_cast<int>(text.size());
startpos -= (alen - blen);
buf->insert(it, new_term);
text = GetText(buf);
}
}
void ChatMessageItemContainer::HandleChannelMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) { void ChatMessageItemContainer::HandleChannelMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
static auto rgx = Glib::Regex::create(R"(<#(\d+)>)"); static auto rgx = Glib::Regex::create(R"(<#(\d+)>)");
Glib::ustring text = GetText(buf); Glib::ustring text = ChatUtil::GetText(buf);
const auto &discord = Abaddon::Get().GetDiscordClient(); const auto &discord = Abaddon::Get().GetDiscordClient();
@@ -975,7 +774,7 @@ void ChatMessageItemContainer::HandleChannelMentions(const Glib::RefPtr<Gtk::Tex
it = buf->insert_with_tag(it, "#" + *chan->Name, tag); it = buf->insert_with_tag(it, "#" + *chan->Name, tag);
// rescan the whole thing so i dont have to deal with fixing match positions // rescan the whole thing so i dont have to deal with fixing match positions
text = GetText(buf); text = ChatUtil::GetText(buf);
startpos = 0; startpos = 0;
} }
} }
@@ -1036,7 +835,7 @@ void ChatMessageItemContainer::HandleLinks(Gtk::TextView &tv) {
const auto rgx = Glib::Regex::create(R"(\bhttps?:\/\/[^\s]+\.[^\s]+\b)"); const auto rgx = Glib::Regex::create(R"(\bhttps?:\/\/[^\s]+\.[^\s]+\b)");
auto buf = tv.get_buffer(); auto buf = tv.get_buffer();
Glib::ustring text = GetText(buf); Glib::ustring text = ChatUtil::GetText(buf);
int startpos = 0; int startpos = 0;
Glib::MatchInfo match; Glib::MatchInfo match;

View File

@@ -29,17 +29,8 @@ protected:
Gtk::Widget *CreateReactionsComponent(const Message &data); Gtk::Widget *CreateReactionsComponent(const Message &data);
Gtk::Widget *CreateReplyComponent(const Message &data); Gtk::Widget *CreateReplyComponent(const Message &data);
static Glib::ustring GetText(const Glib::RefPtr<Gtk::TextBuffer> &buf);
static bool IsEmbedImageOnly(const EmbedData &data); static bool IsEmbedImageOnly(const EmbedData &data);
static void HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf);
void HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) const;
static void HandleStockEmojis(Gtk::TextView &tv);
static void HandleCustomEmojis(Gtk::TextView &tv);
static void HandleEmojis(Gtk::TextView &tv);
static void CleanupEmojis(const Glib::RefPtr<Gtk::TextBuffer> &buf);
void HandleChannelMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf); void HandleChannelMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf);
void HandleChannelMentions(Gtk::TextView *tv); void HandleChannelMentions(Gtk::TextView *tv);
bool OnClickChannel(GdkEventButton *ev); bool OnClickChannel(GdkEventButton *ev);

View File

@@ -10,3 +10,9 @@ constexpr static int NitroAttachmentSizeLimit = 100 * 1024 * 1024;
constexpr static int BoostLevel2AttachmentSizeLimit = 50 * 1024 * 1024; constexpr static int BoostLevel2AttachmentSizeLimit = 50 * 1024 * 1024;
constexpr static int BoostLevel3AttachmentSizeLimit = 100 * 1024 * 1024; constexpr static int BoostLevel3AttachmentSizeLimit = 100 * 1024 * 1024;
constexpr static int MaxMessagePayloadSize = 199 * 1024 * 1024; constexpr static int MaxMessagePayloadSize = 199 * 1024 * 1024;
constexpr static int EmojiSize = 24; // settings eventually
constexpr static int AvatarSize = 32;
constexpr static int EmbedImageWidth = 400;
constexpr static int EmbedImageHeight = 300;
constexpr static int ThumbnailSize = 100;
constexpr static int StickerComponentSize = 160;

View File

@@ -94,6 +94,18 @@ const UserData &DiscordClient::GetUserData() const {
return m_user_data; return m_user_data;
} }
const UserGuildSettingsData &DiscordClient::GetUserGuildSettings() const {
return m_user_guild_settings;
}
std::optional<UserGuildSettingsEntry> DiscordClient::GetSettingsForGuild(Snowflake id) const {
if (const auto it = m_user_guild_settings.Entries.find(id); it != m_user_guild_settings.Entries.end()) {
return it->second;
}
return std::nullopt;
}
std::vector<Snowflake> DiscordClient::GetUserSortedGuilds() const { std::vector<Snowflake> DiscordClient::GetUserSortedGuilds() const {
// sort order is unfolder'd guilds sorted by id descending, then guilds in folders in array order // sort order is unfolder'd guilds sorted by id descending, then guilds in folders in array order
// todo: make sure folder'd guilds are sorted properly // todo: make sure folder'd guilds are sorted properly
@@ -1701,6 +1713,8 @@ void DiscordClient::HandleGatewayReady(const GatewayMessage &msg) {
m_session_id = data.SessionID; m_session_id = data.SessionID;
m_user_data = data.SelfUser; m_user_data = data.SelfUser;
m_user_settings = data.Settings; m_user_settings = data.Settings;
m_user_guild_settings = data.GuildSettings;
// TODO handle update
HandleReadyReadState(data); HandleReadyReadState(data);
HandleReadyGuildSettings(data); HandleReadyGuildSettings(data);
@@ -2118,6 +2132,9 @@ void DiscordClient::HandleGatewayMessageAck(const GatewayMessage &msg) {
void DiscordClient::HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg) { void DiscordClient::HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg) {
UserGuildSettingsUpdateData data = msg.Data; UserGuildSettingsUpdateData data = msg.Data;
m_user_guild_settings.Entries[data.Settings.GuildID] = data.Settings;
const bool for_dms = !data.Settings.GuildID.IsValid(); const bool for_dms = !data.Settings.GuildID.IsValid();
const auto channels = for_dms ? GetPrivateChannels() : GetChannelsInGuild(data.Settings.GuildID); const auto channels = for_dms ? GetPrivateChannels() : GetChannelsInGuild(data.Settings.GuildID);
@@ -2678,7 +2695,7 @@ void DiscordClient::HandleReadyGuildSettings(const ReadyEventData &data) {
} }
const auto now = Snowflake::FromNow(); const auto now = Snowflake::FromNow();
for (const auto &entry : data.GuildSettings.Entries) { for (const auto &[guild_id, entry] : data.GuildSettings.Entries) {
// even if muted is true a guild/channel can be unmuted if the current time passes mute_config.end_time // even if muted is true a guild/channel can be unmuted if the current time passes mute_config.end_time
if (entry.Muted) { if (entry.Muted) {
if (entry.MuteConfig.EndTime.has_value()) { if (entry.MuteConfig.EndTime.has_value()) {

View File

@@ -33,6 +33,8 @@ public:
std::unordered_set<Snowflake> GetGuilds() const; std::unordered_set<Snowflake> GetGuilds() const;
const UserData &GetUserData() const; const UserData &GetUserData() const;
const UserGuildSettingsData &GetUserGuildSettings() const;
std::optional<UserGuildSettingsEntry> GetSettingsForGuild(Snowflake id) const;
std::vector<Snowflake> GetUserSortedGuilds() const; std::vector<Snowflake> GetUserSortedGuilds() const;
std::vector<Message> GetMessagesForChannel(Snowflake id, size_t limit = 50) const; std::vector<Message> GetMessagesForChannel(Snowflake id, size_t limit = 50) const;
std::vector<Message> GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit = 50) const; std::vector<Message> GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit = 50) const;
@@ -328,6 +330,7 @@ private:
UserData m_user_data; UserData m_user_data;
UserSettings m_user_settings; UserSettings m_user_settings;
UserGuildSettingsData m_user_guild_settings;
Store m_store; Store m_store;
HTTPClient m_http; HTTPClient m_http;

View File

@@ -8,6 +8,11 @@
#include <string> #include <string>
#include <unordered_set> #include <unordered_set>
enum class DefaultNotificationLevel {
ALL_MESSAGES = 0,
ONLY_MENTIONS = 1,
};
enum class GuildApplicationStatus { enum class GuildApplicationStatus {
STARTED, STARTED,
PENDING, PENDING,
@@ -36,9 +41,6 @@ struct GuildApplicationData {
// a bot is apparently only supposed to receive the `id` and `unavailable` as false // a bot is apparently only supposed to receive the `id` and `unavailable` as false
// but user tokens seem to get the full objects (minus users) // but user tokens seem to get the full objects (minus users)
// everythings optional cuz of muh partial guild object
// anything not marked optional in https://discord.com/developers/docs/resources/guild#guild-object is guaranteed to be set when returned from Store::GetGuild
struct GuildData { struct GuildData {
Snowflake ID; Snowflake ID;
std::string Name; std::string Name;
@@ -55,7 +57,7 @@ struct GuildData {
std::optional<bool> IsEmbedEnabled; // deprecated std::optional<bool> IsEmbedEnabled; // deprecated
std::optional<Snowflake> EmbedChannelID; // null, deprecated std::optional<Snowflake> EmbedChannelID; // null, deprecated
std::optional<int> VerificationLevel; std::optional<int> VerificationLevel;
std::optional<int> DefaultMessageNotifications; std::optional<DefaultNotificationLevel> DefaultMessageNotifications;
std::optional<int> ExplicitContentFilter; std::optional<int> ExplicitContentFilter;
std::optional<std::vector<RoleData>> Roles; std::optional<std::vector<RoleData>> Roles;
std::optional<std::vector<EmojiData>> Emojis; // only access id std::optional<std::vector<EmojiData>> Emojis; // only access id

View File

@@ -197,7 +197,7 @@ void from_json(const nlohmann::json &j, Message &m) {
JS_D("tts", m.IsTTS); JS_D("tts", m.IsTTS);
JS_D("mention_everyone", m.DoesMentionEveryone); JS_D("mention_everyone", m.DoesMentionEveryone);
JS_D("mentions", m.Mentions); JS_D("mentions", m.Mentions);
// JS_D("mention_roles", m.MentionRoles); JS_D("mention_roles", m.MentionRoles);
// JS_O("mention_channels", m.MentionChannels); // JS_O("mention_channels", m.MentionChannels);
JS_D("attachments", m.Attachments); JS_D("attachments", m.Attachments);
JS_D("embeds", m.Embeds); JS_D("embeds", m.Embeds);
@@ -235,6 +235,7 @@ void Message::from_json_edited(const nlohmann::json &j) {
JS_O("tts", IsTTS); JS_O("tts", IsTTS);
JS_O("mention_everyone", DoesMentionEveryone); JS_O("mention_everyone", DoesMentionEveryone);
JS_O("mentions", Mentions); JS_O("mentions", Mentions);
JS_O("mention_roles", MentionRoles);
JS_O("embeds", Embeds); JS_O("embeds", Embeds);
JS_O("nonce", Nonce); JS_O("nonce", Nonce);
JS_O("pinned", IsPinned); JS_O("pinned", IsPinned);
@@ -264,9 +265,17 @@ bool Message::IsEdited() const {
return m_edited; return m_edited;
} }
bool Message::DoesMention(Snowflake id) const noexcept { bool Message::DoesMentionEveryoneOrUser(Snowflake id) const noexcept {
if (DoesMentionEveryone) return true; if (DoesMentionEveryone) return true;
return std::any_of(Mentions.begin(), Mentions.end(), [id](const UserData &user) { return std::any_of(Mentions.begin(), Mentions.end(), [id](const UserData &user) {
return user.ID == id; return user.ID == id;
}); });
} }
bool Message::DoesMention(Snowflake id) const noexcept {
if (DoesMentionEveryoneOrUser(id)) return true;
if (!GuildID.has_value()) return false; // nothing left to check
const auto member = Abaddon::Get().GetDiscordClient().GetMember(id, *GuildID);
if (!member.has_value()) return false;
return std::find_first_of(MentionRoles.begin(), MentionRoles.end(), member->Roles.begin(), member->Roles.end()) != MentionRoles.end();
}

View File

@@ -183,7 +183,7 @@ struct Message {
bool IsTTS; bool IsTTS;
bool DoesMentionEveryone; bool DoesMentionEveryone;
std::vector<UserData> Mentions; // full user accessible std::vector<UserData> Mentions; // full user accessible
// std::vector<RoleData> MentionRoles; std::vector<Snowflake> MentionRoles;
// std::optional<std::vector<ChannelMentionData>> MentionChannels; // std::optional<std::vector<ChannelMentionData>> MentionChannels;
std::vector<AttachmentData> Attachments; std::vector<AttachmentData> Attachments;
std::vector<EmbedData> Embeds; std::vector<EmbedData> Embeds;
@@ -212,6 +212,7 @@ struct Message {
[[nodiscard]] bool IsDeleted() const; [[nodiscard]] bool IsDeleted() const;
[[nodiscard]] bool IsEdited() const; [[nodiscard]] bool IsEdited() const;
[[nodiscard]] bool DoesMentionEveryoneOrUser(Snowflake id) const noexcept;
[[nodiscard]] bool DoesMention(Snowflake id) const noexcept; [[nodiscard]] bool DoesMention(Snowflake id) const noexcept;
private: private:

View File

@@ -199,10 +199,26 @@ void to_json(nlohmann::json &j, const UserGuildSettingsEntry &m) {
j["version"] = m.Version; j["version"] = m.Version;
} }
std::optional<UserGuildSettingsChannelOverride> UserGuildSettingsEntry::GetOverride(Snowflake channel_id) const {
for (const auto &override : ChannelOverrides) {
if (override.ChannelID == channel_id) return override;
}
return std::nullopt;
}
void from_json(const nlohmann::json &j, UserGuildSettingsData &m) { void from_json(const nlohmann::json &j, UserGuildSettingsData &m) {
JS_D("version", m.Version); JS_D("version", m.Version);
JS_D("partial", m.IsPartial); JS_D("partial", m.IsPartial);
JS_D("entries", m.Entries);
{
std::vector<UserGuildSettingsEntry> entries;
JS_D("entries", entries);
for (const auto &entry : entries) {
m.Entries[entry.GuildID] = entry;
}
}
} }
void from_json(const nlohmann::json &j, ReadyEventData &m) { void from_json(const nlohmann::json &j, ReadyEventData &m) {

View File

@@ -274,10 +274,17 @@ struct ReadStateData {
friend void from_json(const nlohmann::json &j, ReadStateData &m); friend void from_json(const nlohmann::json &j, ReadStateData &m);
}; };
enum class NotificationLevel {
ALL_MESSAGES = 0,
ONLY_MENTIONS = 1,
NO_MESSAGES = 2,
USE_UPPER = 3, // actually called "NULL"
};
struct UserGuildSettingsChannelOverride { struct UserGuildSettingsChannelOverride {
bool Muted; bool Muted;
MuteConfigData MuteConfig; MuteConfigData MuteConfig;
int MessageNotifications; NotificationLevel MessageNotifications;
bool Collapsed; bool Collapsed;
Snowflake ChannelID; Snowflake ChannelID;
@@ -292,19 +299,21 @@ struct UserGuildSettingsEntry {
bool Muted; bool Muted;
MuteConfigData MuteConfig; MuteConfigData MuteConfig;
bool MobilePush; bool MobilePush;
int MessageNotifications; NotificationLevel MessageNotifications;
bool HideMutedChannels; bool HideMutedChannels;
Snowflake GuildID; Snowflake GuildID;
std::vector<UserGuildSettingsChannelOverride> ChannelOverrides; std::vector<UserGuildSettingsChannelOverride> ChannelOverrides;
friend void from_json(const nlohmann::json &j, UserGuildSettingsEntry &m); friend void from_json(const nlohmann::json &j, UserGuildSettingsEntry &m);
friend void to_json(nlohmann::json &j, const UserGuildSettingsEntry &m); friend void to_json(nlohmann::json &j, const UserGuildSettingsEntry &m);
std::optional<UserGuildSettingsChannelOverride> GetOverride(Snowflake channel_id) const;
}; };
struct UserGuildSettingsData { struct UserGuildSettingsData {
int Version; int Version;
bool IsPartial; bool IsPartial;
std::vector<UserGuildSettingsEntry> Entries; std::map<Snowflake, UserGuildSettingsEntry> Entries;
friend void from_json(const nlohmann::json &j, UserGuildSettingsData &m); friend void from_json(const nlohmann::json &j, UserGuildSettingsData &m);
}; };

View File

@@ -346,6 +346,15 @@ void Store::SetMessage(Snowflake id, const Message &message) {
s->Reset(); s->Reset();
} }
for (const auto &r : message.MentionRoles) {
auto &s = m_stmt_set_role_mention;
s->Bind(1, id);
s->Bind(2, r);
if (!s->Insert())
fprintf(stderr, "message role mention insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast<uint64_t>(id), static_cast<uint64_t>(r), m_db.ErrStr());
s->Reset();
}
for (const auto &a : message.Attachments) { for (const auto &a : message.Attachments) {
auto &s = m_stmt_set_attachment; auto &s = m_stmt_set_attachment;
s->Bind(1, id); s->Bind(1, id);
@@ -779,6 +788,7 @@ std::optional<GuildData> Store::GetGuild(Snowflake id) const {
s->Get(1, r.Name); s->Get(1, r.Name);
s->Get(2, r.Icon); s->Get(2, r.Icon);
s->Get(5, r.OwnerID); s->Get(5, r.OwnerID);
s->Get(11, r.DefaultMessageNotifications);
s->Get(20, r.IsUnavailable); s->Get(20, r.IsUnavailable);
s->Get(27, r.PremiumTier); s->Get(27, r.PremiumTier);
@@ -986,6 +996,17 @@ Message Store::GetMessageBound(std::unique_ptr<Statement> &s) const {
s->Reset(); s->Reset();
} }
{
auto &s = m_stmt_get_role_mentions;
s->Bind(1, r.ID);
while (s->FetchOne()) {
Snowflake id;
s->Get(0, id);
r.MentionRoles.push_back(id);
}
s->Reset();
}
{ {
auto &s = m_stmt_get_reactions; auto &s = m_stmt_get_reactions;
s->Bind(1, r.ID); s->Bind(1, r.ID);
@@ -1436,6 +1457,14 @@ bool Store::CreateTables() {
) )
)"; )";
const char *create_mention_roles = R"(
CREATE TABLE IF NOT EXISTS mention_roles (
message INTEGER NOT NULL,
role INTEGER NOT NULL,
PRIMARY KEY(message, role)
)
)";
const char *create_attachments = R"( const char *create_attachments = R"(
CREATE TABLE IF NOT EXISTS attachments ( CREATE TABLE IF NOT EXISTS attachments (
message INTEGER NOT NULL, message INTEGER NOT NULL,
@@ -1555,6 +1584,11 @@ bool Store::CreateTables() {
return false; return false;
} }
if (m_db.Execute(create_mention_roles) != SQLITE_OK) {
fprintf(stderr, "failed to create role mentions table: %s\n", m_db.ErrStr());
return false;
}
if (m_db.Execute(create_attachments) != SQLITE_OK) { if (m_db.Execute(create_attachments) != SQLITE_OK) {
fprintf(stderr, "failed to create attachments table: %s\n", m_db.ErrStr()); fprintf(stderr, "failed to create attachments table: %s\n", m_db.ErrStr());
return false; return false;
@@ -2118,6 +2152,24 @@ bool Store::CreateStatements() {
return false; return false;
} }
m_stmt_set_role_mention = std::make_unique<Statement>(m_db, R"(
REPLACE INTO mention_roles VALUES (
?, ?
)
)");
if (!m_stmt_set_role_mention->OK()) {
fprintf(stderr, "failed to prepare set role mention statement: %s\n", m_db.ErrStr());
return false;
}
m_stmt_get_role_mentions = std::make_unique<Statement>(m_db, R"(
SELECT role FROM mention_roles WHERE message = ?
)");
if (!m_stmt_get_role_mentions->OK()) {
fprintf(stderr, "failed to prepare get role mentions statement: %s\n", m_db.ErrStr());
return false;
}
m_stmt_set_attachment = std::make_unique<Statement>(m_db, R"( m_stmt_set_attachment = std::make_unique<Statement>(m_db, R"(
REPLACE INTO attachments VALUES ( REPLACE INTO attachments VALUES (
?, ?, ?, ?, ?, ?, ?, ? ?, ?, ?, ?, ?, ?, ?, ?

View File

@@ -299,6 +299,8 @@ private:
STMT(get_emoji_roles); STMT(get_emoji_roles);
STMT(set_mention); STMT(set_mention);
STMT(get_mentions); STMT(get_mentions);
STMT(set_role_mention);
STMT(get_role_mentions);
STMT(set_attachment); STMT(set_attachment);
STMT(get_attachments); STMT(get_attachments);
STMT(set_recipient); STMT(set_recipient);

View File

@@ -101,3 +101,7 @@ Glib::RefPtr<Gdk::Pixbuf> ImageManager::GetPlaceholder(int size) {
return Glib::RefPtr<Gdk::Pixbuf>(nullptr); return Glib::RefPtr<Gdk::Pixbuf>(nullptr);
} }
} }
Cache &ImageManager::GetCache() {
return m_cache;
}

View File

@@ -18,6 +18,7 @@ public:
void LoadAnimationFromURL(const std::string &url, int w, int h, const callback_anim_type &cb); void LoadAnimationFromURL(const std::string &url, int w, int h, const callback_anim_type &cb);
void Prefetch(const std::string &url); void Prefetch(const std::string &url);
Glib::RefPtr<Gdk::Pixbuf> GetPlaceholder(int size); Glib::RefPtr<Gdk::Pixbuf> GetPlaceholder(int size);
Cache &GetCache();
private: private:
static Glib::RefPtr<Gdk::Pixbuf> ReadFileToPixbuf(std::string path); static Glib::RefPtr<Gdk::Pixbuf> ReadFileToPixbuf(std::string path);

204
src/misc/chatutil.cpp Normal file
View File

@@ -0,0 +1,204 @@
#include "chatutil.hpp"
#include "constants.hpp"
namespace ChatUtil {
Glib::ustring GetText(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
Gtk::TextBuffer::iterator a, b;
buf->get_bounds(a, b);
auto slice = buf->get_slice(a, b, true);
return slice;
}
void HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
constexpr static const auto mentions_regex = R"(<@&(\d+)>)";
static auto rgx = Glib::Regex::create(mentions_regex);
Glib::ustring text = GetText(buf);
const auto &discord = Abaddon::Get().GetDiscordClient();
int startpos = 0;
Glib::MatchInfo match;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const Glib::ustring role_id = match.fetch(1);
const auto role = discord.GetRole(role_id);
if (!role.has_value()) {
startpos = mend;
continue;
}
Glib::ustring replacement;
if (role->HasColor()) {
replacement = "<b><span color=\"#" + IntToCSSColor(role->Color) + "\">@" + role->GetEscapedName() + "</span></b>";
} else {
replacement = "<b>@" + role->GetEscapedName() + "</b>";
}
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
const auto start_it = buf->get_iter_at_offset(chars_start);
const auto end_it = buf->get_iter_at_offset(chars_end);
auto it = buf->erase(start_it, end_it);
buf->insert_markup(it, replacement);
text = GetText(buf);
startpos = 0;
}
}
void HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf, Snowflake channel_id, bool plain) {
constexpr static const auto mentions_regex = R"(<@!?(\d+)>)";
static auto rgx = Glib::Regex::create(mentions_regex);
Glib::ustring text = GetText(buf);
const auto &discord = Abaddon::Get().GetDiscordClient();
int startpos = 0;
Glib::MatchInfo match;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const Glib::ustring user_id = match.fetch(1);
const auto user = discord.GetUser(user_id);
const auto channel = discord.GetChannel(channel_id);
if (!user.has_value() || !channel.has_value()) {
startpos = mend;
continue;
}
Glib::ustring replacement;
if (channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM || !channel->GuildID.has_value() || plain) {
if (plain) {
replacement = "@" + user->Username + "#" + user->Discriminator;
} else {
replacement = user->GetEscapedBoldString<true>();
}
} else {
const auto role_id = user->GetHoistedRole(*channel->GuildID, true);
const auto role = discord.GetRole(role_id);
if (!role.has_value())
replacement = user->GetEscapedBoldString<true>();
else
replacement = "<span color=\"#" + IntToCSSColor(role->Color) + "\">" + user->GetEscapedBoldString<true>() + "</span>";
}
// regex returns byte positions and theres no straightforward way in the c++ bindings to deal with that :(
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
const auto start_it = buf->get_iter_at_offset(chars_start);
const auto end_it = buf->get_iter_at_offset(chars_end);
auto it = buf->erase(start_it, end_it);
buf->insert_markup(it, replacement);
text = GetText(buf);
startpos = 0;
}
}
void HandleStockEmojis(Gtk::TextView &tv) {
Abaddon::Get().GetEmojis().ReplaceEmojis(tv.get_buffer(), EmojiSize);
}
void HandleCustomEmojis(Gtk::TextView &tv) {
static auto rgx = Glib::Regex::create(R"(<a?:([\w\d_]+):(\d+)>)");
auto &img = Abaddon::Get().GetImageManager();
auto buf = tv.get_buffer();
auto text = GetText(buf);
Glib::MatchInfo match;
int startpos = 0;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const bool is_animated = match.fetch(0)[1] == 'a';
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
auto start_it = buf->get_iter_at_offset(chars_start);
auto end_it = buf->get_iter_at_offset(chars_end);
startpos = mend;
if (is_animated && Abaddon::Get().GetSettings().ShowAnimations) {
const auto mark_start = buf->create_mark(start_it, false);
end_it.backward_char();
const auto mark_end = buf->create_mark(end_it, false);
const auto cb = [&tv, buf, mark_start, mark_end](const Glib::RefPtr<Gdk::PixbufAnimation> &pixbuf) {
auto start_it = mark_start->get_iter();
auto end_it = mark_end->get_iter();
end_it.forward_char();
buf->delete_mark(mark_start);
buf->delete_mark(mark_end);
auto it = buf->erase(start_it, end_it);
const auto anchor = buf->create_child_anchor(it);
auto img = Gtk::manage(new Gtk::Image(pixbuf));
img->show();
tv.add_child_at_anchor(*img, anchor);
};
img.LoadAnimationFromURL(EmojiData::URLFromID(match.fetch(2), "gif"), EmojiSize, EmojiSize, sigc::track_obj(cb, tv));
} else {
// can't erase before pixbuf is ready or else marks that are in the same pos get mixed up
const auto mark_start = buf->create_mark(start_it, false);
end_it.backward_char();
const auto mark_end = buf->create_mark(end_it, false);
const auto cb = [buf, mark_start, mark_end](const Glib::RefPtr<Gdk::Pixbuf> &pixbuf) {
auto start_it = mark_start->get_iter();
auto end_it = mark_end->get_iter();
end_it.forward_char();
buf->delete_mark(mark_start);
buf->delete_mark(mark_end);
auto it = buf->erase(start_it, end_it);
int width, height;
GetImageDimensions(pixbuf->get_width(), pixbuf->get_height(), width, height, EmojiSize, EmojiSize);
buf->insert_pixbuf(it, pixbuf->scale_simple(width, height, Gdk::INTERP_BILINEAR));
};
img.LoadFromURL(EmojiData::URLFromID(match.fetch(2)), sigc::track_obj(cb, tv));
}
text = GetText(buf);
}
}
void HandleEmojis(Gtk::TextView &tv) {
if (Abaddon::Get().GetSettings().ShowStockEmojis) HandleStockEmojis(tv);
if (Abaddon::Get().GetSettings().ShowCustomEmojis) HandleCustomEmojis(tv);
}
void CleanupEmojis(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
static auto rgx = Glib::Regex::create(R"(<a?:([\w\d_]+):(\d+)>)");
auto text = GetText(buf);
Glib::MatchInfo match;
int startpos = 0;
while (rgx->match(text, startpos, match)) {
int mstart, mend;
if (!match.fetch_pos(0, mstart, mend)) break;
const auto new_term = ":" + match.fetch(1) + ":";
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
auto start_it = buf->get_iter_at_offset(chars_start);
auto end_it = buf->get_iter_at_offset(chars_end);
startpos = mend;
const auto it = buf->erase(start_it, end_it);
const int alen = static_cast<int>(text.size());
text = GetText(buf);
const int blen = static_cast<int>(text.size());
startpos -= (alen - blen);
buf->insert(it, new_term);
text = GetText(buf);
}
}
} // namespace ChatUtil

19
src/misc/chatutil.hpp Normal file
View File

@@ -0,0 +1,19 @@
#pragma once
#include <glibmm/refptr.h>
#include <glibmm/ustring.h>
#include "discord/snowflake.hpp"
namespace Gtk {
class TextBuffer;
class TextView;
} // namespace Gtk
namespace ChatUtil {
Glib::ustring GetText(const Glib::RefPtr<Gtk::TextBuffer> &buf);
void HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf);
void HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf, Snowflake channel_id, bool plain);
void HandleStockEmojis(Gtk::TextView &tv);
void HandleCustomEmojis(Gtk::TextView &tv);
void HandleEmojis(Gtk::TextView &tv);
void CleanupEmojis(const Glib::RefPtr<Gtk::TextBuffer> &buf);
} // namespace ChatUtil

View File

@@ -0,0 +1,169 @@
#include "notifications.hpp"
#include "misc/chatutil.hpp"
#include "discord/message.hpp"
Notifications::Notifications() {
}
bool CheckGuildMessage(const Message &message) {
auto &discord = Abaddon::Get().GetDiscordClient();
if (!message.GuildID.has_value()) return false;
const auto guild = discord.GetGuild(*message.GuildID);
if (!guild.has_value()) return false;
const auto guild_settings = discord.GetSettingsForGuild(*message.GuildID);
// if theres no guild settings then just use default message notifications level
// (there will be no channel settings)
if (!guild_settings.has_value()) {
if (guild->DefaultMessageNotifications.has_value()) {
switch (*guild->DefaultMessageNotifications) {
case DefaultNotificationLevel::ALL_MESSAGES:
return true;
case DefaultNotificationLevel::ONLY_MENTIONS:
return message.DoesMention(discord.GetUserData().ID);
default:
return false;
}
}
return false;
} else if (discord.IsGuildMuted(*message.GuildID)) {
// if there are guild settings and the guild is muted then dont notify
return false;
}
// if the channel category is muted then dont notify
const auto channel = discord.GetChannel(message.ChannelID);
std::optional<UserGuildSettingsChannelOverride> category_settings;
if (channel.has_value() && channel->ParentID.has_value()) {
category_settings = guild_settings->GetOverride(*channel->ParentID);
if (discord.IsChannelMuted(*channel->ParentID)) {
return false;
}
}
const auto channel_settings = guild_settings->GetOverride(message.ChannelID);
// 🥴
NotificationLevel level;
if (channel_settings.has_value()) {
if (channel_settings->MessageNotifications == NotificationLevel::USE_UPPER) {
if (category_settings.has_value()) {
if (category_settings->MessageNotifications == NotificationLevel::USE_UPPER) {
level = guild_settings->MessageNotifications;
} else {
level = category_settings->MessageNotifications;
}
} else {
level = guild_settings->MessageNotifications;
}
} else {
level = channel_settings->MessageNotifications;
}
} else {
if (category_settings.has_value()) {
if (category_settings->MessageNotifications == NotificationLevel::USE_UPPER) {
level = guild_settings->MessageNotifications;
} else {
level = category_settings->MessageNotifications;
}
} else {
level = guild_settings->MessageNotifications;
}
}
// there are channel settings, so use them
switch (level) {
case NotificationLevel::ALL_MESSAGES:
return true;
case NotificationLevel::ONLY_MENTIONS:
return message.DoesMention(discord.GetUserData().ID);
case NotificationLevel::NO_MESSAGES:
return false;
default:
return false;
}
}
void Notifications::CheckMessage(const Message &message) {
if (!Abaddon::Get().GetSettings().NotificationsEnabled) return;
// ignore if our status is do not disturb
if (IsDND()) return;
auto &discord = Abaddon::Get().GetDiscordClient();
// ignore if the channel is muted
if (discord.IsChannelMuted(message.ChannelID)) return;
// ignore if focused and message's channel is active
if (Abaddon::Get().IsMainWindowActive() && Abaddon::Get().GetActiveChannelID() == message.ChannelID) return;
// ignore if its our own message
if (message.Author.ID == Abaddon::Get().GetDiscordClient().GetUserData().ID) return;
// notify messages in DMs
const auto channel = discord.GetChannel(message.ChannelID);
if (channel->IsDM()) {
m_chan_notifications[message.ChannelID].push_back(message.ID);
NotifyMessageDM(message);
} else if (CheckGuildMessage(message)) {
m_chan_notifications[message.ChannelID].push_back(message.ID);
NotifyMessageGuild(message);
}
}
void Notifications::WithdrawChannel(Snowflake channel_id) {
if (auto it = m_chan_notifications.find(channel_id); it != m_chan_notifications.end()) {
for (const auto notification_id : it->second) {
m_notifier.Withdraw(std::to_string(notification_id));
}
it->second.clear();
}
}
Glib::ustring Sanitize(const Message &message) {
auto buf = Gtk::TextBuffer::create();
Gtk::TextBuffer::iterator begin, end;
buf->get_bounds(begin, end);
buf->insert(end, message.Content);
ChatUtil::CleanupEmojis(buf);
ChatUtil::HandleUserMentions(buf, message.ChannelID, true);
return Glib::Markup::escape_text(buf->get_text());
}
void Notifications::NotifyMessageDM(const Message &message) {
Glib::ustring default_action = "app.go-to-channel";
default_action += "::";
default_action += std::to_string(message.ChannelID);
const auto title = message.Author.Username;
const auto body = Sanitize(message);
Abaddon::Get().GetImageManager().GetCache().GetFileFromURL(message.Author.GetAvatarURL("png", "64"), [=](const std::string &path) {
m_notifier.Notify(std::to_string(message.ID), title, body, default_action, path);
});
}
void Notifications::NotifyMessageGuild(const Message &message) {
Glib::ustring default_action = "app.go-to-channel";
default_action += "::";
default_action += std::to_string(message.ChannelID);
Glib::ustring title = message.Author.Username;
if (const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(message.ChannelID); channel.has_value() && channel->Name.has_value()) {
if (channel->ParentID.has_value()) {
const auto category = Abaddon::Get().GetDiscordClient().GetChannel(*channel->ParentID);
if (category.has_value() && category->Name.has_value()) {
title += " (#" + *channel->Name + ", " + *category->Name + ")";
} else {
title += " (#" + *channel->Name + ")";
}
} else {
title += " (#" + *channel->Name + ")";
}
}
const auto body = Sanitize(message);
Abaddon::Get().GetImageManager().GetCache().GetFileFromURL(message.Author.GetAvatarURL("png", "64"), [=](const std::string &path) {
m_notifier.Notify(std::to_string(message.ID), title, body, default_action, path);
});
}
bool Notifications::IsDND() const {
auto &discord = Abaddon::Get().GetDiscordClient();
const auto status = discord.GetUserStatus(discord.GetUserData().ID);
return status == PresenceStatus::DND;
}

View File

@@ -0,0 +1,23 @@
#pragma once
#include "notifier.hpp"
#include <map>
#include <vector>
class Message;
class Notifications {
public:
Notifications();
void CheckMessage(const Message &message);
void WithdrawChannel(Snowflake channel_id);
private:
void NotifyMessageDM(const Message &message);
void NotifyMessageGuild(const Message &message);
[[nodiscard]] bool IsDND() const;
Notifier m_notifier;
std::map<Snowflake, std::vector<Snowflake>> m_chan_notifications;
};

View File

@@ -0,0 +1,21 @@
#pragma once
#include <glibmm/ustring.h>
#include <gdkmm/pixbuf.h>
#ifdef ENABLE_NOTIFICATION_SOUNDS
#include <miniaudio.h>
#endif
class Notifier {
public:
Notifier();
~Notifier();
void Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path);
void Withdraw(const Glib::ustring &id);
private:
#ifdef ENABLE_NOTIFICATION_SOUNDS
ma_engine m_engine;
#endif
};

View File

@@ -0,0 +1,46 @@
#include "notifier.hpp"
#include <giomm/notification.h>
#define MINIAUDIO_IMPLEMENTATION
#include <miniaudio.h>
Notifier::Notifier() {
#ifdef ENABLE_NOTIFICATION_SOUNDS
if (ma_engine_init(nullptr, &m_engine) != MA_SUCCESS) {
printf("failed to initialize miniaudio engine\n");
}
#endif
}
Notifier::~Notifier() {
#ifdef ENABLE_NOTIFICATION_SOUNDS
ma_engine_uninit(&m_engine);
#endif
}
void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path) {
auto n = Gio::Notification::create(title);
n->set_body(text);
n->set_default_action(default_action);
// i dont think giomm provides an interface for this
auto *file = g_file_new_for_path(icon_path.c_str());
auto *icon = g_file_icon_new(file);
g_notification_set_icon(n->gobj(), icon);
Abaddon::Get().GetApp()->send_notification(id, n);
g_object_unref(icon);
g_object_unref(file);
#ifdef ENABLE_NOTIFICATION_SOUNDS
if (Abaddon::Get().GetSettings().NotificationsPlaySound) {
ma_engine_play_sound(&m_engine, Abaddon::Get().GetResPath("/sound/message.mp3").c_str(), nullptr);
}
#endif
}
void Notifier::Withdraw(const Glib::ustring &id) {
Abaddon::Get().GetApp()->withdraw_notification(id);
}

View File

@@ -0,0 +1,9 @@
#include "notifier.hpp"
Notifier::Notifier() {}
Notifier::~Notifier() {}
void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path) {}
void Notifier::Withdraw(const Glib::ustring &id) {}

View File

@@ -68,6 +68,8 @@ void SettingsManager::ReadSettings() {
SMSTR("style", "mentionbadgecolor", MentionBadgeColor); SMSTR("style", "mentionbadgecolor", MentionBadgeColor);
SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor); SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor);
SMSTR("style", "unreadcolor", UnreadIndicatorColor); SMSTR("style", "unreadcolor", UnreadIndicatorColor);
SMBOOL("notifications", "enabled", NotificationsEnabled);
SMBOOL("notifications", "playsound", NotificationsPlaySound);
#ifdef WITH_KEYCHAIN #ifdef WITH_KEYCHAIN
keychain::Error error {}; keychain::Error error {};
@@ -149,6 +151,8 @@ void SettingsManager::Close() {
SMSTR("style", "mentionbadgecolor", MentionBadgeColor); SMSTR("style", "mentionbadgecolor", MentionBadgeColor);
SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor); SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor);
SMSTR("style", "unreadcolor", UnreadIndicatorColor); SMSTR("style", "unreadcolor", UnreadIndicatorColor);
SMBOOL("notifications", "enabled", NotificationsEnabled);
SMBOOL("notifications", "playsound", NotificationsPlaySound);
#ifdef WITH_KEYCHAIN #ifdef WITH_KEYCHAIN
keychain::Error error {}; keychain::Error error {};

View File

@@ -44,6 +44,14 @@ public:
std::string MentionBadgeColor { "#b82525" }; std::string MentionBadgeColor { "#b82525" };
std::string MentionBadgeTextColor { "#fbfbfb" }; std::string MentionBadgeTextColor { "#fbfbfb" };
std::string UnreadIndicatorColor { "#ffffff" }; std::string UnreadIndicatorColor { "#ffffff" };
// [notifications]
#ifdef _WIN32
bool NotificationsEnabled { false };
#else
bool NotificationsEnabled { true };
#endif
bool NotificationsPlaySound { true };
}; };
SettingsManager(const std::string &filename); SettingsManager(const std::string &filename);