rudimentary chat

This commit is contained in:
ouwou 2020-08-20 03:19:16 -04:00
parent 4b903bbd3e
commit a201d5905a
14 changed files with 426 additions and 7 deletions

View File

@ -143,6 +143,7 @@
<ItemGroup>
<ClCompile Include="abaddon.cpp" />
<ClCompile Include="components\channels.cpp" />
<ClCompile Include="components\chatwindow.cpp" />
<ClCompile Include="dialogs\token.cpp" />
<ClCompile Include="discord\discord.cpp" />
<ClCompile Include="discord\http.cpp" />
@ -153,6 +154,7 @@
<ItemGroup>
<ClInclude Include="components\channels.hpp" />
<ClInclude Include="abaddon.hpp" />
<ClInclude Include="components\chatwindow.hpp" />
<ClInclude Include="dialogs\token.hpp" />
<ClInclude Include="discord\discord.hpp" />
<ClInclude Include="discord\http.hpp" />

View File

@ -39,6 +39,9 @@
<ClCompile Include="discord\http.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="components\chatwindow.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="windows\mainwindow.hpp">
@ -65,5 +68,8 @@
<ClInclude Include="discord\http.hpp">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="components\chatwindow.hpp">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
</Project>

View File

@ -107,7 +107,7 @@ void Abaddon::ActionMoveGuildUp(Snowflake id) {
std::vector<Snowflake> &pos = d.GuildPositions;
if (pos.size() == 0) {
auto x = m_discord.GetUserSortedGuilds();
for (const auto& pair : x)
for (const auto &pair : x)
pos.push_back(pair.first);
}
@ -136,7 +136,20 @@ void Abaddon::ActionMoveGuildDown(Snowflake id) {
m_discord.UpdateSettingsGuildPositions(pos);
}
void Abaddon::ActionListChannelItemClick(Snowflake id) {
m_main_window->UpdateChatActiveChannel(id);
if (m_channels_requested.find(id) == m_channels_requested.end()) {
m_discord.FetchMessagesInChannel(id, [this, id](const std::vector<MessageData> &msgs) {
m_channels_requested.insert(id);
m_main_window->UpdateChatWindowContents();
});
} else {
m_main_window->UpdateChatWindowContents();
}
}
int main(int argc, char **argv) {
Gtk::Main::init_gtkmm_internals(); // why???
Abaddon abaddon;
return abaddon.StartGTK();
}

View File

@ -2,6 +2,7 @@
#include <memory>
#include <mutex>
#include <string>
#include <unordered_set>
#include "discord/discord.hpp"
#include "windows/mainwindow.hpp"
#include "settings.hpp"
@ -22,6 +23,7 @@ public:
void ActionSetToken();
void ActionMoveGuildUp(Snowflake id);
void ActionMoveGuildDown(Snowflake id);
void ActionListChannelItemClick(Snowflake id);
std::string GetDiscordToken() const;
bool IsDiscordActive() const;
@ -31,10 +33,12 @@ public:
void DiscordNotifyChannelListFullRefresh();
private:
DiscordClient m_discord;
std::string m_discord_token;
std::unordered_set<Snowflake> m_channels_requested;
mutable std::mutex m_mutex;
Glib::RefPtr<Gtk::Application> m_gtk_app;
DiscordClient m_discord;
SettingsManager m_settings;
std::unique_ptr<MainWindow> m_main_window; // wah wah cant create a gtkstylecontext fuck you
};

View File

