summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/channels.cpp127
-rw-r--r--src/components/channels.hpp18
-rw-r--r--src/components/channelscellrenderer.cpp144
-rw-r--r--src/components/channelscellrenderer.hpp10
-rw-r--r--src/discord/discord.cpp304
-rw-r--r--src/discord/discord.hpp36
-rw-r--r--src/discord/message.cpp6
-rw-r--r--src/discord/message.hpp2
-rw-r--r--src/discord/objects.cpp94
-rw-r--r--src/discord/objects.hpp85
-rw-r--r--src/discord/snowflake.cpp17
-rw-r--r--src/discord/snowflake.hpp3
-rw-r--r--src/util.cpp29
-rw-r--r--src/util.hpp4
-rw-r--r--src/windows/mainwindow.cpp35
-rw-r--r--src/windows/mainwindow.hpp4
16 files changed, 891 insertions, 27 deletions
diff --git a/src/components/channels.cpp b/src/components/channels.cpp
index 148f26f..7ad2dd0 100644
--- a/src/components/channels.cpp
+++ b/src/components/channels.cpp
@@ -9,13 +9,14 @@
ChannelList::ChannelList()
: Glib::ObjectBase(typeid(ChannelList))
- , Gtk::ScrolledWindow()
, m_model(Gtk::TreeStore::create(m_columns))
, m_menu_guild_copy_id("_Copy ID", true)
, m_menu_guild_settings("View _Settings", true)
, m_menu_guild_leave("_Leave", true)
+ , m_menu_guild_mark_as_read("Mark as _Read", true)
, m_menu_category_copy_id("_Copy ID", true)
, m_menu_channel_copy_id("_Copy ID", true)
+ , m_menu_channel_mark_as_read("Mark as _Read", true)
, m_menu_dm_copy_id("_Copy ID", true)
, m_menu_dm_close("") // changes depending on if group or not
, m_menu_thread_copy_id("_Copy ID", true)
@@ -24,6 +25,7 @@ ChannelList::ChannelList()
, m_menu_thread_unarchive("_Unarchive", true) {
get_style_context()->add_class("channel-list");
+ // todo: move to method
const auto cb = [this](const Gtk::TreeModel::Path &path, Gtk::TreeViewColumn *column) {
auto row = *m_model->get_iter(path);
const auto type = row[m_columns.m_type];
@@ -40,7 +42,9 @@ ChannelList::ChannelList()
}
if (type == RenderType::TextChannel || type == RenderType::DM || type == RenderType::Thread) {
- m_signal_action_channel_item_select.emit(static_cast<Snowflake>(row[m_columns.m_id]));
+ const auto id = static_cast<Snowflake>(row[m_columns.m_id]);
+ m_signal_action_channel_item_select.emit(id);
+ Abaddon::Get().GetDiscordClient().MarkChannelAsRead(id, [](...) {});
}
};
m_view.signal_row_activated().connect(cb, false);
@@ -77,6 +81,7 @@ ChannelList::ChannelList()
column->add_attribute(renderer->property_icon(), m_columns.m_icon);
column->add_attribute(renderer->property_icon_animation(), m_columns.m_icon_anim);
column->add_attribute(renderer->property_name(), m_columns.m_name);
+ column->add_attribute(renderer->property_id(), m_columns.m_id);
column->add_attribute(renderer->property_expanded(), m_columns.m_expanded);
column->add_attribute(renderer->property_nsfw(), m_columns.m_nsfw);
m_view.append_column(*column);
@@ -90,20 +95,47 @@ ChannelList::ChannelList()
m_menu_guild_leave.signal_activate().connect([this] {
m_signal_action_guild_leave.emit(static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]));
});
- m_menu_guild.append(m_menu_guild_copy_id);
+ m_menu_guild_mark_as_read.signal_activate().connect([this] {
+ Abaddon::Get().GetDiscordClient().MarkGuildAsRead(static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]), [](...) {});
+ });
+ m_menu_guild_toggle_mute.signal_activate().connect([this] {
+ const auto id = static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]);
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ if (discord.IsGuildMuted(id))
+ discord.UnmuteGuild(id, NOOP_CALLBACK);
+ else
+ discord.MuteGuild(id, NOOP_CALLBACK);
+ });
+ m_menu_guild.append(m_menu_guild_mark_as_read);
m_menu_guild.append(m_menu_guild_settings);
m_menu_guild.append(m_menu_guild_leave);
+ m_menu_guild.append(m_menu_guild_toggle_mute);
+ m_menu_guild.append(m_menu_guild_copy_id);
m_menu_guild.show_all();
m_menu_category_copy_id.signal_activate().connect([this] {
Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]));
});
+
m_menu_category.append(m_menu_category_copy_id);
m_menu_category.show_all();
m_menu_channel_copy_id.signal_activate().connect([this] {
Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]));
});
+ m_menu_channel_mark_as_read.signal_activate().connect([this] {
+ Abaddon::Get().GetDiscordClient().MarkChannelAsRead(static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]), [](...) {});
+ });
+ m_menu_channel_toggle_mute.signal_activate().connect([this] {
+ const auto id = static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]);
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ if (discord.IsChannelMuted(id))
+ discord.UnmuteChannel(id, NOOP_CALLBACK);
+ else
+ discord.MuteChannel(id, NOOP_CALLBACK);
+ });
+ m_menu_channel.append(m_menu_channel_mark_as_read);
+ m_menu_channel.append(m_menu_channel_toggle_mute);
m_menu_channel.append(m_menu_channel_copy_id);
m_menu_channel.show_all();
@@ -121,8 +153,8 @@ ChannelList::ChannelList()
else if (Abaddon::Get().ShowConfirm("Are you sure you want to leave this group DM?"))
Abaddon::Get().GetDiscordClient().CloseDM(id);
});
- m_menu_dm.append(m_menu_dm_copy_id);
m_menu_dm.append(m_menu_dm_close);
+ m_menu_dm.append(m_menu_dm_copy_id);
m_menu_dm.show_all();
m_menu_thread_copy_id.signal_activate().connect([this] {
@@ -144,6 +176,8 @@ ChannelList::ChannelList()
m_menu_thread.append(m_menu_thread_unarchive);
m_menu_thread.show_all();
+ m_menu_guild.signal_popped_up().connect(sigc::mem_fun(*this, &ChannelList::OnGuildSubmenuPopup));
+ m_menu_channel.signal_popped_up().connect(sigc::mem_fun(*this, &ChannelList::OnChannelSubmenuPopup));
m_menu_thread.signal_popped_up().connect(sigc::mem_fun(*this, &ChannelList::OnThreadSubmenuPopup));
auto &discord = Abaddon::Get().GetDiscordClient();
@@ -159,6 +193,19 @@ ChannelList::ChannelList()
discord.signal_added_to_thread().connect(sigc::mem_fun(*this, &ChannelList::OnThreadJoined));
discord.signal_removed_from_thread().connect(sigc::mem_fun(*this, &ChannelList::OnThreadRemoved));
discord.signal_guild_update().connect(sigc::mem_fun(*this, &ChannelList::UpdateGuild));
+ discord.signal_message_ack().connect(sigc::mem_fun(*this, &ChannelList::OnMessageAck));
+ discord.signal_channel_muted().connect(sigc::mem_fun(*this, &ChannelList::OnChannelMute));
+ discord.signal_channel_unmuted().connect(sigc::mem_fun(*this, &ChannelList::OnChannelUnmute));
+ discord.signal_guild_muted().connect(sigc::mem_fun(*this, &ChannelList::OnGuildMute));
+ discord.signal_guild_unmuted().connect(sigc::mem_fun(*this, &ChannelList::OnGuildUnmute));
+}
+
+void ChannelList::UsePanedHack(Gtk::Paned &paned) {
+ paned.property_position().signal_changed().connect(sigc::mem_fun(*this, &ChannelList::OnPanedPositionChanged));
+}
+
+void ChannelList::OnPanedPositionChanged() {
+ m_view.queue_draw();
}
void ChannelList::UpdateListing() {
@@ -346,9 +393,35 @@ void ChannelList::DeleteThreadRow(Snowflake id) {
m_model->erase(iter);
}
+void ChannelList::OnChannelMute(Snowflake id) {
+ if (auto iter = GetIteratorForChannelFromID(id))
+ m_model->row_changed(m_model->get_path(iter), iter);
+}
+
+void ChannelList::OnChannelUnmute(Snowflake id) {
+ if (auto iter = GetIteratorForChannelFromID(id))
+ m_model->row_changed(m_model->get_path(iter), iter);
+}
+
+void ChannelList::OnGuildMute(Snowflake id) {
+ if (auto iter = GetIteratorForGuildFromID(id))
+ m_model->row_changed(m_model->get_path(iter), iter);
+}
+
+void ChannelList::OnGuildUnmute(Snowflake id) {
+ if (auto iter = GetIteratorForGuildFromID(id))
+ m_model->row_changed(m_model->get_path(iter), iter);
+}
+
// create a temporary channel row for non-joined threads
// and delete them when the active channel switches off of them if still not joined
void ChannelList::SetActiveChannel(Snowflake id) {
+ // mark channel as read when switching off
+ if (m_active_channel.IsValid())
+ Abaddon::Get().GetDiscordClient().MarkChannelAsRead(m_active_channel, [](...) {});
+
+ m_active_channel = id;
+
if (m_temporary_thread_row) {
const auto thread_id = static_cast<Snowflake>((*m_temporary_thread_row)[m_columns.m_id]);
const auto thread = Abaddon::Get().GetDiscordClient().GetChannel(thread_id);
@@ -662,7 +735,7 @@ void ChannelList::UpdateCreateDMChannel(const ChannelData &dm) {
std::optional<UserData> top_recipient;
const auto recipients = dm.GetDMRecipients();
- if (recipients.size() > 0)
+ if (!recipients.empty())
top_recipient = recipients[0];
auto iter = m_model->append(header_row->children());
@@ -686,13 +759,29 @@ void ChannelList::UpdateCreateDMChannel(const ChannelData &dm) {
}
}
+void ChannelList::OnMessageAck(const MessageAckData &data) {
+ // trick renderer into redrawing
+ auto iter = GetIteratorForChannelFromID(data.ChannelID);
+ if (iter) m_model->row_changed(m_model->get_path(iter), iter);
+ auto channel = Abaddon::Get().GetDiscordClient().GetChannel(data.ChannelID);
+ if (channel.has_value() && channel->GuildID.has_value()) {
+ iter = GetIteratorForGuildFromID(*channel->GuildID);
+ if (iter) m_model->row_changed(m_model->get_path(iter), iter);
+ }
+}
+
void ChannelList::OnMessageCreate(const Message &msg) {
+ auto iter = GetIteratorForChannelFromID(msg.ChannelID);
+ if (iter) m_model->row_changed(m_model->get_path(iter), iter); // redraw
const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(msg.ChannelID);
if (!channel.has_value()) return;
- if (channel->Type != ChannelType::DM && channel->Type != ChannelType::GROUP_DM) return;
- auto iter = GetIteratorForChannelFromID(msg.ChannelID);
- if (iter)
- (*iter)[m_columns.m_sort] = -msg.ID;
+ if (channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM) {
+ if (iter)
+ (*iter)[m_columns.m_sort] = -msg.ID;
+ }
+ if (channel->GuildID.has_value())
+ if ((iter = GetIteratorForGuildFromID(*channel->GuildID)))
+ m_model->row_changed(m_model->get_path(iter), iter);
}
bool ChannelList::OnButtonPressEvent(GdkEventButton *ev) {
@@ -758,6 +847,26 @@ void ChannelList::MoveRow(const Gtk::TreeModel::iterator &iter, const Gtk::TreeM
m_model->erase(iter);
}
+void ChannelList::OnGuildSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) {
+ const auto iter = m_model->get_iter(m_path_for_menu);
+ if (!iter) return;
+ const auto id = static_cast<Snowflake>((*iter)[m_columns.m_id]);
+ if (Abaddon::Get().GetDiscordClient().IsGuildMuted(id))
+ m_menu_guild_toggle_mute.set_label("Unmute");
+ else
+ m_menu_guild_toggle_mute.set_label("Mute");
+}
+
+void ChannelList::OnChannelSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) {
+ const auto iter = m_model->get_iter(m_path_for_menu);
+ if (!iter) return;
+ const auto id = static_cast<Snowflake>((*iter)[m_columns.m_id]);
+ if (Abaddon::Get().GetDiscordClient().IsChannelMuted(id))
+ m_menu_channel_toggle_mute.set_label("Unmute");
+ else
+ m_menu_channel_toggle_mute.set_label("Mute");
+}
+
void ChannelList::OnThreadSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) {
m_menu_thread_archive.set_visible(false);
m_menu_thread_unarchive.set_visible(false);
diff --git a/src/components/channels.hpp b/src/components/channels.hpp
index a255f01..6970729 100644
--- a/src/components/channels.hpp
+++ b/src/components/channels.hpp
@@ -25,7 +25,11 @@ public:
void UseExpansionState(const ExpansionStateRoot &state);
ExpansionStateRoot GetExpansionState() const;
+ void UsePanedHack(Gtk::Paned &paned);
+
protected:
+ void OnPanedPositionChanged();
+
void UpdateNewGuild(const GuildData &guild);
void UpdateRemoveGuild(Snowflake id);
void UpdateRemoveChannel(Snowflake id);
@@ -33,6 +37,10 @@ protected:
void UpdateCreateChannel(const ChannelData &channel);
void UpdateGuild(Snowflake id);
void DeleteThreadRow(Snowflake id);
+ void OnChannelMute(Snowflake id);
+ void OnChannelUnmute(Snowflake id);
+ void OnGuildMute(Snowflake id);
+ void OnGuildUnmute(Snowflake id);
void OnThreadJoined(Snowflake id);
void OnThreadRemoved(Snowflake id);
@@ -89,6 +97,8 @@ protected:
void AddPrivateChannels();
void UpdateCreateDMChannel(const ChannelData &channel);
+ void OnMessageAck(const MessageAckData &data);
+
void OnMessageCreate(const Message &msg);
Gtk::TreeModel::Path m_path_for_menu;
@@ -99,12 +109,16 @@ protected:
Gtk::MenuItem m_menu_guild_copy_id;
Gtk::MenuItem m_menu_guild_settings;
Gtk::MenuItem m_menu_guild_leave;
+ Gtk::MenuItem m_menu_guild_mark_as_read;
+ Gtk::MenuItem m_menu_guild_toggle_mute;
Gtk::Menu m_menu_category;
Gtk::MenuItem m_menu_category_copy_id;
Gtk::Menu m_menu_channel;
Gtk::MenuItem m_menu_channel_copy_id;
+ Gtk::MenuItem m_menu_channel_mark_as_read;
+ Gtk::MenuItem m_menu_channel_toggle_mute;
Gtk::Menu m_menu_dm;
Gtk::MenuItem m_menu_dm_copy_id;
@@ -116,10 +130,14 @@ protected:
Gtk::MenuItem m_menu_thread_archive;
Gtk::MenuItem m_menu_thread_unarchive;
+ void OnGuildSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y);
+ void OnChannelSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y);
void OnThreadSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y);
bool m_updating_listing = false;
+ Snowflake m_active_channel;
+
// (GetIteratorForChannelFromID is rather slow)
// only temporary since i dont want to worry about maintaining this map
std::unordered_map<Snowflake, Gtk::TreeModel::iterator> m_tmp_channel_map;
diff --git a/src/components/channelscellrenderer.cpp b/src/components/channelscellrenderer.cpp
index 2526753..325d45a 100644
--- a/src/components/channelscellrenderer.cpp
+++ b/src/components/channelscellrenderer.cpp
@@ -1,11 +1,19 @@
-#include "channelscellrenderer.hpp"
#include "abaddon.hpp"
+#include "channelscellrenderer.hpp"
#include <gtkmm.h>
+constexpr static int MentionsRightPad = 7;
+#ifndef M_PI
+constexpr static double M_PI = 3.14159265358979;
+#endif
+constexpr static double M_PI_H = M_PI / 2.0;
+constexpr static double M_PI_3_2 = M_PI * 3.0 / 2.0;
+
CellRendererChannels::CellRendererChannels()
: Glib::ObjectBase(typeid(CellRendererChannels))
, Gtk::CellRenderer()
, m_property_type(*this, "render-type")
+ , m_property_id(*this, "id")
, m_property_name(*this, "name")
, m_property_pixbuf(*this, "pixbuf")
, m_property_pixbuf_animation(*this, "pixbuf-animation")
@@ -26,6 +34,10 @@ Glib::PropertyProxy<RenderType> CellRendererChannels::property_type() {
return m_property_type.get_proxy();
}
+Glib::PropertyProxy<uint64_t> CellRendererChannels::property_id() {
+ return m_property_id.get_proxy();
+}
+
Glib::PropertyProxy<Glib::ustring> CellRendererChannels::property_name() {
return m_property_name.get_proxy();
}
@@ -192,7 +204,7 @@ void CellRendererChannels::render_vfunc_guild(const Cairo::RefPtr<Cairo::Context
const double icon_w = pixbuf_w;
const double icon_h = pixbuf_h;
- const double icon_x = background_area.get_x();
+ const double icon_x = background_area.get_x() + 3;
const double icon_y = background_area.get_y() + background_area.get_height() / 2.0 - icon_h / 2.0;
const double text_x = icon_x + icon_w + 5.0;
@@ -233,6 +245,32 @@ void CellRendererChannels::render_vfunc_guild(const Cairo::RefPtr<Cairo::Context
cr->rectangle(icon_x, icon_y, icon_w, icon_h);
cr->fill();
}
+
+ // unread
+
+ const auto id = m_property_id.get_value();
+
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ int total_mentions;
+ const auto has_unread = discord.GetUnreadStateForGuild(id, total_mentions);
+
+ if (has_unread && !discord.IsGuildMuted(id)) {
+ cr->set_source_rgb(1.0, 1.0, 1.0);
+ const auto x = background_area.get_x();
+ const auto y = background_area.get_y();
+ const auto w = background_area.get_width();
+ const auto h = background_area.get_height();
+ cr->rectangle(x, y + h / 2 - 24 / 2, 3, 24);
+ cr->fill();
+ }
+
+ if (total_mentions < 1) return;
+ auto *paned = static_cast<Gtk::Paned *>(widget.get_ancestor(Gtk::Paned::get_type()));
+ if (paned != nullptr) {
+ const auto edge = std::min(paned->get_position(), background_area.get_width());
+
+ unread_render_mentions(cr, widget, total_mentions, edge, background_area);
+ }
}
// category
@@ -321,13 +359,51 @@ void CellRendererChannels::render_vfunc_channel(const Cairo::RefPtr<Cairo::Conte
Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h);
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto id = m_property_id.get_value();
+ const bool is_muted = discord.IsChannelMuted(id);
+
+ // move to style in msys?
+ static Gdk::RGBA sfw_unmuted("#FFFFFF");
+
const auto nsfw_color = Gdk::RGBA(Abaddon::Get().GetSettings().NSFWChannelColor);
if (m_property_nsfw.get_value())
m_renderer_text.property_foreground_rgba() = nsfw_color;
+ else
+ m_renderer_text.property_foreground_rgba() = sfw_unmuted;
+ if (is_muted) {
+ auto col = m_renderer_text.property_foreground_rgba().get_value();
+ col.set_red(col.get_red() * 0.5);
+ col.set_green(col.get_green() * 0.5);
+ col.set_blue(col.get_blue() * 0.5);
+ m_renderer_text.property_foreground_rgba() = col;
+ }
m_renderer_text.render(cr, widget, background_area, text_cell_area, flags);
- // setting property_foreground_rgba() sets this to true which makes non-nsfw cells use the property too which is bad
- // so unset it
+ // unset foreground to default so properties dont bleed
m_renderer_text.property_foreground_set() = false;
+
+ // unread
+
+ const auto unread_state = discord.GetUnreadStateForChannel(id);
+ if (unread_state < 0) return;
+
+ if (!is_muted) {
+ cr->set_source_rgb(1.0, 1.0, 1.0);
+ const auto x = background_area.get_x();
+ const auto y = background_area.get_y();
+ const auto w = background_area.get_width();
+ const auto h = background_area.get_height();
+ cr->rectangle(x, y, 3, h);
+ cr->fill();
+ }
+
+ if (unread_state < 1) return;
+ auto *paned = static_cast<Gtk::Paned *>(widget.get_ancestor(Gtk::Paned::get_type()));
+ if (paned != nullptr) {
+ const auto edge = std::min(paned->get_position(), cell_area.get_width());
+
+ unread_render_mentions(cr, widget, unread_state, edge, cell_area);
+ }
}
// thread
@@ -436,19 +512,75 @@ void CellRendererChannels::render_vfunc_dm(const Cairo::RefPtr<Cairo::Context> &
const double icon_w = pixbuf->get_width();
const double icon_h = pixbuf->get_height();
- const double icon_x = background_area.get_x() + 2;
+ const double icon_x = background_area.get_x() + 3;
const double icon_y = background_area.get_y() + background_area.get_height() / 2.0 - icon_h / 2.0;
- const double text_x = icon_x + icon_w + 5.0;
+ const double text_x = icon_x + icon_w + 6.0;
const double text_y = background_area.get_y() + background_area.get_height() / 2.0 - text_natural.height / 2.0;
const double text_w = text_natural.width;
const double text_h = text_natural.height;
Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h);
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto id = m_property_id.get_value();
+ const bool is_muted = discord.IsChannelMuted(id);
+
+ if (is_muted)
+ m_renderer_text.property_foreground() = "#7f7f7f";
m_renderer_text.render(cr, widget, background_area, text_cell_area, flags);
+ m_renderer_text.property_foreground_set() = false;
Gdk::Cairo::set_source_pixbuf(cr, m_property_pixbuf.get_value(), icon_x, icon_y);
cr->rectangle(icon_x, icon_y, icon_w, icon_h);
cr->fill();
+
+ // unread
+
+ const auto unread_state = discord.GetUnreadStateForChannel(id);
+ if (unread_state < 0) return;
+
+ if (!is_muted) {
+ cr->set_source_rgb(1.0, 1.0, 1.0);
+ const auto x = background_area.get_x();
+ const auto y = background_area.get_y();
+ const auto w = background_area.get_width();
+ const auto h = background_area.get_height();
+ cr->rectangle(x, y, 3, h);
+ cr->fill();
+ }
+}
+
+void CellRendererChannels::cairo_path_rounded_rect(const Cairo::RefPtr<Cairo::Context> &cr, double x, double y, double w, double h, double r) {
+ const double degrees = M_PI / 180.0;
+
+ cr->begin_new_sub_path();
+ cr->arc(x + w - r, y + r, r, -M_PI_H, 0);
+ cr->arc(x + w - r, y + h - r, r, 0, M_PI_H);
+ cr->arc(x + r, y + h - r, r, M_PI_H, M_PI);
+ cr->arc(x + r, y + r, r, M_PI, M_PI_3_2);
+ cr->close_path();
+}
+
+void CellRendererChannels::unread_render_mentions(const Cairo::RefPtr<Cairo::Context> &cr, Gtk::Widget &widget, int mentions, int edge, const Gdk::Rectangle &cell_area) {
+ Pango::FontDescription font;
+ font.set_family("sans 14");
+ //font.set_weight(Pango::WEIGHT_BOLD);
+
+ auto layout = widget.create_pango_layout(std::to_string(mentions));
+ layout->set_font_description(font);
+ layout->set_alignment(Pango::ALIGN_RIGHT);
+
+ int width, height;
+ layout->get_pixel_size(width, height);
+ {
+ const auto x = cell_area.get_x() + edge - width - MentionsRightPad;
+ const auto y = cell_area.get_y() + cell_area.get_height() / 2.0 - height / 2.0 - 1;
+ cairo_path_rounded_rect(cr, x - 4, y + 2, width + 8, height, 5);
+ cr->set_source_rgb(184.0 / 255.0, 37.0 / 255.0, 37.0 / 255.0);
+ cr->fill();
+ cr->set_source_rgb(1.0, 1.0, 1.0);
+ cr->move_to(x, y);
+ layout->show_in_cairo_context(cr);
+ }
}
diff --git a/src/components/channelscellrenderer.hpp b/src/components/channelscellrenderer.hpp
index ce8da54..95ff4fe 100644
--- a/src/components/channelscellrenderer.hpp
+++ b/src/components/channelscellrenderer.hpp
@@ -3,6 +3,7 @@
#include <gdkmm/pixbufanimation.h>
#include <glibmm/property.h>
#include <map>
+#include "discord/snowflake.hpp"
enum class RenderType : uint8_t {
Guild,
@@ -20,6 +21,7 @@ public:
virtual ~CellRendererChannels();
Glib::PropertyProxy<RenderType> property_type();
+ Glib::PropertyProxy<uint64_t> property_id();
Glib::PropertyProxy<Glib::ustring> property_name();
Glib::PropertyProxy<Glib::RefPtr<Gdk::Pixbuf>> property_icon();
Glib::PropertyProxy<Glib::RefPtr<Gdk::PixbufAnimation>> property_icon_animation();
@@ -103,11 +105,15 @@ protected:
const Gdk::Rectangle &cell_area,
Gtk::CellRendererState flags);
+ static void cairo_path_rounded_rect(const Cairo::RefPtr<Cairo::Context> &cr, double x, double y, double w, double h, double r);
+ void unread_render_mentions(const Cairo::RefPtr<Cairo::Context> &cr, Gtk::Widget &widget, int mentions, int edge, const Gdk::Rectangle &cell_area);
+
private:
Gtk::CellRendererText m_renderer_text;
- Glib::Property<RenderType> m_property_type; // all
- Glib::Property<Glib::ustring> m_property_name; // all
+ Glib::Property<RenderType> m_property_type; // all
+ Glib::Property<Glib::ustring> m_property_name; // all
+ Glib::Property<uint64_t> m_property_id;
Glib::Property<Glib::RefPtr<Gdk::Pixbuf>> m_property_pixbuf; // guild, dm
Glib::Property<Glib::RefPtr<Gdk::PixbufAnimation>> m_property_pixbuf_animation; // guild
Glib::Property<bool> m_property_expanded; // category
diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp
index b678de0..6819375 100644
--- a/src/discord/discord.cpp
+++ b/src/discord/discord.cpp
@@ -1,8 +1,10 @@
+#include "abaddon.hpp"
#include "discord.hpp"
+#include "util.hpp"
#include <cassert>
#include <cinttypes>
-#include "util.hpp"
-#include "abaddon.hpp"
+
+using namespace std::string_literals;
DiscordClient::DiscordClient(bool mem_store)
: m_decompress_buf(InflateChunkSize)
@@ -874,6 +876,108 @@ void DiscordClient::UnArchiveThread(Snowflake channel_id, sigc::slot<void(Discor
});
}
+void DiscordClient::MarkChannelAsRead(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback) {
+ if (m_unread.find(channel_id) == m_unread.end()) return;
+ const auto iter = m_last_message_id.find(channel_id);
+ if (iter == m_last_message_id.end()) return;
+ m_http.MakePOST("/channels/" + std::to_string(channel_id) + "/messages/" + std::to_string(iter->second) + "/ack", "{\"token\":null}", [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::MarkGuildAsRead(Snowflake guild_id, sigc::slot<void(DiscordError code)> callback) {
+ AckBulkData data;
+ const auto channels = GetChannelsInGuild(guild_id);
+ for (const auto &[unread, mention_count] : m_unread) {
+ if (channels.find(unread) == channels.end()) continue;
+
+ const auto iter = m_last_message_id.find(unread);
+ if (iter == m_last_message_id.end()) continue;
+ auto &e = data.ReadStates.emplace_back();
+ e.ID = unread;
+ e.LastMessageID = iter->second;
+ }
+
+ if (data.ReadStates.empty()) return;
+
+ m_http.MakePOST("/read-states/ack-bulk", nlohmann::json(data).dump(), [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::MuteChannel(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback) {
+ const auto channel = GetChannel(channel_id);
+ if (!channel.has_value()) return;
+ const auto guild_id_path = channel->GuildID.has_value() ? std::to_string(*channel->GuildID) : "@me"s;
+ nlohmann::json j;
+ j["channel_overrides"][std::to_string(channel_id)]["mute_config"] = MuteConfigData { std::nullopt, -1 };
+ j["channel_overrides"][std::to_string(channel_id)]["muted"] = true;
+ m_http.MakePATCH("/users/@me/guilds/" + guild_id_path + "/settings", j.dump(), [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::UnmuteChannel(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback) {
+ const auto channel = GetChannel(channel_id);
+ if (!channel.has_value()) return;
+ const auto guild_id_path = channel->GuildID.has_value() ? std::to_string(*channel->GuildID) : "@me"s;
+ nlohmann::json j;
+ j["channel_overrides"][std::to_string(channel_id)]["muted"] = false;
+ m_http.MakePATCH("/users/@me/guilds/" + guild_id_path + "/settings", j.dump(), [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::MarkAllAsRead(sigc::slot<void(DiscordError code)> callback) {
+ AckBulkData data;
+ for (const auto &[unread, mention_count] : m_unread) {
+ const auto iter = m_last_message_id.find(unread);
+ if (iter == m_last_message_id.end()) continue;
+ auto &e = data.ReadStates.emplace_back();
+ e.ID = unread;
+ e.LastMessageID = iter->second;
+ }
+
+ if (data.ReadStates.empty()) return;
+
+ m_http.MakePOST("/read-states/ack-bulk", nlohmann::json(data).dump(), [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::MuteGuild(Snowflake id, sigc::slot<void(DiscordError code)> callback) {
+ m_http.MakePATCH("/users/@me/guilds/" + std::to_string(id) + "/settings", R"({"muted":true})", [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
+void DiscordClient::UnmuteGuild(Snowflake id, sigc::slot<void(DiscordError code)> callback) {
+ m_http.MakePATCH("/users/@me/guilds/" + std::to_string(id) + "/settings", R"({"muted":false})", [this, callback](const http::response_type &response) {
+ if (CheckCode(response))
+ callback(DiscordError::NONE);
+ else
+ callback(GetCodeFromResponse(response));
+ });
+}
+
void DiscordClient::FetchPinned(Snowflake id, sigc::slot<void(std::vector<Message>, DiscordError code)> callback) {
// return from db if we know the pins have already been requested
if (m_channels_pinned_requested.find(id) != m_channels_pinned_requested.end()) {
@@ -1060,6 +1164,34 @@ void DiscordClient::SetUserAgent(std::string agent) {
m_websocket.SetUserAgent(agent);
}
+bool DiscordClient::IsChannelMuted(Snowflake id) const noexcept {
+ return m_muted_channels.find(id) != m_muted_channels.end();
+}
+
+bool DiscordClient::IsGuildMuted(Snowflake id) const noexcept {
+ return m_muted_guilds.find(id) != m_muted_guilds.end();
+}
+
+int DiscordClient::GetUnreadStateForChannel(Snowflake id) const noexcept {
+ const auto iter = m_unread.find(id);
+ if (iter == m_unread.end()) return -1; // todo: no magic number (who am i kidding ill never change this)
+ return iter->second;
+}
+
+bool DiscordClient::GetUnreadStateForGuild(Snowflake id, int &total_mentions) const noexcept {
+ total_mentions = 0;
+ bool has_any_unread = false;
+ const auto channels = GetChannelsInGuild(id);
+ for (const auto channel_id : channels) {
+ const auto channel_unread = GetUnreadStateForChannel(channel_id);
+ if (!has_any_unread && channel_unread > -1 && !IsChannelMuted(channel_id))
+ has_any_unread = true;
+ if (channel_unread > -1)
+ total_mentions += channel_unread;
+ }
+ return has_any_unread;
+}
+
PresenceStatus DiscordClient::GetUserStatus(Snowflake id) const {
auto it = m_user_to_status.find(id);
if (it != m_user_to_status.end())
@@ -1290,6 +1422,12 @@ void DiscordClient::HandleGatewayMessage(std::string str) {
case GatewayEvent::THREAD_MEMBER_LIST_UPDATE: {
HandleGatewayThreadMemberListUpdate(m);
} break;
+ case GatewayEvent::MESSAGE_ACK: {
+ HandleGatewayMessageAck(m);
+ } break;
+ case GatewayEvent::USER_GUILD_SETTINGS_UPDATE: {
+ HandleGatewayUserGuildSettingsUpdate(m);
+ } break;
}
} break;
default:
@@ -1411,6 +1549,10 @@ void DiscordClient::HandleGatewayReady(const GatewayMessage &msg) {
m_session_id = data.SessionID;
m_user_data = data.SelfUser;
m_user_settings = data.Settings;
+
+ HandleReadyReadState(data);
+ HandleReadyGuildSettings(data);
+
m_signal_gateway_ready.emit();
}
@@ -1419,6 +1561,12 @@ void DiscordClient::HandleGatewayMessageCreate(const GatewayMessage &msg) {
StoreMessageData(data);
if (data.GuildID.has_value())
AddUserToGuild(data.Author.ID, *data.GuildID);
+ m_last_message_id[data.ChannelID] = data.ID;
+ if (data.Author.ID != GetUserData().ID)
+ m_unread[data.ChannelID];
+ if (data.DoesMention(GetUserData().ID)) {
+ m_unread[data.ChannelID]++;
+ }
m_signal_message_create.emit(data);
}
@@ -1778,6 +1926,65 @@ void DiscordClient::HandleGatewayThreadMemberListUpdate(const GatewayMessage &ms
m_signal_thread_member_list_update.emit(data);
}
+void DiscordClient::HandleGatewayMessageAck(const GatewayMessage &msg) {
+ MessageAckData data = msg.Data;
+ m_unread.erase(data.ChannelID);
+ m_signal_message_ack.emit(data);
+}
+
+void DiscordClient::HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg) {
+ UserGuildSettingsUpdateData data = msg.Data;
+ const auto channels = GetChannelsInGuild(data.Settings.GuildID);
+ std::set<Snowflake> now_muted_channels;
+ const auto now = Snowflake::FromNow();
+
+ const bool was_muted = IsGuildMuted(data.Settings.GuildID);
+ bool now_muted = false;
+ if (data.Settings.Muted) {
+ if (data.Settings.MuteConfig.EndTime.has_value()) {
+ const auto end = Snowflake::FromISO8601(*data.Settings.MuteConfig.EndTime);
+ if (end.IsValid() && end > now)
+ now_muted = true;
+ } else {
+ now_muted = true;
+ }
+ }
+ if (was_muted && !now_muted) {
+ m_muted_guilds.erase(data.Settings.GuildID);
+ m_signal_guild_unmuted.emit(data.Settings.GuildID);
+ } else if (!was_muted && now_muted) {
+ m_muted_guilds.insert(data.Settings.GuildID);
+ m_signal_guild_muted.emit(data.Settings.GuildID);
+ }
+
+ for (const auto &override : data.Settings.ChannelOverrides) {
+ if (override.Muted) {
+ if (override.MuteConfig.EndTime.has_value()) {
+ const auto end = Snowflake::FromISO8601(*override.MuteConfig.EndTime);
+ if (end.IsValid() && end > now)
+ now_muted_channels.insert(override.ChannelID);
+ } else {
+ now_muted_channels.insert(override.ChannelID);
+ }
+ }
+ }
+ for (const auto &channel_id : channels) {
+ const bool was_muted = IsChannelMuted(channel_id);
+ const bool now_muted = now_muted_channels.find(channel_id) != now_muted_channels.end();
+ if (now_muted) {
+ m_muted_channels.insert(channel_id);
+ if (!was_muted) {
+ m_signal_channel_muted.emit(channel_id);
+ }
+ } else {
+ m_muted_channels.erase(channel_id);
+ if (was_muted) {
+ m_signal_channel_unmuted.emit(channel_id);
+ }
+ }
+ }
+}
+
void DiscordClient::HandleGatewayReadySupplemental(const GatewayMessage &msg) {
ReadySupplementalData data = msg.Data;
for (const auto &p : data.MergedPresences.Friends) {
@@ -2105,6 +2312,77 @@ void DiscordClient::StoreMessageData(Message &msg) {
StoreMessageData(**msg.ReferencedMessage);
}
+// some notes for myself
+// a read channel is determined by checking if the channel object's last message id is equal to the read state's last message id
+// channels without entries are also unread
+// here the absence of an entry in m_unread indicates a read channel and the value is only the mention count since the message doesnt matter
+// no entry.id cannot be a guild even though sometimes it looks like it
+void DiscordClient::HandleReadyReadState(const ReadyEventData &data) {
+ for (const auto &guild : data.Guilds)
+ for (const auto &channel : *guild.Channels)
+ if (channel.LastMessageID.has_value() && (channel.Type == ChannelType::GUILD_TEXT || channel.Type == ChannelType::GUILD_NEWS))
+ m_last_message_id[channel.ID] = *channel.LastMessageID;
+ for (const auto &channel : data.PrivateChannels)
+ if (channel.LastMessageID.has_value())
+ m_last_message_id[channel.ID] = *channel.LastMessageID;
+
+ for (const auto &entry : data.ReadState.Entries) {
+ const auto it = m_last_message_id.find(entry.ID);
+ if (it == m_last_message_id.end()) continue;
+ if (it->second > entry.LastMessageID) {
+ if (HasChannelPermission(GetUserData().ID, entry.ID, Permission::VIEW_CHANNEL))
+ m_unread[entry.ID] = entry.MentionCount;
+ }
+ }
+
+ // channels that arent in the read state are considered unread
+ for (const auto &guild : data.Guilds) {
+ if (!guild.JoinedAt.has_value()) continue; // doubt this can happen but whatever
+ const auto joined_at = Snowflake::FromISO8601(*guild.JoinedAt);
+ for (const auto &channel : *guild.Channels) {
+ if (channel.LastMessageID.has_value()) {
+ // unread messages from before you joined dont count as unread
+ if (*channel.LastMessageID < joined_at) continue;
+ if (std::find_if(data.ReadState.Entries.begin(), data.ReadState.Entries.end(), [id = channel.ID](const ReadStateEntry &e) {
+ return e.ID == id;
+ }) == data.ReadState.Entries.end()) {
+ // cant be unread if u cant even see the channel
+ // better to check here since HasChannelPermission hits the store
+ if (HasChannelPermission(GetUserData().ID, channel.ID, Permission::VIEW_CHANNEL))
+ m_unread[channel.ID] = 0;
+ }
+ }
+ }
+ }
+}
+
+void DiscordClient::HandleReadyGuildSettings(const ReadyEventData &data) {
+ const auto now = Snowflake::FromNow();
+ for (const auto &entry : data.GuildSettings.Entries) {
+ // 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.MuteConfig.EndTime.has_value()) {
+ const auto end = Snowflake::FromISO8601(*entry.MuteConfig.EndTime);
+ if (end.IsValid() && end > now)
+ m_muted_guilds.insert(entry.GuildID);
+ } else {
+ m_muted_guilds.insert(entry.GuildID);
+ }
+ }
+ for (const auto &override : entry.ChannelOverrides) {
+ if (override.Muted) {
+ if (override.MuteConfig.EndTime.has_value()) {
+ const auto end = Snowflake::FromISO8601(*override.MuteConfig.EndTime);
+ if (end.IsValid() && end > now)
+ m_muted_channels.insert(override.ChannelID);
+ } else {
+ m_muted_channels.insert(override.ChannelID);
+ }
+ }
+ }
+ }
+}
+
void DiscordClient::LoadEventMap() {
m_event_map["READY"] = GatewayEvent::READY;
m_event_map["MESSAGE_CREATE"] = GatewayEvent::MESSAGE_CREATE;
@@ -2147,6 +2425,8 @@ void DiscordClient::LoadEventMap() {
m_event_map["THREAD_MEMBER_UPDATE"] = GatewayEvent::THREAD_MEMBER_UPDATE;
m_event_map["THREAD_UPDATE"] = GatewayEvent::THREAD_UPDATE;
m_event_map["THREAD_MEMBER_LIST_UPDATE"] = GatewayEvent::THREAD_MEMBER_LIST_UPDATE;
+ m_event_map["MESSAGE_ACK"] = GatewayEvent::MESSAGE_ACK;
+ m_event_map["USER_GUILD_SETTINGS_UPDATE"] = GatewayEvent::USER_GUILD_SETTINGS_UPDATE;
}
DiscordClient::type_signal_gateway_ready DiscordClient::signal_gateway_ready() {
@@ -2309,6 +2589,10 @@ DiscordClient::type_signal_thread_member_list_update DiscordClient::signal_threa
return m_signal_thread_member_list_update;
}
+DiscordClient::type_signal_message_ack DiscordClient::signal_message_ack() {
+ return m_signal_message_ack;
+}
+
DiscordClient::type_signal_added_to_thread DiscordClient::signal_added_to_thread() {
return m_signal_added_to_thread;
}
@@ -2321,6 +2605,22 @@ DiscordClient::type_signal_message_sent DiscordClient::signal_message_sent() {
return m_signal_message_sent;
}
+DiscordClient::type_signal_channel_muted DiscordClient::signal_channel_muted() {
+ return m_signal_channel_muted;
+}
+
+DiscordClient::type_signal_channel_unmuted DiscordClient::signal_channel_unmuted() {
+ return m_signal_channel_unmuted;
+}
+
+DiscordClient::type_signal_guild_muted DiscordClient::signal_guild_muted() {
+ return m_signal_guild_muted;
+}
+
+DiscordClient::type_signal_guild_unmuted DiscordClient::signal_guild_unmuted() {
+ return m_signal_guild_unmuted;
+}
+
DiscordClient::type_signal_message_send_fail DiscordClient::signal_message_send_fail() {
return m_signal_message_send_fail;
}
diff --git a/src/discord/discord.hpp b/src/discord/discord.hpp
index 4010977..44f02bb 100644
--- a/src/discord/discord.hpp
+++ b/src/discord/discord.hpp
@@ -138,6 +138,13 @@ public:
void LeaveThread(Snowflake channel_id, const std::string &location, sigc::slot<void(DiscordError code)> callback);
void ArchiveThread(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback);
void UnArchiveThread(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback);
+ void MarkChannelAsRead(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback);
+ void MarkGuildAsRead(Snowflake guild_id, sigc::slot<void(DiscordError code)> callback);
+ void MuteChannel(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback);
+ void UnmuteChannel(Snowflake channel_id, sigc::slot<void(DiscordError code)> callback);
+ void MarkAllAsRead(sigc::slot<void(DiscordError code)> callback);
+ void MuteGuild(Snowflake id, sigc::slot<void(DiscordError code)> callback);
+ void UnmuteGuild(Snowflake id, sigc::slot<void(DiscordError code)> callback);
bool CanModifyRole(Snowflake guild_id, Snowflake role_id) const;
bool CanModifyRole(Snowflake guild_id, Snowflake role_id, Snowflake user_id) const;
@@ -182,6 +189,11 @@ public:
void UpdateToken(std::string token);
void SetUserAgent(std::string agent);
+ bool IsChannelMuted(Snowflake id) const noexcept;
+ bool IsGuildMuted(Snowflake id) const noexcept;
+ int GetUnreadStateForChannel(Snowflake id) const noexcept;
+ bool GetUnreadStateForGuild(Snowflake id, int &total_mentions) const noexcept;
+
PresenceStatus GetUserStatus(Snowflake id) const;
std::map<Snowflake, RelationshipType> GetRelationships() const;
@@ -244,6 +256,8 @@ private:
void HandleGatewayThreadMemberUpdate(const GatewayMessage &msg);
void HandleGatewayThreadUpdate(const GatewayMessage &msg);
void HandleGatewayThreadMemberListUpdate(const GatewayMessage &msg);
+ void HandleGatewayMessageAck(const GatewayMessage &msg);
+ void HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg);
void HandleGatewayReadySupplemental(const GatewayMessage &msg);
void HandleGatewayReconnect(const GatewayMessage &msg);
void HandleGatewayInvalidSession(const GatewayMessage &msg);
@@ -259,6 +273,9 @@ private:
void StoreMessageData(Message &msg);
+ void HandleReadyReadState(const ReadyEventData &data);
+ void HandleReadyGuildSettings(const ReadyEventData &data);
+
std::string m_token;
void AddUserToGuild(Snowflake user_id, Snowflake guild_id);
@@ -269,6 +286,10 @@ private:
std::map<Snowflake, RelationshipType> m_user_relationships;
std::set<Snowflake> m_joined_threads;
std::map<Snowflake, std::vector<Snowflake>> m_thread_members;
+ std::map<Snowflake, Snowflake> m_last_message_id;
+ std::unordered_set<Snowflake> m_muted_guilds;
+ std::unordered_set<Snowflake> m_muted_channels;
+ std::unordered_map<Snowflake, int> m_unread;
UserData m_user_data;
UserSettings m_user_settings;
@@ -343,6 +364,7 @@ public:
typedef sigc::signal<void, ThreadMembersUpdateData> type_signal_thread_members_update;
typedef sigc::signal<void, ThreadUpdateData> type_signal_thread_update;
typedef sigc::signal<void, ThreadMemberListUpdateData> type_signal_thread_member_list_update;
+ typedef sigc::signal<void, MessageAckData> type_signal_message_ack;
// not discord dispatch events
typedef sigc::signal<void, Snowflake> type_signal_added_to_thread;
@@ -350,6 +372,10 @@ public:
typedef sigc::signal<void, Message> type_signal_message_unpinned;
typedef sigc::signal<void, Message> type_signal_message_pinned;
typedef sigc::signal<void, Message> type_signal_message_sent;
+ typedef sigc::signal<void, Snowflake> type_signal_channel_muted;
+ typedef sigc::signal<void, Snowflake> type_signal_channel_unmuted;
+ typedef sigc::signal<void, Snowflake> type_signal_guild_muted;
+ typedef sigc::signal<void, Snowflake> type_signal_guild_unmuted;
typedef sigc::signal<void, std::string /* nonce */, float /* retry_after */> type_signal_message_send_fail; // retry after param will be 0 if it failed for a reason that isnt slowmode
typedef sigc::signal<void, bool, GatewayCloseCode> type_signal_disconnected; // bool true if reconnecting
@@ -393,10 +419,15 @@ public:
type_signal_thread_members_update signal_thread_members_update();
type_signal_thread_update signal_thread_update();
type_signal_thread_member_list_update signal_thread_member_list_update();
+ type_signal_message_ack signal_message_ack();
type_signal_added_to_thread signal_added_to_thread();
type_signal_removed_from_thread signal_removed_from_thread();
type_signal_message_sent signal_message_sent();
+ type_signal_channel_muted signal_channel_muted();
+ type_signal_channel_unmuted signal_channel_unmuted();
+ type_signal_guild_muted signal_guild_muted();
+ type_signal_guild_unmuted signal_guild_unmuted();
type_signal_message_send_fail signal_message_send_fail();
type_signal_disconnected signal_disconnected();
type_signal_connected signal_connected();
@@ -440,10 +471,15 @@ protected:
type_signal_thread_members_update m_signal_thread_members_update;
type_signal_thread_update m_signal_thread_update;
type_signal_thread_member_list_update m_signal_thread_member_list_update;
+ type_signal_message_ack m_signal_message_ack;
type_signal_removed_from_thread m_signal_removed_from_thread;
type_signal_added_to_thread m_signal_added_to_thread;
type_signal_message_sent m_signal_message_sent;
+ type_signal_channel_muted m_signal_channel_muted;
+ type_signal_channel_unmuted m_signal_channel_unmuted;
+ type_signal_guild_muted m_signal_guild_muted;
+ type_signal_guild_unmuted m_signal_guild_unmuted;
type_signal_message_send_fail m_signal_message_send_fail;
type_signal_disconnected m_signal_disconnected;
type_signal_connected m_signal_connected;
diff --git a/src/discord/message.cpp b/src/discord/message.cpp
index 70c557d..93d57c2 100644
--- a/src/discord/message.cpp
+++ b/src/discord/message.cpp
@@ -263,3 +263,9 @@ bool Message::IsDeleted() const {
bool Message::IsEdited() const {
return m_edited;
}
+
+bool Message::DoesMention(Snowflake id) const noexcept {
+ return std::any_of(Mentions.begin(), Mentions.end(), [id](const UserData &user) {
+ return user.ID == id;
+ });
+}
diff --git a/src/discord/message.hpp b/src/discord/message.hpp
index 56f4c0f..244b572 100644
--- a/src/discord/message.hpp
+++ b/src/discord/message.hpp
@@ -212,6 +212,8 @@ struct Message {
bool IsDeleted() const;
bool IsEdited() const;
+ bool DoesMention(Snowflake id) const noexcept;
+
private:
bool m_deleted = false;
bool m_edited = false;
diff --git a/src/discord/objects.cpp b/src/discord/objects.cpp
index c6de2ce..8ca8c0f 100644
--- a/src/discord/objects.cpp
+++ b/src/discord/objects.cpp
@@ -119,6 +119,84 @@ void to_json(nlohmann::json &j, const UpdateStatusMessage &m) {
}
}
+void from_json(const nlohmann::json &j, ReadStateEntry &m) {
+ JS_ON("mention_count", m.MentionCount);
+ JS_ON("last_message_id", m.LastMessageID);
+ JS_D("id", m.ID);
+}
+
+void to_json(nlohmann::json &j, const ReadStateEntry &m) {
+ j["channel_id"] = m.ID;
+ j["message_id"] = m.LastMessageID;
+}
+
+void from_json(const nlohmann::json &j, ReadStateData &m) {
+ JS_ON("version", m.Version);
+ JS_ON("partial", m.IsPartial);
+ JS_ON("entries", m.Entries);
+}
+
+void from_json(const nlohmann::json &j, UserGuildSettingsChannelOverride &m) {
+ JS_D("muted", m.Muted);
+ JS_D("message_notifications", m.MessageNotifications);
+ JS_D("collapsed", m.Collapsed);
+ JS_D("channel_id", m.ChannelID);
+ JS_N("mute_config", m.MuteConfig);
+}
+
+void to_json(nlohmann::json &j, const UserGuildSettingsChannelOverride &m) {
+ j["channel_id"] = m.ChannelID;
+ j["collapsed"] = m.Collapsed;
+ j["message_notifications"] = m.MessageNotifications;
+ j["mute_config"] = m.MuteConfig;
+ j["muted"] = m.Muted;
+}
+
+void from_json(const nlohmann::json &j, MuteConfigData &m) {
+ JS_ON("end_time", m.EndTime);
+ JS_D("selected_time_window", m.SelectedTimeWindow);
+}
+
+void to_json(nlohmann::json &j, const MuteConfigData &m) {
+ if (m.EndTime.has_value())
+ j["end_time"] = *m.EndTime;
+ else
+ j["end_time"] = nullptr;
+ j["selected_time_window"] = m.SelectedTimeWindow;
+}
+
+void from_json(const nlohmann::json &j, UserGuildSettingsEntry &m) {
+ JS_D("version", m.Version);
+ JS_D("suppress_roles", m.SuppressRoles);
+ JS_D("suppress_everyone", m.SuppressEveryone);
+ JS_D("muted", m.Muted);
+ JS_D("mobile_push", m.MobilePush);
+ JS_D("message_notifications", m.MessageNotifications);
+ JS_D("hide_muted_channels", m.HideMutedChannels);
+ JS_N("guild_id", m.GuildID);
+ JS_D("channel_overrides", m.ChannelOverrides);
+ JS_N("mute_config", m.MuteConfig);
+}
+
+void to_json(nlohmann::json &j, const UserGuildSettingsEntry &m) {
+ j["channel_overrides"] = m.ChannelOverrides;
+ j["guild_id"] = m.GuildID;
+ j["hide_muted_channels"] = m.HideMutedChannels;
+ j["message_notifications"] = m.MessageNotifications;
+ j["mobile_push"] = m.MobilePush;
+ j["mute_config"] = m.MuteConfig;
+ j["muted"] = m.Muted;
+ j["suppress_everyone"] = m.SuppressEveryone;
+ j["suppress_roles"] = m.SuppressRoles;
+ j["version"] = m.Version;
+}
+
+void from_json(const nlohmann::json &j, UserGuildSettingsData &m) {
+ JS_D("version", m.Version);
+ JS_D("partial", m.IsPartial);
+ JS_D("entries", m.Entries);
+}
+
void from_json(const nlohmann::json &j, ReadyEventData &m) {
JS_D("v", m.GatewayVersion);
JS_D("user", m.SelfUser);
@@ -132,6 +210,8 @@ void from_json(const nlohmann::json &j, ReadyEventData &m) {
JS_ON("merged_members", m.MergedMembers);
JS_O("relationships", m.Relationships);
JS_O("guild_join_requests", m.GuildJoinRequests);
+ JS_O("read_state", m.ReadState);
+ JS_D("user_guild_settings", m.GuildSettings);
}
void from_json(const nlohmann::json &j, MergedPresence &m) {
@@ -532,3 +612,17 @@ void to_json(nlohmann::json &j, const ModifyChannelObject &m) {
JS_IF("archived", m.Archived);
JS_IF("locked", m.Locked);
}
+
+void from_json(const nlohmann::json &j, MessageAckData &m) {
+ // JS_D("version", m.Version);
+ JS_D("message_id", m.MessageID);
+ JS_D("channel_id", m.ChannelID);
+}
+
+void to_json(nlohmann::json &j, const AckBulkData &m) {
+ j["read_states"] = m.ReadStates;
+}
+
+void from_json(const nlohmann::json &j, UserGuildSettingsUpdateData &m) {
+ m.Settings = j;
+}
diff --git a/src/discord/objects.hpp b/src/discord/objects.hpp
index 7084efb..c72361b 100644
--- a/src/discord/objects.hpp
+++ b/src/discord/objects.hpp
@@ -78,6 +78,8 @@ enum class GatewayEvent : int {
THREAD_MEMBER_UPDATE,
THREAD_MEMBERS_UPDATE,
THREAD_MEMBER_LIST_UPDATE,
+ MESSAGE_ACK,
+ USER_GUILD_SETTINGS_UPDATE,
};
enum class GatewayCloseCode : uint16_t {
@@ -224,6 +226,67 @@ struct UpdateStatusMessage {
friend void to_json(nlohmann::json &j, const UpdateStatusMessage &m);
};
+struct ReadStateEntry {
+ int MentionCount;
+ Snowflake LastMessageID;
+ Snowflake ID;
+ // std::string LastPinTimestamp; iso
+
+ friend void from_json(const nlohmann::json &j, ReadStateEntry &m);
+ friend void to_json(nlohmann::json &j, const ReadStateEntry &m);
+};
+
+struct ReadStateData {
+ int Version;
+ bool IsPartial;
+ std::vector<ReadStateEntry> Entries;
+
+ friend void from_json(const nlohmann::json &j, ReadStateData &m);
+};
+
+struct MuteConfigData {
+ std::optional<std::string> EndTime; // nullopt is encoded as null
+ int SelectedTimeWindow;
+
+ friend void from_json(const nlohmann::json &j, MuteConfigData &m);
+ friend void to_json(nlohmann::json &j, const MuteConfigData &m);
+};
+
+struct UserGuildSettingsChannelOverride {
+ bool Muted;
+ MuteConfigData MuteConfig;
+ int MessageNotifications;
+ bool Collapsed;
+ Snowflake ChannelID;
+
+ friend void from_json(const nlohmann::json &j, UserGuildSettingsChannelOverride &m);
+ friend void to_json(nlohmann::json &j, const UserGuildSettingsChannelOverride &m);
+};
+
+struct UserGuildSettingsEntry {
+ int Version;
+ bool SuppressRoles;
+ bool SuppressEveryone;
+ bool Muted;
+ MuteConfigData MuteConfig;
+ bool MobilePush;
+ int MessageNotifications;
+ bool HideMutedChannels;
+ Snowflake GuildID;
+ std::vector<UserGuildSettingsChannelOverride> ChannelOverrides;
+
+ friend void from_json(const nlohmann::json &j, UserGuildSettingsEntry &m);
+ friend void to_json(nlohmann::json &j, const UserGuildSettingsEntry &m);
+};
+
+struct UserGuildSettingsData {
+ int Version;
+ bool IsPartial;
+ std::vector<UserGuildSettingsEntry> Entries;
+
+ friend void from_json(const nlohmann::json &j, UserGuildSettingsData &m);
+};
+
struct ReadyEventData {
int GatewayVersion;
UserData SelfUser;
@@ -239,6 +302,8 @@ struct ReadyEventData {
std::optional<std::vector<std::vector<GuildMember>>> MergedMembers;
std::optional<std::vector<RelationshipData>> Relationships;
std::optional<std::vector<GuildApplicationData>> GuildJoinRequests;
+ ReadStateData ReadState;
+ UserGuildSettingsData GuildSettings;
// std::vector<Unknown> ConnectedAccounts; // opt
// std::map<std::string, Unknown> Consents; // opt
// std::vector<Unknown> Experiments; // opt
@@ -745,3 +810,23 @@ struct ModifyChannelObject {
friend void to_json(nlohmann::json &j, const ModifyChannelObject &m);
};
+
+struct MessageAckData {
+ // int Version; // what is this ?!?!?!!?
+ Snowflake MessageID;
+ Snowflake ChannelID;
+
+ friend void from_json(const nlohmann::json &j, MessageAckData &m);
+};
+
+struct AckBulkData {
+ std::vector<ReadStateEntry> ReadStates;
+
+ friend void to_json(nlohmann::json &j, const AckBulkData &m);
+};
+
+struct UserGuildSettingsUpdateData {
+ UserGuildSettingsEntry Settings;
+
+ friend void from_json(const nlohmann::json &j, UserGuildSettingsUpdateData &m);
+};
diff --git a/src/discord/snowflake.cpp b/src/discord/snowflake.cpp
index cea9153..8f470a7 100644
--- a/src/discord/snowflake.cpp
+++ b/src/discord/snowflake.cpp
@@ -1,7 +1,8 @@
#include "snowflake.hpp"
+#include "util.hpp"
+#include <chrono>
#include <ctime>
#include <iomanip>
-#include <chrono>
constexpr static uint64_t DiscordEpochSeconds = 1420070400;
@@ -14,13 +15,13 @@ Snowflake::Snowflake(uint64_t n)
: m_num(n) {}
Snowflake::Snowflake(const std::string &str) {
- if (str.size())
+ if (!str.empty())
m_num = std::stoull(str);
else
m_num = Invalid;
}
Snowflake::Snowflake(const Glib::ustring &str) {
- if (str.size())
+ if (!str.empty())
m_num = std::strtoull(str.c_str(), nullptr, 10);
else
m_num = Invalid;
@@ -38,6 +39,16 @@ Snowflake Snowflake::FromNow() {
return snowflake;
}
+Snowflake Snowflake::FromISO8601(std::string_view ts) {
+ int yr, mon, day, hr, min, sec, tzhr, tzmin;
+ float milli;
+ if (std::sscanf(ts.data(), "%d-%d-%dT%d:%d:%d%f+%d:%d",
+ &yr, &mon, &day, &hr, &min, &sec, &milli, &tzhr, &tzmin) != 9) return Snowflake::Invalid;
+ const auto epoch = util::TimeToEpoch(yr, mon, day, hr, min, sec);
+ if (epoch < DiscordEpochSeconds) return Snowflake::Invalid;
+ return SecondsInterval * (epoch - DiscordEpochSeconds) + static_cast<uint64_t>(milli * static_cast<float>(SecondsInterval));
+}
+
bool Snowflake::IsValid() const {
return m_num != Invalid;
}
diff --git a/src/discord/snowflake.hpp b/src/discord/snowflake.hpp
index 0b79723..f2da5d1 100644
--- a/src/discord/snowflake.hpp
+++ b/src/discord/snowflake.hpp
@@ -10,6 +10,7 @@ struct Snowflake {
Snowflake(const Glib::ustring &str);
static Snowflake FromNow(); // not thread safe
+ static Snowflake FromISO8601(std::string_view ts);
bool IsValid() const;
std::string GetLocalTimestamp() const;
@@ -26,7 +27,7 @@ struct Snowflake {
return m_num;
}
- const static Snowflake Invalid; // makes sense to me
+ const static Snowflake Invalid; // makes sense to me
const static uint64_t SecondsInterval = 4194304000ULL; // the "difference" between two snowflakes one second apart
friend void from_json(const nlohmann::json &j, Snowflake &s);
diff --git a/src/util.cpp b/src/util.cpp
index 34ca6d4..6796a3f 100644
--- a/src/util.cpp
+++ b/src/util.cpp
@@ -1,4 +1,5 @@
#include "util.hpp"
+#include <array>
#include <filesystem>
Semaphore::Semaphore(int count)
@@ -215,3 +216,31 @@ bool util::IsFile(std::string_view path) {
if (ec) return false;
return status.type() == std::filesystem::file_type::regular;
}
+
+constexpr bool IsLeapYear(int year) {
+ if (year % 4 != 0) return false;
+ if (year % 100 != 0) return true;
+ return (year % 400) == 0;
+}
+
+constexpr static int SecsPerMinute = 60;
+constexpr static int SecsPerHour = 3600;
+constexpr static int SecsPerDay = 86400;
+constexpr static std::array<int, 12> DaysOfMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
+
+// may god smite whoever is responsible for the absolutely abominable api that is C time functions
+// i shouldnt have to write this. mktime ALMOST works but it adds the current timezone offset. WHY???
+uint64_t util::TimeToEpoch(int year, int month, int day, int hour, int minute, int seconds) {
+ uint64_t secs = 0;
+ for (int y = 1970; y < year; ++y)
+ secs += (IsLeapYear(y) ? 366 : 365) * SecsPerDay;
+ for (int m = 1; m < month; ++m) {
+ secs += DaysOfMonth[m - 1] * SecsPerDay;
+ if (m == 2 && IsLeapYear(year)) secs += SecsPerDay;
+ }
+ secs += (day - 1) * SecsPerDay;
+ secs += hour * SecsPerHour;
+ secs += minute * SecsPerMinute;
+ secs += seconds;
+ return secs;
+}
diff --git a/src/util.hpp b/src/util.hpp
index feaf08d..aa87301 100644
--- a/src/util.hpp
+++ b/src/util.hpp
@@ -15,6 +15,8 @@
#include <type_traits>
#include <gtkmm.h>
+#define NOOP_CALLBACK [](...) {}
+
namespace util {
template<typename T>
struct is_optional : ::std::false_type {};
@@ -25,6 +27,8 @@ struct is_optional<::std::optional<T>> : ::std::true_type {};
bool IsFolder(std::string_view path);
bool IsFile(std::string_view path);
+
+uint64_t TimeToEpoch(int year, int month, int day, int hour, int minute, int seconds);
} // namespace util
class Semaphore {
diff --git a/src/windows/mainwindow.cpp b/src/windows/mainwindow.cpp
index 659107a..c8abb75 100644
--- a/src/windows/mainwindow.cpp
+++ b/src/windows/mainwindow.cpp
@@ -5,10 +5,13 @@ MainWindow::MainWindow()
: m_main_box(Gtk::ORIENTATION_VERTICAL)
, m_content_box(Gtk::ORIENTATION_HORIZONTAL)
, m_chan_content_paned(Gtk::ORIENTATION_HORIZONTAL)
- , m_content_members_paned(Gtk::ORIENTATION_HORIZONTAL) {
+ , m_content_members_paned(Gtk::ORIENTATION_HORIZONTAL)
+ , m_accels(Gtk::AccelGroup::create()) {
set_default_size(1200, 800);
get_style_context()->add_class("app-window");
+ add_accel_group(m_accels);
+
m_menu_discord.set_label("Discord");
m_menu_discord.set_submenu(m_menu_discord_sub);
m_menu_discord_connect.set_label("Connect");
@@ -42,9 +45,14 @@ MainWindow::MainWindow()
m_menu_view_friends.set_label("Friends");
m_menu_view_pins.set_label("Pins");
m_menu_view_threads.set_label("Threads");
+ m_menu_view_mark_guild_as_read.set_label("Mark Server as Read");
+ m_menu_view_mark_guild_as_read.add_accelerator("activate", m_accels, GDK_KEY_Escape, Gdk::SHIFT_MASK, Gtk::ACCEL_VISIBLE);
+ m_menu_view_mark_all_as_read.set_label("Mark All as Read");
m_menu_view_sub.append(m_menu_view_friends);
m_menu_view_sub.append(m_menu_view_pins);
m_menu_view_sub.append(m_menu_view_threads);
+ m_menu_view_sub.append(m_menu_view_mark_guild_as_read);
+ m_menu_view_sub.append(m_menu_view_mark_all_as_read);
m_menu_view_sub.signal_popped_up().connect(sigc::mem_fun(*this, &MainWindow::OnViewSubmenuPopup));
m_menu_bar.append(m_menu_file);
@@ -98,6 +106,19 @@ MainWindow::MainWindow()
m_signal_action_view_threads.emit(GetChatActiveChannel());
});
+ m_menu_view_mark_guild_as_read.signal_activate().connect([this] {
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto channel_id = GetChatActiveChannel();
+ const auto channel = discord.GetChannel(channel_id);
+ if (channel.has_value() && channel->GuildID.has_value()) {
+ discord.MarkGuildAsRead(*channel->GuildID, NOOP_CALLBACK);
+ }
+ });
+
+ m_menu_view_mark_all_as_read.signal_activate().connect([this] {
+ Abaddon::Get().GetDiscordClient().MarkAllAsRead(NOOP_CALLBACK);
+ });
+
m_content_box.set_hexpand(true);
m_content_box.set_vexpand(true);
m_content_box.show();
@@ -138,6 +159,7 @@ MainWindow::MainWindow()
m_chan_content_paned.set_position(200);
m_chan_content_paned.show();
m_content_box.add(m_chan_content_paned);
+ m_channel_list.UsePanedHack(m_chan_content_paned);
m_content_members_paned.pack1(m_content_stack);
m_content_members_paned.pack2(*member_list);
@@ -246,13 +268,18 @@ void MainWindow::OnDiscordSubmenuPopup(const Gdk::Rectangle *flipped_rect, const
}
void MainWindow::OnViewSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) {
- m_menu_view_friends.set_sensitive(Abaddon::Get().GetDiscordClient().IsStarted());
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const bool discord_active = discord.IsStarted();
+
+ m_menu_view_friends.set_sensitive(discord_active);
+ m_menu_view_mark_guild_as_read.set_sensitive(discord_active);
+ m_menu_view_mark_all_as_read.set_sensitive(discord_active);
+
auto channel_id = GetChatActiveChannel();
m_menu_view_pins.set_sensitive(false);
m_menu_view_threads.set_sensitive(false);
if (channel_id.IsValid()) {
- auto channel = Abaddon::Get().GetDiscordClient().GetChannel(channel_id);
- if (channel.has_value()) {
+ if (auto channel = discord.GetChannel(channel_id); channel.has_value()) {
m_menu_view_threads.set_sensitive(channel->Type == ChannelType::GUILD_TEXT || channel->IsThread());
m_menu_view_pins.set_sensitive(channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM || channel->IsThread());
}
diff --git a/src/windows/mainwindow.hpp b/src/windows/mainwindow.hpp
index df1c968..7afe782 100644
--- a/src/windows/mainwindow.hpp
+++ b/src/windows/mainwindow.hpp
@@ -74,6 +74,8 @@ protected:
Gtk::Stack m_content_stack;
+ Glib::RefPtr<Gtk::AccelGroup> m_accels;
+
Gtk::MenuBar m_menu_bar;
Gtk::MenuItem m_menu_discord;
Gtk::Menu m_menu_discord_sub;
@@ -95,5 +97,7 @@ protected:
Gtk::MenuItem m_menu_view_friends;
Gtk::MenuItem m_menu_view_pins;
Gtk::MenuItem m_menu_view_threads;
+ Gtk::MenuItem m_menu_view_mark_guild_as_read;
+ Gtk::MenuItem m_menu_view_mark_all_as_read;
void OnViewSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y);
};