add typing indicator with optional res/typing_indicator.gif

This commit is contained in:
ouwou
2021-01-11 18:27:46 -05:00
parent def598941a
commit e8cbb9d3d1
10 changed files with 232 additions and 18 deletions

View File

@@ -1,6 +1,7 @@
#include "chatwindow.hpp" #include "chatwindow.hpp"
#include "chatmessage.hpp" #include "chatmessage.hpp"
#include "../abaddon.hpp" #include "../abaddon.hpp"
#include "typingindicator.hpp"
ChatWindow::ChatWindow() { ChatWindow::ChatWindow() {
m_main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); m_main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL));
@@ -8,13 +9,15 @@ ChatWindow::ChatWindow() {
m_scroll = Gtk::manage(new Gtk::ScrolledWindow); m_scroll = Gtk::manage(new Gtk::ScrolledWindow);
m_input = Gtk::manage(new Gtk::TextView); m_input = Gtk::manage(new Gtk::TextView);
m_input_scroll = Gtk::manage(new Gtk::ScrolledWindow); 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_main->get_style_context()->add_class("messages");
m_list->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_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_hexpand(true);
m_main->set_vexpand(true); m_main->set_vexpand(true);
@@ -27,6 +30,7 @@ ChatWindow::ChatWindow() {
m_scroll->set_can_focus(false); m_scroll->set_can_focus(false);
m_scroll->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_ALWAYS); m_scroll->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_ALWAYS);
m_scroll->show();
m_list->signal_size_allocate().connect([this](Gtk::Allocation &) { m_list->signal_size_allocate().connect([this](Gtk::Allocation &) {
if (m_should_scroll_to_bottom) if (m_should_scroll_to_bottom)
@@ -38,16 +42,20 @@ ChatWindow::ChatWindow() {
m_list->set_vexpand(true); m_list->set_vexpand(true);
m_list->set_focus_hadjustment(m_scroll->get_hadjustment()); m_list->set_focus_hadjustment(m_scroll->get_hadjustment());
m_list->set_focus_vadjustment(m_scroll->get_vadjustment()); m_list->set_focus_vadjustment(m_scroll->get_vadjustment());
m_list->show();
m_input->set_hexpand(false); m_input->set_hexpand(false);
m_input->set_halign(Gtk::ALIGN_FILL); m_input->set_halign(Gtk::ALIGN_FILL);
m_input->set_valign(Gtk::ALIGN_CENTER); m_input->set_valign(Gtk::ALIGN_CENTER);
m_input->set_wrap_mode(Gtk::WRAP_WORD_CHAR); 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_propagate_natural_height(true);
m_input_scroll->set_min_content_height(20); m_input_scroll->set_min_content_height(20);
m_input_scroll->set_max_content_height(250); m_input_scroll->set_max_content_height(250);
m_input_scroll->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); m_input_scroll->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC);
m_input_scroll->show();
m_completer.SetBuffer(m_input->get_buffer()); m_completer.SetBuffer(m_input->get_buffer());
m_completer.SetGetChannelID([this]() -> auto { m_completer.SetGetChannelID([this]() -> auto {
@@ -80,11 +88,15 @@ ChatWindow::ChatWindow() {
return ret; return ret;
}); });
m_completer.show();
m_input_scroll->add(*m_input); m_input_scroll->add(*m_input);
m_scroll->add(*m_list); m_scroll->add(*m_list);
m_main->add(*m_scroll); m_main->add(*m_scroll);
m_main->add(m_completer); m_main->add(m_completer);
m_main->add(*m_input_scroll); m_main->add(*m_input_scroll);
m_main->add(*m_typing_indicator);
m_main->show();
} }
Gtk::Widget *ChatWindow::GetRoot() const { Gtk::Widget *ChatWindow::GetRoot() const {
@@ -114,6 +126,7 @@ void ChatWindow::SetMessages(const std::set<Snowflake> &msgs) {
void ChatWindow::SetActiveChannel(Snowflake id) { void ChatWindow::SetActiveChannel(Snowflake id) {
m_active_channel = id; m_active_channel = id;
m_typing_indicator->SetActiveChannel(id);
} }
void ChatWindow::AddNewMessage(Snowflake id) { void ChatWindow::AddNewMessage(Snowflake id) {

View File

@@ -6,6 +6,7 @@
#include "chatmessage.hpp" #include "chatmessage.hpp"
#include "completer.hpp" #include "completer.hpp"
class TypingIndicator;
class ChatWindow { class ChatWindow {
public: public:
ChatWindow(); ChatWindow();
@@ -47,6 +48,7 @@ protected:
Gtk::ScrolledWindow *m_input_scroll; Gtk::ScrolledWindow *m_input_scroll;
Completer m_completer; Completer m_completer;
TypingIndicator *m_typing_indicator;
public: public:
typedef sigc::signal<void, Snowflake, Snowflake> type_signal_action_message_delete; typedef sigc::signal<void, Snowflake, Snowflake> type_signal_action_message_delete;

View File

@@ -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...");
}
}

View File

@@ -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]
};

View File

@@ -1,3 +1,7 @@
@define-color background_color #263238;
@define-color secondary_color #2c3e50;
@define-color text_color #cfd8dc;
.embed { .embed {
background-color: #364759; background-color: #364759;
color: #cbcbcb; color: #cbcbcb;
@@ -16,7 +20,7 @@
} }
.channel-list { .channel-list {
background-color: #2c3e50; background-color: @secondary_color;
} }
.channel-row-label { .channel-row-label {
@@ -24,7 +28,7 @@
} }
.channel-row-label, .channel-row-label text { .channel-row-label, .channel-row-label text {
color: #cfd8dc; color: @text_color;
background: rgba(0, 0, 0, 0); background: rgba(0, 0, 0, 0);
} }
@@ -41,7 +45,7 @@
} }
.messages, .message-container { .messages, .message-container {
background-color: #263238; background-color: @background_color;
} }
.messages { .messages {
@@ -67,7 +71,7 @@
} }
.message-text text, .message-reply { .message-text text, .message-reply {
color: #cfd8dc; color: @text_color;
} }
.message-reply { .message-reply {
@@ -83,12 +87,12 @@
} }
.message-text text { .message-text text {
background-color: #263238; background-color: @background_color;
} }
.message-input, .message-input textview, .message-input textview text { .message-input, .message-input textview, .message-input textview text {
background-color: #37474f; background-color: @secondary_color;
color: #cfd8dc; color: @text_color;
} }
.message-input { .message-input {
@@ -96,11 +100,11 @@
} }
.members { .members {
background-color: #263238; background-color: @background_color;
} }
.members-row-label { .members-row-label {
color: #cfd8dc; color: @text_color;
padding: 5px; padding: 5px;
} }
@@ -132,32 +136,38 @@
} }
.reaction-count { .reaction-count {
color: #cfd8dc; color: @text_color;
} }
.completer { .completer {
background-color: #2c3e50; background-color: @secondary_color;
padding: 5px; padding: 5px;
} }
.completer-entry { .completer-entry {
color: #cfd8dc; color: @text_color;
} }
.completer-entry-image { .completer-entry-image {
margin-right: 6px; margin-right: 6px;
} }
.typing-indicator {
margin-top: 10px;
margin-bottom: -7px;
color: @text_color;
}
paned separator { paned separator {
background: #37474f; background: #37474f;
} }
scrollbar { scrollbar {
background: #263238; background: @background_color;
border-left: 1px solid transparent; border-left: 1px solid transparent;
} }
menubar, menu { menubar, menu {
background: #263238; background: @background_color;
color: #cccccc; color: #cccccc;
} }

View File

@@ -590,6 +590,9 @@ void DiscordClient::HandleGatewayMessage(std::string str) {
case GatewayEvent::CHANNEL_RECIPIENT_REMOVE: { case GatewayEvent::CHANNEL_RECIPIENT_REMOVE: {
HandleGatewayChannelRecipientRemove(m); HandleGatewayChannelRecipientRemove(m);
} break; } break;
case GatewayEvent::TYPING_START: {
HandleGatewayTypingStart(m);
} break;
} }
} break; } break;
default: default:
@@ -871,6 +874,28 @@ void DiscordClient::HandleGatewayChannelRecipientRemove(const GatewayMessage &ms
m_store.SetChannel(cur->ID, *cur); 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) { void DiscordClient::HandleGatewayReconnect(const GatewayMessage &msg) {
m_signal_disconnected.emit(true); m_signal_disconnected.emit(true);
inflateEnd(&m_zstream); inflateEnd(&m_zstream);
@@ -1091,6 +1116,7 @@ void DiscordClient::LoadEventMap() {
m_event_map["MESSAGE_REACTION_REMOVE"] = GatewayEvent::MESSAGE_REACTION_REMOVE; 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_ADD"] = GatewayEvent::CHANNEL_RECIPIENT_ADD;
m_event_map["CHANNEL_RECIPIENT_REMOVE"] = GatewayEvent::CHANNEL_RECIPIENT_REMOVE; 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() { 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() { DiscordClient::type_signal_reaction_remove DiscordClient::signal_reaction_remove() {
return m_signal_reaction_remove; return m_signal_reaction_remove;
} }
DiscordClient::type_signal_typing_start DiscordClient::signal_typing_start() {
return m_signal_typing_start;
}

View File

@@ -146,6 +146,7 @@ private:
void HandleGatewayMessageReactionRemove(const GatewayMessage &msg); void HandleGatewayMessageReactionRemove(const GatewayMessage &msg);
void HandleGatewayChannelRecipientAdd(const GatewayMessage &msg); void HandleGatewayChannelRecipientAdd(const GatewayMessage &msg);
void HandleGatewayChannelRecipientRemove(const GatewayMessage &msg); void HandleGatewayChannelRecipientRemove(const GatewayMessage &msg);
void HandleGatewayTypingStart(const GatewayMessage &msg);
void HandleGatewayReconnect(const GatewayMessage &msg); void HandleGatewayReconnect(const GatewayMessage &msg);
void HeartbeatThread(); void HeartbeatThread();
void SendIdentify(); void SendIdentify();
@@ -212,6 +213,7 @@ public:
typedef sigc::signal<void, Snowflake> type_signal_role_delete; 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_add;
typedef sigc::signal<void, Snowflake, Glib::ustring> type_signal_reaction_remove; 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, bool> type_signal_disconnected; // bool true if reconnecting
typedef sigc::signal<void> type_signal_connected; typedef sigc::signal<void> type_signal_connected;
@@ -231,6 +233,7 @@ public:
type_signal_role_delete signal_role_delete(); type_signal_role_delete signal_role_delete();
type_signal_reaction_add signal_reaction_add(); type_signal_reaction_add signal_reaction_add();
type_signal_reaction_remove signal_reaction_remove(); type_signal_reaction_remove signal_reaction_remove();
type_signal_typing_start signal_typing_start();
type_signal_disconnected signal_disconnected(); type_signal_disconnected signal_disconnected();
type_signal_connected signal_connected(); type_signal_connected signal_connected();
@@ -251,6 +254,7 @@ protected:
type_signal_role_delete m_signal_role_delete; type_signal_role_delete m_signal_role_delete;
type_signal_reaction_add m_signal_reaction_add; type_signal_reaction_add m_signal_reaction_add;
type_signal_reaction_remove m_signal_reaction_remove; type_signal_reaction_remove m_signal_reaction_remove;
type_signal_typing_start m_signal_typing_start;
type_signal_disconnected m_signal_disconnected; type_signal_disconnected m_signal_disconnected;
type_signal_connected m_signal_connected; type_signal_connected m_signal_connected;
}; };

View File

@@ -239,3 +239,11 @@ void from_json(const nlohmann::json &j, ChannelRecipientRemove &m) {
JS_D("user", m.User); JS_D("user", m.User);
JS_D("channel_id", m.ChannelID); 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);
}

View File

@@ -53,6 +53,7 @@ enum class GatewayEvent : int {
MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE,
CHANNEL_RECIPIENT_ADD, CHANNEL_RECIPIENT_ADD,
CHANNEL_RECIPIENT_REMOVE, CHANNEL_RECIPIENT_REMOVE,
TYPING_START,
}; };
struct GatewayMessage { struct GatewayMessage {
@@ -334,3 +335,13 @@ struct ChannelRecipientRemove {
friend void from_json(const nlohmann::json &j, ChannelRecipientRemove &m); 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);
};

View File

@@ -35,6 +35,7 @@ MainWindow::MainWindow()
m_menu_bar.append(m_menu_file); m_menu_bar.append(m_menu_file);
m_menu_bar.append(m_menu_discord); m_menu_bar.append(m_menu_discord);
m_menu_bar.show_all();
m_menu_discord_connect.signal_activate().connect([this] { m_menu_discord_connect.signal_activate().connect([this] {
m_signal_action_connect.emit(); m_signal_action_connect.emit();
@@ -66,9 +67,11 @@ MainWindow::MainWindow()
m_content_box.set_hexpand(true); m_content_box.set_hexpand(true);
m_content_box.set_vexpand(true); m_content_box.set_vexpand(true);
m_content_box.show();
m_main_box.add(m_menu_bar); m_main_box.add(m_menu_bar);
m_main_box.add(m_content_box); m_main_box.add(m_content_box);
m_main_box.show();
auto *channel_list = m_channel_list.GetRoot(); auto *channel_list = m_channel_list.GetRoot();
auto *member_list = m_members.GetRoot(); auto *member_list = m_members.GetRoot();
@@ -84,17 +87,21 @@ MainWindow::MainWindow()
chat->set_vexpand(true); chat->set_vexpand(true);
chat->set_hexpand(true); chat->set_hexpand(true);
chat->show();
channel_list->set_vexpand(true); channel_list->set_vexpand(true);
channel_list->set_size_request(-1, -1); channel_list->set_size_request(-1, -1);
channel_list->show();
member_list->set_vexpand(true); member_list->set_vexpand(true);
member_list->show();
m_chan_chat_paned.pack1(*channel_list); m_chan_chat_paned.pack1(*channel_list);
m_chan_chat_paned.pack2(m_chat_members_paned); 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_shrink(*channel_list) = false;
m_chan_chat_paned.child_property_resize(*channel_list) = false; m_chan_chat_paned.child_property_resize(*channel_list) = false;
m_chan_chat_paned.set_position(200); m_chan_chat_paned.set_position(200);
m_chan_chat_paned.show();
m_content_box.add(m_chan_chat_paned); m_content_box.add(m_chan_chat_paned);
m_chat_members_paned.pack1(*chat); m_chat_members_paned.pack1(*chat);
@@ -104,10 +111,9 @@ MainWindow::MainWindow()
int w, h; int w, h;
get_default_size(w, h); // :s get_default_size(w, h); // :s
m_chat_members_paned.set_position(w - m_chan_chat_paned.get_position() - 150); m_chat_members_paned.set_position(w - m_chan_chat_paned.get_position() - 150);
m_chat_members_paned.show();
add(m_main_box); add(m_main_box);
show_all_children();
} }
void MainWindow::UpdateComponents() { void MainWindow::UpdateComponents() {