diff options
-rw-r--r-- | components/chatwindow.cpp | 17 | ||||
-rw-r--r-- | components/chatwindow.hpp | 2 | ||||
-rw-r--r-- | components/typingindicator.cpp | 106 | ||||
-rw-r--r-- | components/typingindicator.hpp | 24 | ||||
-rw-r--r-- | css/main.css | 38 | ||||
-rw-r--r-- | discord/discord.cpp | 30 | ||||
-rw-r--r-- | discord/discord.hpp | 4 | ||||
-rw-r--r-- | discord/objects.cpp | 8 | ||||
-rw-r--r-- | discord/objects.hpp | 11 | ||||
-rw-r--r-- | windows/mainwindow.cpp | 10 |
10 files changed, 232 insertions, 18 deletions
diff --git a/components/chatwindow.cpp b/components/chatwindow.cpp index 2e688de..de1aa50 100644 --- a/components/chatwindow.cpp +++ b/components/chatwindow.cpp @@ -1,6 +1,7 @@ #include "chatwindow.hpp" #include "chatmessage.hpp" #include "../abaddon.hpp" +#include "typingindicator.hpp" ChatWindow::ChatWindow() { m_main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); @@ -8,13 +9,15 @@ ChatWindow::ChatWindow() { m_scroll = Gtk::manage(new Gtk::ScrolledWindow); m_input = Gtk::manage(new Gtk::TextView); m_input_scroll = Gtk::manage(new Gtk::ScrolledWindow); + m_typing_indicator = Gtk::manage(new TypingIndicator); + + m_typing_indicator->set_valign(Gtk::ALIGN_END); + m_typing_indicator->show(); m_main->get_style_context()->add_class("messages"); m_list->get_style_context()->add_class("messages"); m_input_scroll->get_style_context()->add_class("message-input"); - m_input->signal_key_press_event().connect(sigc::mem_fun(*this, &ChatWindow::on_key_press_event), false); - m_main->set_hexpand(true); m_main->set_vexpand(true); @@ -27,6 +30,7 @@ ChatWindow::ChatWindow() { m_scroll->set_can_focus(false); m_scroll->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_ALWAYS); + m_scroll->show(); m_list->signal_size_allocate().connect([this](Gtk::Allocation &) { if (m_should_scroll_to_bottom) @@ -38,16 +42,20 @@ ChatWindow::ChatWindow() { m_list->set_vexpand(true); m_list->set_focus_hadjustment(m_scroll->get_hadjustment()); m_list->set_focus_vadjustment(m_scroll->get_vadjustment()); + m_list->show(); m_input->set_hexpand(false); m_input->set_halign(Gtk::ALIGN_FILL); m_input->set_valign(Gtk::ALIGN_CENTER); m_input->set_wrap_mode(Gtk::WRAP_WORD_CHAR); + m_input->signal_key_press_event().connect(sigc::mem_fun(*this, &ChatWindow::on_key_press_event), false); + m_input->show(); m_input_scroll->set_propagate_natural_height(true); m_input_scroll->set_min_content_height(20); m_input_scroll->set_max_content_height(250); m_input_scroll->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + m_input_scroll->show(); m_completer.SetBuffer(m_input->get_buffer()); m_completer.SetGetChannelID([this]() -> auto { @@ -80,11 +88,15 @@ ChatWindow::ChatWindow() { return ret; }); + m_completer.show(); + m_input_scroll->add(*m_input); m_scroll->add(*m_list); m_main->add(*m_scroll); m_main->add(m_completer); m_main->add(*m_input_scroll); + m_main->add(*m_typing_indicator); + m_main->show(); } Gtk::Widget *ChatWindow::GetRoot() const { @@ -114,6 +126,7 @@ void ChatWindow::SetMessages(const std::set<Snowflake> &msgs) { void ChatWindow::SetActiveChannel(Snowflake id) { m_active_channel = id; + m_typing_indicator->SetActiveChannel(id); } void ChatWindow::AddNewMessage(Snowflake id) { diff --git a/components/chatwindow.hpp b/components/chatwindow.hpp index bac27c0..c1740c4 100644 --- a/components/chatwindow.hpp +++ b/components/chatwindow.hpp @@ -6,6 +6,7 @@ #include "chatmessage.hpp" #include "completer.hpp" +class TypingIndicator; class ChatWindow { public: ChatWindow(); @@ -47,6 +48,7 @@ protected: Gtk::ScrolledWindow *m_input_scroll; Completer m_completer; + TypingIndicator *m_typing_indicator; public: typedef sigc::signal<void, Snowflake, Snowflake> type_signal_action_message_delete; diff --git a/components/typingindicator.cpp b/components/typingindicator.cpp new file mode 100644 index 0000000..cc0ae7d --- /dev/null +++ b/components/typingindicator.cpp @@ -0,0 +1,106 @@ +#include <filesystem> +#include "typingindicator.hpp" +#include "../abaddon.hpp" +#include "../util.hpp" + +constexpr static const int MaxUsersInIndicator = 4; + +TypingIndicator::TypingIndicator() + : Gtk::Box(Gtk::ORIENTATION_HORIZONTAL) { + m_label.set_text(""); + m_label.set_ellipsize(Pango::ELLIPSIZE_END); + m_label.set_valign(Gtk::ALIGN_END); + m_img.set_margin_right(5); + get_style_context()->add_class("typing-indicator"); + + Abaddon::Get().GetDiscordClient().signal_typing_start().connect(sigc::mem_fun(*this, &TypingIndicator::OnUserTypingStart)); + Abaddon::Get().GetDiscordClient().signal_message_create().connect(sigc::mem_fun(*this, &TypingIndicator::OnMessageCreate)); + + add(m_img); + add(m_label); + m_label.show(); + + // try loading gif + if (!std::filesystem::exists("./res/typing_indicator.gif")) return; + auto gif_data = ReadWholeFile("./res/typing_indicator.gif"); + auto loader = Gdk::PixbufLoader::create(); + loader->signal_size_prepared().connect([&](int inw, int inh) { + int w, h; + GetImageDimensions(inw, inh, w, h, 20, 10); + loader->set_size(w, h); + }); + loader->write(gif_data.data(), gif_data.size()); + try { + loader->close(); + m_img.property_pixbuf_animation() = loader->get_animation(); + } catch (const std::exception &) {} +} + +void TypingIndicator::AddUser(Snowflake channel_id, const UserData &user, int timeout) { + auto current_connection_it = m_typers[channel_id].find(user.ID); + if (current_connection_it != m_typers.at(channel_id).end()) { + current_connection_it->second.disconnect(); + m_typers.at(channel_id).erase(current_connection_it); + } + + Snowflake user_id = user.ID; + auto cb = [this, user_id, channel_id]() -> bool { + m_typers.at(channel_id).erase(user_id); + ComputeTypingString(); + return false; + }; + m_typers[channel_id][user.ID] = Glib::signal_timeout().connect_seconds(cb, timeout); + ComputeTypingString(); +} + +void TypingIndicator::SetActiveChannel(Snowflake id) { + m_active_channel = id; + ComputeTypingString(); +} + +void TypingIndicator::OnUserTypingStart(Snowflake user_id, Snowflake channel_id) { + const auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = discord.GetUser(user_id); + if (!user.has_value()) return; + + AddUser(channel_id, *user, 10); +} + +void TypingIndicator::OnMessageCreate(Snowflake message_id) { + const auto msg = Abaddon::Get().GetDiscordClient().GetMessage(message_id); + if (!msg.has_value()) return; + m_typers[msg->ChannelID].erase(msg->Author.ID); + ComputeTypingString(); +} + +void TypingIndicator::SetTypingString(const Glib::ustring &str) { + m_label.set_text(str); + if (str == "") + m_img.hide(); + else if (m_img.property_pixbuf_animation().get_value()) + m_img.show(); +} + +void TypingIndicator::ComputeTypingString() { + const auto &discord = Abaddon::Get().GetDiscordClient(); + std::vector<UserData> typers; + for (const auto &[id, conn] : m_typers[m_active_channel]) { + const auto user = discord.GetUser(id); + if (user.has_value()) + typers.push_back(*user); + } + if (typers.size() == 0) { + SetTypingString(""); + } else if (typers.size() == 1) { + SetTypingString(typers[0].Username + " is typing..."); + } else if (typers.size() == 2) { + SetTypingString(typers[0].Username + " and " + typers[1].Username + " are typing..."); + } else if (typers.size() > 2 && typers.size() <= MaxUsersInIndicator) { + Glib::ustring str; + for (int i = 0; i < typers.size() - 1; i++) + str += typers[i].Username + ", "; + SetTypingString(str + "and " + typers[typers.size() - 1].Username + " are typing..."); + } else { // size() > MaxUsersInIndicator + SetTypingString("Several people are typing..."); + } +} diff --git a/components/typingindicator.hpp b/components/typingindicator.hpp new file mode 100644 index 0000000..d9633f4 --- /dev/null +++ b/components/typingindicator.hpp @@ -0,0 +1,24 @@ +#pragma once +#include <gtkmm.h> +#include <unordered_map> +#include "../discord/snowflake.hpp" +#include "../discord/user.hpp" + +class TypingIndicator : public Gtk::Box { +public: + TypingIndicator(); + void SetActiveChannel(Snowflake id); + +private: + void AddUser(Snowflake channel_id, const UserData &user, int timeout); + void OnUserTypingStart(Snowflake user_id, Snowflake channel_id); + void OnMessageCreate(Snowflake message_id); + void SetTypingString(const Glib::ustring &str); + void ComputeTypingString(); + + Gtk::Image m_img; + Gtk::Label m_label; + + Snowflake m_active_channel; + std::unordered_map<Snowflake, std::unordered_map<Snowflake, sigc::connection>> m_typers; // channel id -> [user id -> connection] +}; diff --git a/css/main.css b/css/main.css index 7e0a323..f4bfec2 100644 --- a/css/main.css +++ b/css/main.css @@ -1,3 +1,7 @@ +@define-color background_color #263238; +@define-color secondary_color #2c3e50; +@define-color text_color #cfd8dc; + .embed { background-color: #364759; color: #cbcbcb; @@ -16,7 +20,7 @@ } .channel-list { - background-color: #2c3e50; + background-color: @secondary_color; } .channel-row-label { @@ -24,7 +28,7 @@ } .channel-row-label, .channel-row-label text { - color: #cfd8dc; + color: @text_color; background: rgba(0, 0, 0, 0); } @@ -41,7 +45,7 @@ } .messages, .message-container { - background-color: #263238; + background-color: @background_color; } .messages { @@ -67,7 +71,7 @@ } .message-text text, .message-reply { - color: #cfd8dc; + color: @text_color; } .message-reply { @@ -83,12 +87,12 @@ } .message-text text { - background-color: #263238; + background-color: @background_color; } .message-input, .message-input textview, .message-input textview text { - background-color: #37474f; - color: #cfd8dc; + background-color: @secondary_color; + color: @text_color; } .message-input { @@ -96,11 +100,11 @@ } .members { - background-color: #263238; + background-color: @background_color; } .members-row-label { - color: #cfd8dc; + color: @text_color; padding: 5px; } @@ -132,32 +136,38 @@ } .reaction-count { - color: #cfd8dc; + color: @text_color; } .completer { - background-color: #2c3e50; + background-color: @secondary_color; padding: 5px; } .completer-entry { - color: #cfd8dc; + color: @text_color; } .completer-entry-image { margin-right: 6px; } +.typing-indicator { + margin-top: 10px; + margin-bottom: -7px; + color: @text_color; +} + paned separator { background: #37474f; } scrollbar { - background: #263238; + background: @background_color; border-left: 1px solid transparent; } menubar, menu { - background: #263238; + background: @background_color; color: #cccccc; } diff --git a/discord/discord.cpp b/discord/discord.cpp index e5c739e..008308f 100644 --- a/discord/discord.cpp +++ b/discord/discord.cpp @@ -590,6 +590,9 @@ void DiscordClient::HandleGatewayMessage(std::string str) { case GatewayEvent::CHANNEL_RECIPIENT_REMOVE: { HandleGatewayChannelRecipientRemove(m); } break; + case GatewayEvent::TYPING_START: { + HandleGatewayTypingStart(m); + } break; } } break; default: @@ -871,6 +874,28 @@ void DiscordClient::HandleGatewayChannelRecipientRemove(const GatewayMessage &ms m_store.SetChannel(cur->ID, *cur); } +void DiscordClient::HandleGatewayTypingStart(const GatewayMessage &msg) { + TypingStartObject data = msg.Data; + Snowflake guild_id; + if (data.GuildID.has_value()) { + guild_id = *data.GuildID; + } else { + auto chan = m_store.GetChannel(data.ChannelID); + if (chan.has_value() && chan->GuildID.has_value()) + guild_id = *chan->GuildID; + } + if (guild_id.IsValid() && data.Member.has_value()) { + auto cur = m_store.GetGuildMember(guild_id, data.UserID); + if (!cur.has_value()) { + AddUserToGuild(data.UserID, guild_id); + m_store.SetGuildMember(guild_id, data.UserID, *data.Member); + } + if (data.Member->User.has_value()) + m_store.SetUser(data.UserID, *data.Member->User); + } + m_signal_typing_start.emit(data.UserID, data.ChannelID); +} + void DiscordClient::HandleGatewayReconnect(const GatewayMessage &msg) { m_signal_disconnected.emit(true); inflateEnd(&m_zstream); @@ -1091,6 +1116,7 @@ void DiscordClient::LoadEventMap() { m_event_map["MESSAGE_REACTION_REMOVE"] = GatewayEvent::MESSAGE_REACTION_REMOVE; m_event_map["CHANNEL_RECIPIENT_ADD"] = GatewayEvent::CHANNEL_RECIPIENT_ADD; m_event_map["CHANNEL_RECIPIENT_REMOVE"] = GatewayEvent::CHANNEL_RECIPIENT_REMOVE; + m_event_map["TYPING_START"] = GatewayEvent::TYPING_START; } DiscordClient::type_signal_gateway_ready DiscordClient::signal_gateway_ready() { @@ -1164,3 +1190,7 @@ DiscordClient::type_signal_reaction_add DiscordClient::signal_reaction_add() { DiscordClient::type_signal_reaction_remove DiscordClient::signal_reaction_remove() { return m_signal_reaction_remove; } + +DiscordClient::type_signal_typing_start DiscordClient::signal_typing_start() { + return m_signal_typing_start; +} diff --git a/discord/discord.hpp b/discord/discord.hpp index 86a75cc..c2b2bef 100644 --- a/discord/discord.hpp +++ b/discord/discord.hpp @@ -146,6 +146,7 @@ private: void HandleGatewayMessageReactionRemove(const GatewayMessage &msg); void HandleGatewayChannelRecipientAdd(const GatewayMessage &msg); void HandleGatewayChannelRecipientRemove(const GatewayMessage &msg); + void HandleGatewayTypingStart(const GatewayMessage &msg); void HandleGatewayReconnect(const GatewayMessage &msg); void HeartbeatThread(); void SendIdentify(); @@ -212,6 +213,7 @@ public: typedef sigc::signal<void, Snowflake> type_signal_role_delete; typedef sigc::signal<void, Snowflake, Glib::ustring> type_signal_reaction_add; typedef sigc::signal<void, Snowflake, Glib::ustring> type_signal_reaction_remove; + typedef sigc::signal<void, Snowflake, Snowflake> type_signal_typing_start; // user id, channel id typedef sigc::signal<void, bool> type_signal_disconnected; // bool true if reconnecting typedef sigc::signal<void> type_signal_connected; @@ -231,6 +233,7 @@ public: type_signal_role_delete signal_role_delete(); type_signal_reaction_add signal_reaction_add(); type_signal_reaction_remove signal_reaction_remove(); + type_signal_typing_start signal_typing_start(); type_signal_disconnected signal_disconnected(); type_signal_connected signal_connected(); @@ -251,6 +254,7 @@ protected: type_signal_role_delete m_signal_role_delete; type_signal_reaction_add m_signal_reaction_add; type_signal_reaction_remove m_signal_reaction_remove; + type_signal_typing_start m_signal_typing_start; type_signal_disconnected m_signal_disconnected; type_signal_connected m_signal_connected; }; diff --git a/discord/objects.cpp b/discord/objects.cpp index 2c1f5a1..cf0f504 100644 --- a/discord/objects.cpp +++ b/discord/objects.cpp @@ -239,3 +239,11 @@ void from_json(const nlohmann::json &j, ChannelRecipientRemove &m) { JS_D("user", m.User); JS_D("channel_id", m.ChannelID); } + +void from_json(const nlohmann::json &j, TypingStartObject &m) { + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); + JS_D("user_id", m.UserID); + JS_D("timestamp", m.Timestamp); + JS_O("member", m.Member); +} diff --git a/discord/objects.hpp b/discord/objects.hpp index 37b2dc2..9c5e648 100644 --- a/discord/objects.hpp +++ b/discord/objects.hpp @@ -53,6 +53,7 @@ enum class GatewayEvent : int { MESSAGE_REACTION_REMOVE, CHANNEL_RECIPIENT_ADD, CHANNEL_RECIPIENT_REMOVE, + TYPING_START, }; struct GatewayMessage { @@ -334,3 +335,13 @@ struct ChannelRecipientRemove { friend void from_json(const nlohmann::json &j, ChannelRecipientRemove &m); }; + +struct TypingStartObject { + Snowflake ChannelID; + std::optional<Snowflake> GuildID; + Snowflake UserID; + uint64_t Timestamp; + std::optional<GuildMember> Member; + + friend void from_json(const nlohmann::json &j, TypingStartObject &m); +}; diff --git a/windows/mainwindow.cpp b/windows/mainwindow.cpp index 30a79fa..883801f 100644 --- a/windows/mainwindow.cpp +++ b/windows/mainwindow.cpp @@ -35,6 +35,7 @@ MainWindow::MainWindow() m_menu_bar.append(m_menu_file); m_menu_bar.append(m_menu_discord); + m_menu_bar.show_all(); m_menu_discord_connect.signal_activate().connect([this] { m_signal_action_connect.emit(); @@ -66,9 +67,11 @@ MainWindow::MainWindow() m_content_box.set_hexpand(true); m_content_box.set_vexpand(true); + m_content_box.show(); m_main_box.add(m_menu_bar); m_main_box.add(m_content_box); + m_main_box.show(); auto *channel_list = m_channel_list.GetRoot(); auto *member_list = m_members.GetRoot(); @@ -84,17 +87,21 @@ MainWindow::MainWindow() chat->set_vexpand(true); chat->set_hexpand(true); + chat->show(); channel_list->set_vexpand(true); channel_list->set_size_request(-1, -1); + channel_list->show(); member_list->set_vexpand(true); + member_list->show(); m_chan_chat_paned.pack1(*channel_list); m_chan_chat_paned.pack2(m_chat_members_paned); m_chan_chat_paned.child_property_shrink(*channel_list) = false; m_chan_chat_paned.child_property_resize(*channel_list) = false; m_chan_chat_paned.set_position(200); + m_chan_chat_paned.show(); m_content_box.add(m_chan_chat_paned); m_chat_members_paned.pack1(*chat); @@ -104,10 +111,9 @@ MainWindow::MainWindow() int w, h; get_default_size(w, h); // :s m_chat_members_paned.set_position(w - m_chan_chat_paned.get_position() - 150); + m_chat_members_paned.show(); add(m_main_box); - - show_all_children(); } void MainWindow::UpdateComponents() { |