@ -52,6 +52,10 @@ void ChannelList::on_row_activated(Gtk::ListBoxRow *row) {
bool new_collapsed = !info.IsUserCollapsed;
info.IsUserCollapsed = new_collapsed;
if (info.Type == ListItemInfo::ListItemType::Channel) {
m_abaddon->ActionListChannelItemClick(info.ID);
}
if (info.CatArrow != nullptr)
info.CatArrow->set(new_collapsed ? Gtk::ARROW_RIGHT : Gtk::ARROW_DOWN, Gtk::SHADOW_NONE);
@ -143,6 +147,7 @@ void ChannelList::SetListingFromGuildsInternal() {
info.ID = id;
info.IsUserCollapsed = false;
info.IsHidden = false;
info.Type = ListItemInfo::ListItemType::Channel;
m_infos[channel_row] = std::move(info);
return channel_row;
@ -168,6 +173,7 @@ void ChannelList::SetListingFromGuildsInternal() {
info.IsUserCollapsed = false;
info.IsHidden = true;
info.CatArrow = category_arrow;
info.Type = ListItemInfo::ListItemType::Category;
if (cat_to_channels.find(id) != cat_to_channels.end()) {
std::map<int, const ChannelData *> sorted_channels;
@ -206,6 +212,7 @@ void ChannelList::SetListingFromGuildsInternal() {
info.IsUserCollapsed = true;
info.IsHidden = false;
info.GuildIndex = m_guild_count++;
info.Type = ListItemInfo::ListItemType::Guild;
if (orphan_channels.find(id) != orphan_channels.end()) {
std::map<int, const ChannelData *> sorted_orphans;

View File

@ -21,11 +21,17 @@ protected:
Gtk::ScrolledWindow *m_main;
struct ListItemInfo {
enum ListItemType {
Guild,
Category,
Channel,
};
int GuildIndex;
Snowflake ID;
std::unordered_set<Gtk::ListBoxRow *> Children;
bool IsUserCollapsed;
bool IsHidden;
ListItemType Type;
// for categories
Gtk::Arrow *CatArrow = nullptr;
};

164
components/chatwindow.cpp Normal file
View File

@ -0,0 +1,164 @@
#include "chatwindow.hpp"
#include <map>
ChatWindow::ChatWindow() {
m_update_dispatcher.connect(sigc::mem_fun(*this, &ChatWindow::SetMessagesInternal));
m_main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL));
m_listbox = Gtk::manage(new Gtk::ListBox);
m_viewport = Gtk::manage(new Gtk::Viewport(Gtk::Adjustment::create(0, 0, 0, 0, 0, 0), Gtk::Adjustment::create(0, 0, 0, 0, 0, 0)));
m_scroll = Gtk::manage(new Gtk::ScrolledWindow);
m_input = Gtk::manage(new Gtk::TextView);
m_entry_scroll = Gtk::manage(new Gtk::ScrolledWindow);
m_main->set_hexpand(true);
m_main->set_vexpand(true);
m_main->show();
m_scroll->set_can_focus(false);
m_scroll->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_ALWAYS);
m_scroll->show();
m_listbox->signal_size_allocate().connect([this](Gtk::Allocation &) {
ScrollToBottom();
});
m_listbox->set_selection_mode(Gtk::SELECTION_NONE);
m_listbox->set_hexpand(true);
m_listbox->set_vexpand(true);
m_listbox->set_focus_hadjustment(m_scroll->get_hadjustment());
m_listbox->set_focus_vadjustment(m_scroll->get_vadjustment());
m_listbox->show();
m_viewport->set_can_focus(false);
m_viewport->set_valign(Gtk::ALIGN_END);
m_viewport->set_vscroll_policy(Gtk::SCROLL_NATURAL);
m_viewport->set_shadow_type(Gtk::SHADOW_NONE);
m_viewport->show();
m_input->set_hexpand(true);
m_input->set_wrap_mode(Gtk::WRAP_WORD_CHAR);
m_input->show();
m_entry_scroll->set_max_content_height(150);
m_entry_scroll->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC);
m_entry_scroll->add(*m_input);
m_entry_scroll->show();
m_viewport->add(*m_listbox);
m_scroll->add(*m_viewport);
m_main->add(*m_scroll);
m_main->add(*m_entry_scroll);
}
Gtk::Widget *ChatWindow::GetRoot() const {
return m_main;
}
void ChatWindow::SetActiveChannel(Snowflake id) {
m_active_channel = id;
}
Snowflake ChatWindow::GetActiveChannel() const {
return m_active_channel;
}
Gtk::ListBoxRow *ChatWindow::CreateChatEntryComponentText(const MessageData *data) {
auto *row = Gtk::manage(new Gtk::ListBoxRow);
auto *main_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL));
auto *sub_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL));
auto *meta_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL));
auto *author = Gtk::manage(new Gtk::Label);
auto *timestamp = Gtk::manage(new Gtk::Label);
auto *text = Gtk::manage(new Gtk::TextView);
text->set_can_focus(false);
text->set_editable(false);
text->set_wrap_mode(Gtk::WRAP_WORD_CHAR);
text->set_halign(Gtk::ALIGN_FILL);
text->set_hexpand(true);
text->get_buffer()->set_text(data->Content);
text->show();
author->set_markup("<span weight=\"bold\">" + Glib::Markup::escape_text(data->Author.Username) + "</span>");
author->set_single_line_mode(true);
author->set_line_wrap(false);
author->set_ellipsize(Pango::ELLIPSIZE_END);
author->set_xalign(0.f);
author->show();
timestamp->set_text(data->Timestamp);
timestamp->set_opacity(0.5);
timestamp->set_single_line_mode(true);
timestamp->set_margin_start(12);
timestamp->show();
main_box->set_hexpand(true);
main_box->set_vexpand(true);
main_box->show();
meta_box->show();
sub_box->show();
meta_box->add(*author);
meta_box->add(*timestamp);
sub_box->add(*meta_box);
sub_box->add(*text);
main_box->add(*sub_box);
row->add(*main_box);
row->set_margin_bottom(8);
row->show();
return row;
}
Gtk::ListBoxRow *ChatWindow::CreateChatEntryComponent(const MessageData *data) {
if (data->Type == MessageType::DEFAULT && data->Content.size() > 0)
return CreateChatEntryComponentText(data);
return nullptr;
}
void ChatWindow::SetMessages(std::unordered_set<const MessageData *> msgs) {
std::scoped_lock<std::mutex> guard(m_update_mutex);
m_update_queue.push(msgs);
m_update_dispatcher.emit();
}
void ChatWindow::ScrollToBottom() {
auto x = m_scroll->get_vadjustment();
x->set_value(x->get_upper());
}
void ChatWindow::SetMessagesInternal() {
auto children = m_listbox->get_children();
auto it = children.begin();
while (it != children.end()) {
delete *it;
it++;
}
std::unordered_set<const MessageData *> *msgs;
{
std::scoped_lock<std::mutex> guard(m_update_mutex);
msgs = &m_update_queue.front();
}
// sort
std::map<Snowflake, const MessageData *> sorted_messages;
for (const auto msg : *msgs)
sorted_messages[msg->ID] = msg;
for (const auto &[id, msg] : sorted_messages) {
auto *row = CreateChatEntryComponent(msg);
if (row != nullptr)
m_listbox->add(*row);
}
{
std::scoped_lock<std::mutex> guard(m_update_mutex);
m_update_queue.pop();
}
}

33
components/chatwindow.hpp Normal file
View File

@ -0,0 +1,33 @@
#pragma once
#include <gtkmm.h>
#include <queue>
#include <mutex>
#include "../discord/discord.hpp"
class ChatWindow {
public:
ChatWindow();
Gtk::Widget *GetRoot() const;
void SetActiveChannel(Snowflake id);
Snowflake GetActiveChannel() const;
void SetMessages(std::unordered_set<const MessageData *> msgs);
protected:
void ScrollToBottom();
void SetMessagesInternal();
Gtk::ListBoxRow *CreateChatEntryComponentText(const MessageData *data);
Gtk::ListBoxRow *CreateChatEntryComponent(const MessageData *data);
Glib::Dispatcher m_update_dispatcher;
std::queue<std::unordered_set<const MessageData *>> m_update_queue;
std::mutex m_update_mutex;
Snowflake m_active_channel;
Gtk::Box *m_main;
Gtk::ListBox *m_listbox;
Gtk::Viewport *m_viewport;
Gtk::ScrolledWindow *m_scroll;
Gtk::ScrolledWindow *m_entry_scroll;
Gtk::TextView *m_input;
};

View File

@ -84,6 +84,13 @@ std::vector<std::pair<Snowflake, GuildData>> DiscordClient::GetUserSortedGuilds(
return sorted_guilds;
}
std::unordered_set<const MessageData *> DiscordClient::GetMessagesForChannel(Snowflake id) const {
auto it = m_chan_to_message_map.find(id);
if (it == m_chan_to_message_map.end())
return std::unordered_set<const MessageData *>();
return it->second;
}
void DiscordClient::UpdateSettingsGuildPositions(const std::vector<Snowflake> &pos) {
assert(pos.size() == m_guilds.size());
nlohmann::json body;
@ -94,6 +101,18 @@ void DiscordClient::UpdateSettingsGuildPositions(const std::vector<Snowflake> &p
});
}
void DiscordClient::FetchMessagesInChannel(Snowflake id, std::function<void(const std::vector<MessageData> &)> cb) {
std::string path = "/channels/" + std::to_string(id) + "/messages?limit=50";
m_http.MakeGET(path, [this, id, cb](cpr::Response r) {
std::vector<MessageData> msgs;
nlohmann::json::parse(r.text).get_to(msgs);
for (const auto &msg : msgs)
StoreMessage(msg.ID, msg);
cb(msgs);
});
}
void DiscordClient::UpdateToken(std::string token) {
m_token = token;
m_http.SetAuth(token);
@ -153,6 +172,15 @@ void DiscordClient::StoreGuild(Snowflake id, const GuildData &g) {
m_guilds[id] = g;
}
void DiscordClient::StoreMessage(Snowflake id, const MessageData &m) {
assert(id.IsValid());
m_messages[id] = m;
auto it = m_chan_to_message_map.find(m.ChannelID);
if (it == m_chan_to_message_map.end())
m_chan_to_message_map[m.ChannelID] = decltype(m_chan_to_message_map)::mapped_type();
m_chan_to_message_map[m.ChannelID].insert(&m_messages[id]);
}
void DiscordClient::HeartbeatThread() {
while (m_client_connected) {
if (!m_heartbeat_acked) {
@ -308,6 +336,33 @@ void from_json(const nlohmann::json &j, ChannelData &m) {
JS_ON("last_pin_timestamp", m.LastPinTimestamp);
}
void from_json(const nlohmann::json &j, MessageData &m) {
JS_D("id", m.ID);
JS_D("channel_id", m.ChannelID);
JS_O("guild_id", m.GuildID);
JS_D("author", m.Author);
// JS_O("member", m.Member);
JS_D("content", m.Content);
JS_D("timestamp", m.Timestamp);
JS_N("edited_timestamp", m.EditedTimestamp);
JS_D("tts", m.IsTTS);
JS_D("mention_everyone", m.DoesMentionEveryone);
JS_D("mentions", m.Mentions);
// JS_D("mention_roles", m.MentionRoles);
// JS_O("mention_channels", m.MentionChannels);
// JS_D("attachments", m.Attachments);
// JS_D("embeds", m.Embeds);
// JS_O("reactions", m.Reactions);
JS_O("nonce", m.Nonce);
JS_D("pinned", m.IsPinned);
JS_O("webhook_id", m.WebhookID);
JS_D("type", m.Type);
// JS_O("activity", m.Activity);
// JS_O("application", m.Application);
// JS_O("message_reference", m.MessageReference);
JS_O("flags", m.Flags);
}
void from_json(const nlohmann::json &j, ReadyEventData &m) {
JS_D("v", m.GatewayVersion);
JS_D("user", m.User);
@ -400,7 +455,7 @@ void from_json(const nlohmann::json &j, Snowflake &s) {
s.m_num = std::stoull(tmp);
}
void to_json(nlohmann::json& j, const Snowflake& s) {
void to_json(nlohmann::json &j, const Snowflake &s) {
j = std::to_string(s);
}

View File

@ -4,6 +4,7 @@
#include <nlohmann/json.hpp>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <mutex>
struct Snowflake {
@ -33,6 +34,7 @@ struct Snowflake {
private:
friend struct std::hash<Snowflake>;
friend struct std::less<Snowflake>;
unsigned long long m_num;
};
@ -43,6 +45,13 @@ struct hash<Snowflake> {
return k.m_num;
}
};
template<>
struct less<Snowflake> {
bool operator()(const Snowflake &l, const Snowflake &r) const {
return l.m_num < r.m_num;
}
};
} // namespace std
enum class GatewayOp : int {
@ -222,6 +231,62 @@ struct UserSettingsData {
friend void from_json(const nlohmann::json &j, UserSettingsData &m);
};
enum class MessageType {
DEFAULT = 0,
RECIPIENT_ADD = 1,
RECIPIENT_REMOVE = 2,
CALL = 3,
CHANNEL_NaME_CHANGE = 4,
CHANNEL_ICON_CHANGE = 5,
CHANNEL_PINNED_MESSAGE = 6,
GUILD_MEMBER_JOIN = 6,
USER_PREMIUM_GUILD_SUBSCRIPTION = 7,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 8,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 9,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 10,
CHANNEL_FOLLOW_ADD = 12,
GUILD_DISCOVERY_DISQUALIFIED = 13,
GUILD_DISCOVERY_REQUALIFIED = 14,
};
enum class MessageFlags {
NONE = 0,
CROSSPOSTED = 1 << 0,
IS_CROSSPOST = 1 << 1,
SUPPRESS_EMBEDS = 1 << 2,
SOURCE_MESSAGE_DELETE = 1 << 3,
URGENT = 1 << 4,
};
struct MessageData {
Snowflake ID; //
Snowflake ChannelID; //
Snowflake GuildID; // opt
UserData Author; //
// GuildMemberData Member; // opt
std::string Content; //
std::string Timestamp; //
std::string EditedTimestamp; // null
bool IsTTS; //
bool DoesMentionEveryone; //
std::vector<UserData> Mentions; //
// std::vector<RoleData> MentionRoles; //
// std::vector<ChannelMentionData> MentionChannels; // opt
// std::vector<AttachmentData> Attachments; //
// std::vector<EmbedData> Embeds; //
// std::vector<ReactionData> Reactions; // opt
std::string Nonce; // opt
bool IsPinned; //
Snowflake WebhookID; // opt
MessageType Type; //
// MessageActivityData Activity; // opt
// MessageApplicationData Application; // opt
// MessageReferenceData MessageReference; // opt
MessageFlags Flags = MessageFlags::NONE; // opt
friend void from_json(const nlohmann::json &j, MessageData &m);
};
struct ReadyEventData {
int GatewayVersion; //
UserData User; //
@ -307,10 +372,15 @@ public:
bool IsStarted() const;
using Guilds_t = std::unordered_map<Snowflake, GuildData>;
using Messages_t = std::unordered_map<Snowflake, MessageData>;
const Guilds_t &GetGuilds() const;
const UserSettingsData &GetUserSettings() const;
std::vector<std::pair<Snowflake, GuildData>> GetUserSortedGuilds() const;
std::unordered_set<const MessageData *> GetMessagesForChannel(Snowflake id) const;
void UpdateSettingsGuildPositions(const std::vector<Snowflake> &pos);
void FetchMessagesInChannel(Snowflake id, std::function<void(const std::vector<MessageData> &)> cb);
void UpdateToken(std::string token);
@ -330,6 +400,10 @@ private:
void StoreGuild(Snowflake id, const GuildData &g);
Guilds_t m_guilds;
void StoreMessage(Snowflake id, const MessageData &m);
Messages_t m_messages;
std::unordered_map<Snowflake, std::unordered_set<const MessageData *>> m_chan_to_message_map;
UserSettingsData m_user_settings;
Websocket m_websocket;

View File

@ -36,6 +36,37 @@ void HTTPClient::MakePOST(std::string path, std::string payload, std::function<v
{ "Content-Type", "application/json" },
};
auto body = cpr::Body { payload };
#ifdef USE_LOCAL_PROXY
m_futures.push_back(cpr::GetCallback(
std::bind(&HTTPClient::OnResponse, this, std::placeholders::_1, cb),
url, headers, body,
cpr::Proxies { { "http", "127.0.0.1:8888" }, { "https", "127.0.0.1:8888" } },
cpr::VerifySsl { false }));
#else
m_futures.push_back(cpr::PatchCallback(
std::bind(&HTTPClient::OnResponse, this, std::placeholders::_1, cb),
url, headers, body));
#endif
}
void HTTPClient::MakeGET(std::string path, std::function<void(cpr::Response r)> cb) {
printf("POST %s\n", path.c_str());
auto url = cpr::Url { m_api_base + path };
auto headers = cpr::Header {
{ "Authorization", m_authorization },
{ "Content-Type", "application/json" },
};
#ifdef USE_LOCAL_PROXY
m_futures.push_back(cpr::GetCallback(
std::bind(&HTTPClient::OnResponse, this, std::placeholders::_1, cb),
url, headers,
cpr::Proxies { { "http", "127.0.0.1:8888" }, { "https", "127.0.0.1:8888" } },
cpr::VerifySsl { false }));
#else
m_futures.push_back(cpr::GetCallback(
std::bind(&HTTPClient::OnResponse, this, std::placeholders::_1, cb),
url, headers));
#endif
}
void HTTPClient::CleanupFutures() {

View File

@ -19,6 +19,7 @@ public:
HTTPClient(std::string api_base);
void SetAuth(std::string auth);
void MakeGET(std::string path, std::function<void(cpr::Response r)> cb);
void MakePATCH(std::string path, std::string payload, std::function<void(cpr::Response r)> cb);
void MakePOST(std::string path, std::string payload, std::function<void(cpr::Response r)> cb);

View File

@ -3,7 +3,8 @@
MainWindow::MainWindow()
: m_main_box(Gtk::ORIENTATION_VERTICAL)
, m_content_box(Gtk::ORIENTATION_HORIZONTAL) {
, m_content_box(Gtk::ORIENTATION_HORIZONTAL)
, m_chan_chat_paned(Gtk::ORIENTATION_HORIZONTAL) {
set_default_size(800, 600);
m_menu_discord.set_label("Discord");
@ -20,7 +21,7 @@ MainWindow::MainWindow()
m_menu_bar.append(m_menu_discord);
m_menu_discord_connect.signal_activate().connect([&] {
m_abaddon->ActionConnect(); // this feels maybe not too smart
m_abaddon->ActionConnect();
});
m_menu_discord_disconnect.signal_activate().connect([&] {
@ -38,9 +39,17 @@ MainWindow::MainWindow()
m_main_box.add(m_content_box);
auto *channel_list = m_channel_list.GetRoot();
channel_list->set_hexpand(true);
channel_list->set_vexpand(true);
m_content_box.add(*channel_list);
channel_list->set_size_request(-1, -1);
m_chan_chat_paned.pack1(*channel_list);
auto *chat = m_chat.GetRoot();
chat->set_vexpand(true);
chat->set_hexpand(true);
m_chan_chat_paned.pack2(*chat);
m_chan_chat_paned.set_position(200);
m_chan_chat_paned.child_property_shrink(*channel_list) = true;
m_chan_chat_paned.child_property_resize(*channel_list) = true;
m_content_box.add(m_chan_chat_paned);
add(m_main_box);
@ -70,6 +79,15 @@ void MainWindow::UpdateChannelListing() {
m_channel_list.SetListingFromGuilds(discord.GetGuilds());
}
void MainWindow::UpdateChatWindowContents() {
auto &discord = m_abaddon->GetDiscordClient();
m_chat.SetMessages(discord.GetMessagesForChannel(m_chat.GetActiveChannel()));
}
void MainWindow::UpdateChatActiveChannel(Snowflake id) {
m_chat.SetActiveChannel(id);
}
void MainWindow::SetAbaddon(Abaddon *ptr) {
m_abaddon = ptr;
m_channel_list.SetAbaddon(ptr);

View File

@ -1,5 +1,6 @@
#pragma once
#include "../components/channels.hpp"
#include "../components/chatwindow.hpp"
#include <gtkmm.h>
class Abaddon;
@ -10,12 +11,16 @@ public:
void UpdateComponents();
void UpdateChannelListing();
void UpdateChatWindowContents();
void UpdateChatActiveChannel(Snowflake id);
protected:
Gtk::Box m_main_box;
Gtk::Box m_content_box;
Gtk::Paned m_chan_chat_paned;
ChannelList m_channel_list;
ChatWindow m_chat;
Gtk::MenuBar m_menu_bar;
Gtk::MenuItem m_menu_discord;