summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml6
-rw-r--r--.gitignore3
-rw-r--r--.gitmodules3
-rwxr-xr-x.lapce/gen_compile_commands.sh7
-rw-r--r--.lapce/run.toml24
-rw-r--r--README.md9
-rw-r--r--ci/msys-deps.txt2
l---------compile_commands.json1
-rw-r--r--res/css/main.css4
-rw-r--r--src/abaddon.cpp10
-rw-r--r--src/audio/manager.cpp15
-rw-r--r--src/components/channellist/cellrendererchannels.cpp12
-rw-r--r--src/components/channellist/cellrendererchannels.hpp6
-rw-r--r--src/components/channellist/channellisttree.cpp64
-rw-r--r--src/components/channellist/channellisttree.hpp5
-rw-r--r--src/components/chatmessage.cpp1
-rw-r--r--src/discord/channel.hpp16
-rw-r--r--src/discord/discord.cpp159
-rw-r--r--src/discord/discord.hpp32
-rw-r--r--src/discord/guild.cpp1
-rw-r--r--src/discord/guild.hpp2
-rw-r--r--src/discord/objects.cpp13
-rw-r--r--src/discord/objects.hpp13
-rw-r--r--src/discord/snowflake.cpp9
-rw-r--r--src/discord/snowflake.hpp2
-rw-r--r--src/discord/stage.cpp12
-rw-r--r--src/discord/stage.hpp32
-rw-r--r--src/discord/voiceclient.cpp18
-rw-r--r--src/discord/voiceclient.hpp23
-rw-r--r--src/discord/voicestate.cpp5
-rw-r--r--src/discord/voicestate.hpp (renamed from src/discord/voicestateflags.hpp)11
-rw-r--r--src/misc/bitwise.hpp7
-rw-r--r--src/startup.cpp22
-rw-r--r--src/util.cpp32
-rw-r--r--src/util.hpp4
-rw-r--r--src/windows/voice/voicewindow.cpp (renamed from src/windows/voicewindow.cpp)262
-rw-r--r--src/windows/voice/voicewindow.hpp (renamed from src/windows/voicewindow.hpp)36
-rw-r--r--src/windows/voice/voicewindowaudiencelistentry.cpp23
-rw-r--r--src/windows/voice/voicewindowaudiencelistentry.hpp18
-rw-r--r--src/windows/voice/voicewindowspeakerlistentry.cpp58
-rw-r--r--src/windows/voice/voicewindowspeakerlistentry.hpp38
41 files changed, 824 insertions, 196 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cdfa8f3..a3ef6a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -122,7 +122,7 @@ jobs:
if_false: "${{ matrix.buildtype }}"
- name: Upload build (2)
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: build-windows-msys2-${{ steps.buildname.outputs.value }}
path: build/artifactdir
@@ -167,7 +167,7 @@ jobs:
cp -r "${{ github.workspace }}/res/res" "${artifact_dir}/res"
- name: Upload build
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: build-macos-${{ matrix.buildtype }}
path: build/artifactdir
@@ -225,7 +225,7 @@ jobs:
cp -r "${{ github.workspace }}/res/res" "${artifact_dir}/res"
- name: Upload build
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: build-linux-${{ matrix.buildtype }}
path: ${{ runner.workspace }}/artifactdir
diff --git a/.gitignore b/.gitignore
index e47837d..b28524f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+# Build directory contents
+build/*
+
# User-specific files
*.rsuser
*.suo
diff --git a/.gitmodules b/.gitmodules
index e173bb4..e0d062a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -10,9 +10,6 @@
[submodule "subprojects/keychain"]
path = subprojects/keychain
url = https://github.com/hrantzsch/keychain
-[submodule "subprojects/miniaudio"]
- path = subprojects/miniaudio
- url = https://github.com/mackron/miniaudio
[submodule "subprojects/rnnoise"]
path = subprojects/rnnoise
url = https://github.com/xiph/rnnoise
diff --git a/.lapce/gen_compile_commands.sh b/.lapce/gen_compile_commands.sh
new file mode 100755
index 0000000..e9e7e9b
--- /dev/null
+++ b/.lapce/gen_compile_commands.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Use this script to create the compile_commands.json file.
+# This is necessary for clangd completion.
+
+cmake . -B build \
+ -DCMAKE_BUILD_TYPE=Debug \
+ -DCMAKE_EXPORT_COMPILE_COMMANDS=True \ No newline at end of file
diff --git a/.lapce/run.toml b/.lapce/run.toml
new file mode 100644
index 0000000..a309b78
--- /dev/null
+++ b/.lapce/run.toml
@@ -0,0 +1,24 @@
+# The run config is used for both run mode and debug mode
+
+[[configs]]
+name = "cmake-debug"
+program = "sh"
+args = [".lapce/gen_compile_commands.sh"]
+
+[configs.env]
+CC = "/usr/bin/clang"
+CXX = "/usr/bin/clang++"
+
+[[configs]]
+name = "cmake"
+program = "cmake"
+args = ["--build", "build"]
+
+[configs.env]
+CC = "/usr/bin/clang"
+CXX = "/usr/bin/clang++"
+
+[[configs]]
+name = "run"
+type = "lldb"
+program = "build/abaddon"
diff --git a/README.md b/README.md
index b1d1a35..44a0055 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,15 @@ the result of fundamental issues with Discord's thread implementation.
5. `make`
6. [Copy resources](#resources)
+#### FreeBSD:
+
+1. `pkg install git cmake nlohmann-json spdlog gtkmm30 libhandy libsodium`
+2. `git clone https://github.com/uowuo/abaddon --recurse-submodules="subprojects" && cd abaddon`
+3. `mkdir build && cd build`
+4. `cmake ..`
+5. `make`
+6. [Copy resources](#resources)
+
### Downloads:
Latest release version: https://github.com/uowuo/abaddon/releases/latest
diff --git a/ci/msys-deps.txt b/ci/msys-deps.txt
index 56fc676..071d288 100644
--- a/ci/msys-deps.txt
+++ b/ci/msys-deps.txt
@@ -15,7 +15,7 @@
/bin/libepoxy-0.dll
/bin/libexpat-1.dll
/bin/libffi-8.dll
-/bin/libfmt.dll
+/bin/libfmt-11.dll
/bin/libfontconfig-1.dll
/bin/libfreetype-6.dll
/bin/libfribidi-0.dll
diff --git a/compile_commands.json b/compile_commands.json
new file mode 120000
index 0000000..25eb4b2
--- /dev/null
+++ b/compile_commands.json
@@ -0,0 +1 @@
+build/compile_commands.json \ No newline at end of file
diff --git a/res/css/main.css b/res/css/main.css
index 65d6eeb..7292101 100644
--- a/res/css/main.css
+++ b/res/css/main.css
@@ -39,6 +39,10 @@
color: #FAA61A;
}
+.message-container {
+ margin-bottom: 8px;
+}
+
.message-input textview, .message-input textview text {
background-color: inherit;
}
diff --git a/src/abaddon.cpp b/src/abaddon.cpp
index 7f4281d..653327c 100644
--- a/src/abaddon.cpp
+++ b/src/abaddon.cpp
@@ -19,7 +19,7 @@
#include "windows/profilewindow.hpp"
#include "windows/pinnedwindow.hpp"
#include "windows/threadswindow.hpp"
-#include "windows/voicewindow.hpp"
+#include "windows/voice/voicewindow.hpp"
#include "startup.hpp"
#include "notifications/notifications.hpp"
#include "remoteauth/remoteauthdialog.hpp"
@@ -1154,9 +1154,14 @@ void Abaddon::on_window_hide() {
}
int main(int argc, char **argv) {
- if (std::getenv("ABADDON_NO_FC") == nullptr)
+ if (std::getenv("ABADDON_NO_FC") == nullptr) {
Platform::SetupFonts();
+ }
+
+ // windows doesnt have langinfo.h so some localization falls back to translation strings
+ // i dont like the default translation so this lets us use strftime
+#ifdef _WIN32
char *systemLocale = std::setlocale(LC_ALL, "");
try {
if (systemLocale != nullptr) {
@@ -1170,6 +1175,7 @@ int main(int argc, char **argv) {
}
} catch (...) {}
}
+#endif
#if defined(_WIN32) && defined(_MSC_VER)
TCHAR buf[2] { 0 };
diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp
index 33730d9..d32c5e2 100644
--- a/src/audio/manager.cpp
+++ b/src/audio/manager.cpp
@@ -15,17 +15,6 @@
#include <cstring>
// clang-format on
-const uint8_t *StripRTPExtensionHeader(const uint8_t *buf, int num_bytes, size_t &outlen) {
- if (buf[0] == 0xbe && buf[1] == 0xde && num_bytes > 4) {
- uint64_t offset = 4 + 4 * ((buf[2] << 8) | buf[3]);
-
- outlen = num_bytes - offset;
- return buf + offset;
- }
- outlen = num_bytes;
- return buf;
-}
-
void data_callback(ma_device *pDevice, void *pOutput, const void *pInput, ma_uint32 frameCount) {
AudioManager *mgr = reinterpret_cast<AudioManager *>(pDevice->pUserData);
if (mgr == nullptr) return;
@@ -233,11 +222,9 @@ void AudioManager::FeedMeOpus(uint32_t ssrc, const std::vector<uint8_t> &data) {
std::lock_guard<std::mutex> _(m_mutex);
if (m_muted_ssrcs.find(ssrc) != m_muted_ssrcs.end()) return;
- size_t payload_size = 0;
- const auto *opus_encoded = StripRTPExtensionHeader(data.data(), static_cast<int>(data.size()), payload_size);
static std::array<opus_int16, 120 * 48 * 2> pcm;
if (auto it = m_sources.find(ssrc); it != m_sources.end()) {
- int decoded = opus_decode(it->second.second, opus_encoded, static_cast<opus_int32>(payload_size), pcm.data(), 120 * 48, 0);
+ int decoded = opus_decode(it->second.second, data.data(), static_cast<opus_int32>(data.size()), pcm.data(), 120 * 48, 0);
if (decoded <= 0) {
} else {
UpdateReceiveVolume(ssrc, pcm.data(), decoded);
diff --git a/src/components/channellist/cellrendererchannels.cpp b/src/components/channellist/cellrendererchannels.cpp
index 8a6097e..af9109a 100644
--- a/src/components/channellist/cellrendererchannels.cpp
+++ b/src/components/channellist/cellrendererchannels.cpp
@@ -123,6 +123,7 @@ void CellRendererChannels::get_preferred_width_vfunc(Gtk::Widget &widget, int &m
case RenderType::Thread:
return get_preferred_width_vfunc_thread(widget, minimum_width, natural_width);
case RenderType::VoiceChannel:
+ case RenderType::VoiceStage:
return get_preferred_width_vfunc_voice_channel(widget, minimum_width, natural_width);
case RenderType::VoiceParticipant:
return get_preferred_width_vfunc_voice_participant(widget, minimum_width, natural_width);
@@ -146,6 +147,7 @@ void CellRendererChannels::get_preferred_width_for_height_vfunc(Gtk::Widget &wid
case RenderType::Thread:
return get_preferred_width_for_height_vfunc_thread(widget, height, minimum_width, natural_width);
case RenderType::VoiceChannel:
+ case RenderType::VoiceStage:
return get_preferred_width_for_height_vfunc_voice_channel(widget, height, minimum_width, natural_width);
case RenderType::VoiceParticipant:
return get_preferred_width_for_height_vfunc_voice_participant(widget, height, minimum_width, natural_width);
@@ -169,6 +171,7 @@ void CellRendererChannels::get_preferred_height_vfunc(Gtk::Widget &widget, int &
case RenderType::Thread:
return get_preferred_height_vfunc_thread(widget, minimum_height, natural_height);
case RenderType::VoiceChannel:
+ case RenderType::VoiceStage:
return get_preferred_height_vfunc_voice_channel(widget, minimum_height, natural_height);
case RenderType::VoiceParticipant:
return get_preferred_height_vfunc_voice_participant(widget, minimum_height, natural_height);
@@ -192,6 +195,7 @@ void CellRendererChannels::get_preferred_height_for_width_vfunc(Gtk::Widget &wid
case RenderType::Thread:
return get_preferred_height_for_width_vfunc_thread(widget, width, minimum_height, natural_height);
case RenderType::VoiceChannel:
+ case RenderType::VoiceStage:
return get_preferred_height_for_width_vfunc_voice_channel(widget, width, minimum_height, natural_height);
case RenderType::VoiceParticipant:
return get_preferred_height_for_width_vfunc_voice_participant(widget, width, minimum_height, natural_height);
@@ -215,7 +219,9 @@ void CellRendererChannels::render_vfunc(const Cairo::RefPtr<Cairo::Context> &cr,
case RenderType::Thread:
return render_vfunc_thread(cr, widget, background_area, cell_area, flags);
case RenderType::VoiceChannel:
- return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags);
+ return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags, "\U0001F50A");
+ case RenderType::VoiceStage:
+ return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags, "\U0001F4E1");
case RenderType::VoiceParticipant:
return render_vfunc_voice_participant(cr, widget, background_area, cell_area, flags);
case RenderType::DMHeader:
@@ -571,7 +577,7 @@ void CellRendererChannels::get_preferred_height_for_width_vfunc_voice_channel(Gt
m_renderer_text.get_preferred_height_for_width(widget, width, minimum_height, natural_height);
}
-void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtr<Cairo::Context> &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) {
+void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtr<Cairo::Context> &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags, const char *emoji) {
// channel name text
Gtk::Requisition minimum_size, natural_size;
m_renderer_text.get_preferred_size(widget, minimum_size, natural_size);
@@ -588,7 +594,7 @@ void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtr<Cairo:
Pango::FontDescription font;
font.set_family("sans 14");
- auto layout = widget.create_pango_layout("\U0001F50A");
+ auto layout = widget.create_pango_layout(emoji);
layout->set_font_description(font);
layout->set_alignment(Pango::ALIGN_LEFT);
cr->set_source_rgba(1.0, 1.0, 1.0, 1.0);
diff --git a/src/components/channellist/cellrendererchannels.hpp b/src/components/channellist/cellrendererchannels.hpp
index ebe4957..7059c6f 100644
--- a/src/components/channellist/cellrendererchannels.hpp
+++ b/src/components/channellist/cellrendererchannels.hpp
@@ -6,7 +6,7 @@
#include <gtkmm/cellrendererpixbuf.h>
#include <gtkmm/cellrenderertext.h>
#include "discord/snowflake.hpp"
-#include "discord/voicestateflags.hpp"
+#include "discord/voicestate.hpp"
#include "misc/bitwise.hpp"
enum class RenderType : uint8_t {
@@ -16,6 +16,7 @@ enum class RenderType : uint8_t {
TextChannel,
Thread,
VoiceChannel,
+ VoiceStage, // identical to non-stage except for icon
VoiceParticipant,
DMHeader,
@@ -112,7 +113,8 @@ protected:
Gtk::Widget &widget,
const Gdk::Rectangle &background_area,
const Gdk::Rectangle &cell_area,
- Gtk::CellRendererState flags);
+ Gtk::CellRendererState flags,
+ const char *emoji);
// voice participant
void get_preferred_width_vfunc_voice_participant(Gtk::Widget &widget, int &minimum_width, int &natural_width) const;
diff --git a/src/components/channellist/channellisttree.cpp b/src/components/channellist/channellisttree.cpp
index 4597a1f..e824933 100644
--- a/src/components/channellist/channellisttree.cpp
+++ b/src/components/channellist/channellisttree.cpp
@@ -28,6 +28,8 @@ ChannelListTree::ChannelListTree()
#endif
, m_menu_voice_channel_join("_Join", true)
, m_menu_voice_channel_disconnect("_Disconnect", true)
+ , m_menu_voice_stage_join("_Join", true)
+ , m_menu_voice_stage_disconnect("_Disconnect", true)
, m_menu_voice_channel_mark_as_read("Mark as _Read", true)
, m_menu_voice_open_chat("Open _Chat", true)
, m_menu_dm_copy_id("_Copy ID", true)
@@ -225,6 +227,21 @@ ChannelListTree::ChannelListTree()
m_menu_voice_channel.append(m_menu_voice_open_chat);
m_menu_voice_channel.show_all();
+#ifdef WITH_VOICE
+ m_menu_voice_stage_join.signal_activate().connect([this]() {
+ const auto id = static_cast<Snowflake>((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]);
+ m_signal_action_join_voice_channel.emit(id);
+ });
+
+ m_menu_voice_stage_disconnect.signal_activate().connect([this]() {
+ m_signal_action_disconnect_voice.emit();
+ });
+#endif
+
+ m_menu_voice_stage.append(m_menu_voice_stage_join);
+ m_menu_voice_stage.append(m_menu_voice_stage_disconnect);
+ m_menu_voice_stage.show_all();
+
m_menu_dm_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]));
});
@@ -366,8 +383,8 @@ int ChannelListTree::SortFunc(const Gtk::TreeModel::iterator &a, const Gtk::Tree
const int64_t b_sort = (*b)[m_columns.m_sort];
if (a_type == RenderType::DMHeader) return -1;
if (b_type == RenderType::DMHeader) return 1;
- if (a_type == RenderType::TextChannel && b_type == RenderType::VoiceChannel) return -1;
- if (b_type == RenderType::TextChannel && a_type == RenderType::VoiceChannel) return 1;
+ if (a_type == RenderType::TextChannel && (b_type == RenderType::VoiceChannel || b_type == RenderType::VoiceStage)) return -1;
+ if (b_type == RenderType::TextChannel && (a_type == RenderType::VoiceChannel || a_type == RenderType::VoiceStage)) return 1;
return static_cast<int>(std::clamp(a_sort - b_sort, int64_t(-1), int64_t(1)));
}
@@ -634,6 +651,7 @@ void ChannelListTree::OnThreadListSync(const ThreadListSyncData &data) {
void ChannelListTree::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) {
auto parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::VoiceChannel);
+ if (!parent_iter) parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::VoiceStage);
if (!parent_iter) parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::DM);
if (!parent_iter) return;
const auto user = Abaddon::Get().GetDiscordClient().GetUser(user_id);
@@ -914,7 +932,7 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const
for (const auto &channel_ : *guild.Channels) {
const auto channel = discord.GetChannel(channel_.ID);
if (!channel.has_value()) continue;
- if (channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::GUILD_NEWS || channel->Type == ChannelType::GUILD_VOICE) {
+ if (channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::GUILD_NEWS || channel->Type == ChannelType::GUILD_VOICE || channel->Type == ChannelType::GUILD_STAGE_VOICE) {
if (channel->ParentID.has_value())
categories[*channel->ParentID].push_back(*channel);
else
@@ -954,6 +972,10 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const
if (IsTextChannel(channel.Type)) {
channel_row[m_columns.m_type] = RenderType::TextChannel;
channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name);
+ } else if (channel.Type == ChannelType::GUILD_STAGE_VOICE) {
+ channel_row[m_columns.m_type] = RenderType::VoiceStage;
+ channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name);
+ add_voice_participants(channel, channel_row->children());
} else {
channel_row[m_columns.m_type] = RenderType::VoiceChannel;
channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name);
@@ -983,6 +1005,10 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const
if (IsTextChannel(channel.Type)) {
channel_row[m_columns.m_type] = RenderType::TextChannel;
channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name);
+ } else if (channel.Type == ChannelType::GUILD_STAGE_VOICE) {
+ channel_row[m_columns.m_type] = RenderType::VoiceStage;
+ channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name);
+ add_voice_participants(channel, channel_row->children());
} else {
channel_row[m_columns.m_type] = RenderType::VoiceChannel;
channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name);
@@ -1033,7 +1059,7 @@ Gtk::TreeModel::iterator ChannelListTree::CreateVoiceParticipantRow(const UserDa
const auto voice_state = Abaddon::Get().GetDiscordClient().GetVoiceState(user.ID);
if (voice_state.has_value()) {
- row[m_columns.m_voice_flags] = voice_state->second;
+ row[m_columns.m_voice_flags] = voice_state->second.Flags;
}
auto &img = Abaddon::Get().GetImageManager();
@@ -1153,8 +1179,11 @@ bool ChannelListTree::SelectionFunc(const Glib::RefPtr<Gtk::TreeModel> &model, c
}
}
- const auto type = (*model->get_iter(path))[m_columns.m_type];
- const auto id = static_cast<Snowflake>((*model->get_iter(path))[m_columns.m_id]);
+ const auto iter = model->get_iter(path);
+ if (!iter) return false;
+
+ const auto type = (*iter)[m_columns.m_type];
+ const auto id = static_cast<Snowflake>((*iter)[m_columns.m_id]);
// todo maybe just keep this last check?
if (type == RenderType::TextChannel || type == RenderType::DM || type == RenderType::Thread || (id == m_active_channel)) return true;
return is_currently_selected;
@@ -1328,6 +1357,10 @@ bool ChannelListTree::OnButtonPressEvent(GdkEventButton *ev) {
OnVoiceChannelSubmenuPopup();
m_menu_voice_channel.popup_at_pointer(reinterpret_cast<GdkEvent *>(ev));
break;
+ case RenderType::VoiceStage:
+ OnVoiceStageSubmenuPopup();
+ m_menu_voice_stage.popup_at_pointer(reinterpret_cast<GdkEvent *>(ev));
+ break;
case RenderType::DM: {
OnDMSubmenuPopup();
const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(static_cast<Snowflake>(row[m_columns.m_id]));
@@ -1439,6 +1472,25 @@ void ChannelListTree::OnVoiceChannelSubmenuPopup() {
#endif
}
+void ChannelListTree::OnVoiceStageSubmenuPopup() {
+#ifdef WITH_VOICE
+ 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]);
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ if (discord.IsVoiceConnected() || discord.IsVoiceConnecting()) {
+ m_menu_voice_stage_join.set_sensitive(false);
+ m_menu_voice_stage_disconnect.set_sensitive(discord.GetVoiceChannelID() == id);
+ } else {
+ m_menu_voice_stage_join.set_sensitive(true);
+ m_menu_voice_stage_disconnect.set_sensitive(false);
+ }
+#else
+ m_menu_voice_stage_join.set_sensitive(false);
+ m_menu_voice_stage_disconnect.set_sensitive(false);
+#endif
+}
+
void ChannelListTree::OnDMSubmenuPopup() {
auto iter = m_model->get_iter(m_path_for_menu);
if (!iter) return;
diff --git a/src/components/channellist/channellisttree.hpp b/src/components/channellist/channellisttree.hpp
index 9e2c544..323f843 100644
--- a/src/components/channellist/channellisttree.hpp
+++ b/src/components/channellist/channellisttree.hpp
@@ -162,6 +162,10 @@ protected:
Gtk::Menu m_menu_voice_channel;
Gtk::MenuItem m_menu_voice_channel_join;
Gtk::MenuItem m_menu_voice_channel_disconnect;
+
+ Gtk::Menu m_menu_voice_stage;
+ Gtk::MenuItem m_menu_voice_stage_join;
+ Gtk::MenuItem m_menu_voice_stage_disconnect;
Gtk::MenuItem m_menu_voice_channel_mark_as_read;
Gtk::MenuItem m_menu_voice_open_chat;
@@ -192,6 +196,7 @@ protected:
void OnDMSubmenuPopup();
void OnThreadSubmenuPopup();
void OnVoiceChannelSubmenuPopup();
+ void OnVoiceStageSubmenuPopup();
bool m_updating_listing = false;
diff --git a/src/components/chatmessage.cpp b/src/components/chatmessage.cpp
index 8f2f519..8940164 100644
--- a/src/components/chatmessage.cpp
+++ b/src/components/chatmessage.cpp
@@ -1041,7 +1041,6 @@ ChatMessageHeader::ChatMessageHeader(const Message &data)
m_main_box.add(m_content_box_ev);
add(m_main_box);
- set_margin_bottom(8);
set_focus_on_click(false);
show_all();
diff --git a/src/discord/channel.hpp b/src/discord/channel.hpp
index cac8b4c..ebf67b0 100644
--- a/src/discord/channel.hpp
+++ b/src/discord/channel.hpp
@@ -27,22 +27,6 @@ enum class ChannelType : int {
GUILD_MEDIA = 16,
};
-enum class StagePrivacy {
- PUBLIC = 1,
- GUILD_ONLY = 2,
-};
-
-constexpr const char *GetStagePrivacyDisplayString(StagePrivacy e) {
- switch (e) {
- case StagePrivacy::PUBLIC:
- return "Public";
- case StagePrivacy::GUILD_ONLY:
- return "Guild Only";
- default:
- return "Unknown";
- }
-}
-
// should be moved somewhere?
struct ThreadMetadataData {
diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp
index 062a871..a7712d6 100644
--- a/src/discord/discord.cpp
+++ b/src/discord/discord.cpp
@@ -360,6 +360,14 @@ std::optional<WebhookMessageData> DiscordClient::GetWebhookMessageData(Snowflake
return m_store.GetWebhookMessage(message_id);
}
+std::optional<StageInstance> DiscordClient::GetStageInstanceFromChannel(Snowflake channel_id) const {
+ const auto iter1 = m_channel_to_stage_instance.find(channel_id);
+ if (iter1 == m_channel_to_stage_instance.end()) return {};
+ const auto iter2 = m_stage_instances.find(iter1->second);
+ if (iter2 == m_stage_instances.end()) return {};
+ return iter2->second;
+}
+
bool DiscordClient::IsThreadJoined(Snowflake thread_id) const {
return std::find(m_joined_threads.begin(), m_joined_threads.end(), thread_id) != m_joined_threads.end();
}
@@ -462,6 +470,10 @@ bool DiscordClient::CanManageMember(Snowflake guild_id, Snowflake actor, Snowfla
return actor_highest->Position > target_highest->Position;
}
+bool DiscordClient::IsStageModerator(Snowflake user_id, Snowflake channel_id) const {
+ return HasChannelPermission(user_id, channel_id, Permission::MANAGE_CHANNELS | Permission::MOVE_MEMBERS | Permission::MUTE_MEMBERS);
+}
+
void DiscordClient::ChatMessageCallback(const std::string &nonce, const http::response_type &response, const sigc::slot<void(DiscordError)> &callback) {
if (!CheckCode(response)) {
if (response.status_code == http::TooManyRequests) {
@@ -1288,6 +1300,78 @@ std::optional<uint32_t> DiscordClient::GetSSRCOfUser(Snowflake id) const {
return m_voice.GetSSRCOfUser(id);
}
+bool DiscordClient::IsUserSpeaker(Snowflake user_id) const {
+ const auto state = GetVoiceState(user_id);
+ return state.has_value() && state->second.IsSpeaker();
+}
+
+bool DiscordClient::HasUserRequestedToSpeak(Snowflake user_id) const {
+ const auto state = GetVoiceState(user_id);
+ return state.has_value() && state->second.RequestToSpeakTimestamp.has_value() && util::FlagSet(state->second.Flags, VoiceStateFlags::Suppressed);
+}
+
+bool DiscordClient::IsUserInvitedToSpeak(Snowflake user_id) const {
+ const auto state = GetVoiceState(user_id);
+ return state.has_value() && state->second.RequestToSpeakTimestamp.has_value() && !util::FlagSet(state->second.Flags, VoiceStateFlags::Suppressed);
+}
+
+void DiscordClient::RequestToSpeak(Snowflake channel_id, bool want, const sigc::slot<void(DiscordError code)> &callback) {
+ if (want && !HasSelfChannelPermission(channel_id, Permission::REQUEST_TO_SPEAK)) return;
+ const auto channel = GetChannel(channel_id);
+ if (!channel.has_value() || !channel->GuildID.has_value()) return;
+
+ ModifyCurrentUserVoiceStateObject d;
+ d.ChannelID = channel_id;
+ if (want) {
+ d.RequestToSpeakTimestamp = Glib::DateTime::create_now_utc().format_iso8601();
+ } else {
+ d.RequestToSpeakTimestamp = "";
+ }
+ m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) {
+ if (CheckCode(response, 204)) {
+ callback(DiscordError::NONE);
+ } else {
+ callback(GetCodeFromResponse(response));
+ }
+ });
+}
+
+void DiscordClient::SetStageSpeaking(Snowflake channel_id, bool want, const sigc::slot<void(DiscordError code)> &callback) {
+ const auto channel = GetChannel(channel_id);
+ if (!channel.has_value() || !channel->GuildID.has_value()) return;
+
+ ModifyCurrentUserVoiceStateObject d;
+ d.ChannelID = channel_id;
+ d.Suppress = !want;
+ if (want) {
+ d.RequestToSpeakTimestamp = "";
+ }
+ m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) {
+ if (CheckCode(response, 204)) {
+ callback(DiscordError::NONE);
+ } else {
+ callback(GetCodeFromResponse(response));
+ }
+ });
+}
+
+void DiscordClient::DeclineInviteToSpeak(Snowflake channel_id, const sigc::slot<void(DiscordError code)> &callback) {
+ const auto channel = GetChannel(channel_id);
+ if (!channel.has_value() || !channel->GuildID.has_value()) return;
+
+ ModifyCurrentUserVoiceStateObject d;
+ d.ChannelID = channel_id;
+ d.Suppress = true;
+ d.RequestToSpeakTimestamp = "";
+ m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) {
+ if (CheckCode(response, 204)) {
+ callback(DiscordError::NONE);
+ } else {
+ callback(GetCodeFromResponse(response));
+ }
+ });
+}
+
DiscordVoiceClient &DiscordClient::GetVoiceClient() {
return m_voice;
}
@@ -1303,7 +1387,7 @@ void DiscordClient::SetVoiceDeafened(bool is_deaf) {
}
#endif
-std::optional<std::pair<Snowflake, VoiceStateFlags>> DiscordClient::GetVoiceState(Snowflake user_id) const {
+std::optional<std::pair<Snowflake, PackedVoiceState>> DiscordClient::GetVoiceState(Snowflake user_id) const {
if (const auto it = m_voice_states.find(user_id); it != m_voice_states.end()) {
return it->second;
}
@@ -1652,6 +1736,15 @@ void DiscordClient::HandleGatewayMessage(std::string str) {
case GatewayEvent::GUILD_MEMBERS_CHUNK: {
HandleGatewayGuildMembersChunk(m);
} break;
+ case GatewayEvent::STAGE_INSTANCE_CREATE: {
+ HandleGatewayStageInstanceCreate(m);
+ } break;
+ case GatewayEvent::STAGE_INSTANCE_UPDATE: {
+ HandleGatewayStageInstanceUpdate(m);
+ } break;
+ case GatewayEvent::STAGE_INSTANCE_DELETE: {
+ HandleGatewayStageInstanceDelete(m);
+ } break;
case GatewayEvent::VOICE_STATE_UPDATE: {
HandleGatewayVoiceStateUpdate(m);
} break;
@@ -1712,6 +1805,14 @@ void DiscordClient::ProcessNewGuild(GuildData &guild) {
return;
}
+ if (guild.StageInstances.has_value()) {
+ for (const auto &stage : *guild.StageInstances) {
+ spdlog::get("discord")->debug("storing stage {} in channel {}", stage.ID, stage.ChannelID);
+ m_stage_instances[stage.ID] = stage;
+ m_channel_to_stage_instance[stage.ChannelID] = stage.ID;
+ }
+ }
+
m_store.BeginTransaction();
m_store.SetGuild(guild.ID, guild);
@@ -2302,6 +2403,29 @@ void DiscordClient::HandleGatewayGuildMembersChunk(const GatewayMessage &msg) {
m_store.EndTransaction();
}
+void DiscordClient::HandleGatewayStageInstanceCreate(const GatewayMessage &msg) {
+ StageInstance data = msg.Data;
+ spdlog::get("discord")->debug("STAGE_INSTANCE_CREATE: {} in {}", data.ID, data.ChannelID);
+ m_stage_instances[data.ID] = data;
+ m_channel_to_stage_instance[data.ChannelID] = data.ID;
+ m_signal_stage_instance_create.emit(data);
+}
+
+void DiscordClient::HandleGatewayStageInstanceUpdate(const GatewayMessage &msg) {
+ StageInstance data = msg.Data;
+ spdlog::get("discord")->debug("STAGE_INSTANCE_UPDATE: {} in {}", data.ID, data.ChannelID);
+ m_stage_instances[data.ID] = data;
+ m_signal_stage_instance_update.emit(data);
+}
+
+void DiscordClient::HandleGatewayStageInstanceDelete(const GatewayMessage &msg) {
+ StageInstance data = msg.Data;
+ spdlog::get("discord")->debug("STAGE_INSTANCE_DELETE: {} in {}", data.ID, data.ChannelID);
+ m_stage_instances.erase(data.ID);
+ m_channel_to_stage_instance.erase(data.ChannelID);
+ m_signal_stage_instance_delete.emit(data);
+}
+
#ifdef WITH_VOICE
/*
@@ -2399,9 +2523,14 @@ void DiscordClient::CheckVoiceState(const VoiceState &data) {
if (data.ChannelID.has_value()) {
const auto old_state = GetVoiceState(data.UserID);
SetVoiceState(data.UserID, data);
- if (old_state.has_value() && old_state->first != *data.ChannelID) {
- m_signal_voice_user_disconnect.emit(data.UserID, old_state->first);
- m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID);
+ const auto new_state = GetVoiceState(data.UserID);
+ if (old_state.has_value()) {
+ if (old_state->first != *data.ChannelID) {
+ m_signal_voice_user_disconnect.emit(data.UserID, old_state->first);
+ m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID);
+ } else if (old_state->second.IsSpeaker() != new_state.value().second.IsSpeaker()) {
+ m_signal_voice_speaker_state_changed.emit(*data.ChannelID, data.UserID, new_state->second.IsSpeaker());
+ }
} else if (!old_state.has_value()) {
m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID);
}
@@ -2954,8 +3083,9 @@ void DiscordClient::SetVoiceState(Snowflake user_id, const VoiceState &state) {
if (state.IsDeafened) flags |= VoiceStateFlags::Deaf;
if (state.IsSelfStream) flags |= VoiceStateFlags::SelfStream;
if (state.IsSelfVideo) flags |= VoiceStateFlags::SelfVideo;
+ if (state.IsSuppressed) flags |= VoiceStateFlags::Suppressed;
- m_voice_states[user_id] = std::make_pair(*state.ChannelID, flags);
+ m_voice_states[user_id] = std::make_pair(*state.ChannelID, PackedVoiceState { flags, state.RequestToSpeakTimestamp });
m_voice_state_channel_users[*state.ChannelID].insert(user_id);
m_signal_voice_state_set.emit(user_id, *state.ChannelID, flags);
@@ -3018,6 +3148,9 @@ void DiscordClient::LoadEventMap() {
m_event_map["VOICE_STATE_UPDATE"] = GatewayEvent::VOICE_STATE_UPDATE;
m_event_map["VOICE_SERVER_UPDATE"] = GatewayEvent::VOICE_SERVER_UPDATE;
m_event_map["CALL_CREATE"] = GatewayEvent::CALL_CREATE;
+ m_event_map["STAGE_INSTANCE_CREATE"] = GatewayEvent::STAGE_INSTANCE_CREATE;
+ m_event_map["STAGE_INSTANCE_UPDATE"] = GatewayEvent::STAGE_INSTANCE_UPDATE;
+ m_event_map["STAGE_INSTANCE_DELETE"] = GatewayEvent::STAGE_INSTANCE_DELETE;
}
DiscordClient::type_signal_gateway_ready DiscordClient::signal_gateway_ready() {
@@ -3196,6 +3329,18 @@ DiscordClient::type_signal_guild_members_chunk DiscordClient::signal_guild_membe
return m_signal_guild_members_chunk;
}
+DiscordClient::type_signal_stage_instance_create DiscordClient::signal_stage_instance_create() {
+ return m_signal_stage_instance_create;
+}
+
+DiscordClient::type_signal_stage_instance_update DiscordClient::signal_stage_instance_update() {
+ return m_signal_stage_instance_update;
+}
+
+DiscordClient::type_signal_stage_instance_delete DiscordClient::signal_stage_instance_delete() {
+ return m_signal_stage_instance_delete;
+}
+
DiscordClient::type_signal_added_to_thread DiscordClient::signal_added_to_thread() {
return m_signal_added_to_thread;
}
@@ -3273,3 +3418,7 @@ DiscordClient::type_signal_voice_user_connect DiscordClient::signal_voice_user_c
DiscordClient::type_signal_voice_state_set DiscordClient::signal_voice_state_set() {
return m_signal_voice_state_set;
}
+
+DiscordClient::type_signal_voice_speaker_state_changed DiscordClient::signal_voice_speaker_state_changed() {
+ return m_signal_voice_speaker_state_changed;
+} \ No newline at end of file
diff --git a/src/discord/discord.hpp b/src/discord/discord.hpp
index 4e898dc..483abf0 100644
--- a/src/discord/discord.hpp
+++ b/src/discord/discord.hpp
@@ -5,7 +5,7 @@
#include "objects.hpp"
#include "store.hpp"
#include "voiceclient.hpp"
-#include "voicestateflags.hpp"
+#include "voicestate.hpp"
#include "websocket.hpp"
#include <gdkmm/rgba.h>
#include <sigc++/sigc++.h>
@@ -65,6 +65,7 @@ public:
void GetArchivedPrivateThreads(Snowflake channel_id, const sigc::slot<void(DiscordError, const ArchivedThreadsResponseData &)> &callback);
std::vector<Snowflake> GetChildChannelIDs(Snowflake parent_id) const;
std::optional<WebhookMessageData> GetWebhookMessageData(Snowflake message_id) const;
+ std::optional<StageInstance> GetStageInstanceFromChannel(Snowflake channel_id) const;
// get ids of given list of members for who we do not have the member data
template<typename Iter>
@@ -86,6 +87,7 @@ public:
Permission ComputePermissions(Snowflake member_id, Snowflake guild_id) const;
Permission ComputeOverwrites(Permission base, Snowflake member_id, Snowflake channel_id) const;
bool CanManageMember(Snowflake guild_id, Snowflake actor, Snowflake target) const; // kick, ban, edit nickname (cant think of a better name)
+ bool IsStageModerator(Snowflake user_id, Snowflake channel_id) const;
void ChatMessageCallback(const std::string &nonce, const http::response_type &response, const sigc::slot<void(DiscordError code)> &callback);
void SendChatMessageNoAttachments(const ChatSubmitParams &params, const sigc::slot<void(DiscordError code)> &callback);
@@ -201,6 +203,13 @@ public:
[[nodiscard]] bool IsVoiceConnecting() const noexcept;
[[nodiscard]] Snowflake GetVoiceChannelID() const noexcept;
[[nodiscard]] std::optional<uint32_t> GetSSRCOfUser(Snowflake id) const;
+ [[nodiscard]] bool IsUserSpeaker(Snowflake user_id) const;
+ [[nodiscard]] bool HasUserRequestedToSpeak(Snowflake user_id) const;
+ [[nodiscard]] bool IsUserInvitedToSpeak(Snowflake user_id) const;
+
+ void RequestToSpeak(Snowflake channel_id, bool want, const sigc::slot<void(DiscordError code)> &callback);
+ void SetStageSpeaking(Snowflake channel_id, bool want, const sigc::slot<void(DiscordError code)> &callback);
+ void DeclineInviteToSpeak(Snowflake channel_id, const sigc::slot<void(DiscordError code)> &callback);
DiscordVoiceClient &GetVoiceClient();
@@ -208,7 +217,7 @@ public:
void SetVoiceDeafened(bool is_deaf);
#endif
- [[nodiscard]] std::optional<std::pair<Snowflake, VoiceStateFlags>> GetVoiceState(Snowflake user_id) const;
+ [[nodiscard]] std::optional<std::pair<Snowflake, PackedVoiceState>> GetVoiceState(Snowflake user_id) const;
[[nodiscard]] std::unordered_set<Snowflake> GetUsersInVoiceChannel(Snowflake channel_id);
void SetReferringChannel(Snowflake id);
@@ -295,6 +304,9 @@ private:
void HandleGatewayMessageAck(const GatewayMessage &msg);
void HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg);
void HandleGatewayGuildMembersChunk(const GatewayMessage &msg);
+ void HandleGatewayStageInstanceCreate(const GatewayMessage &msg);
+ void HandleGatewayStageInstanceUpdate(const GatewayMessage &msg);
+ void HandleGatewayStageInstanceDelete(const GatewayMessage &msg);
void HandleGatewayReadySupplemental(const GatewayMessage &msg);
void HandleGatewayReconnect(const GatewayMessage &msg);
void HandleGatewayInvalidSession(const GatewayMessage &msg);
@@ -345,6 +357,8 @@ private:
std::unordered_set<Snowflake> m_muted_channels;
std::unordered_map<Snowflake, int> m_unread;
std::unordered_set<Snowflake> m_channel_muted_parent;
+ std::map<Snowflake, StageInstance> m_stage_instances;
+ std::map<Snowflake, Snowflake> m_channel_to_stage_instance;
UserData m_user_data;
UserSettings m_user_settings;
@@ -388,7 +402,7 @@ private:
void ClearVoiceState(Snowflake user_id);
// todo sql i guess
- std::unordered_map<Snowflake, std::pair<Snowflake, VoiceStateFlags>> m_voice_states;
+ std::unordered_map<Snowflake, std::pair<Snowflake, PackedVoiceState>> m_voice_states;
std::unordered_map<Snowflake, std::unordered_set<Snowflake>> m_voice_state_channel_users;
mutable std::mutex m_msg_mutex;
@@ -446,6 +460,9 @@ public:
typedef sigc::signal<void, ThreadMemberListUpdateData> type_signal_thread_member_list_update;
typedef sigc::signal<void, MessageAckData> type_signal_message_ack;
typedef sigc::signal<void, GuildMembersChunkData> type_signal_guild_members_chunk;
+ typedef sigc::signal<void, StageInstance> type_signal_stage_instance_create;
+ typedef sigc::signal<void, StageInstance> type_signal_stage_instance_update;
+ typedef sigc::signal<void, StageInstance> type_signal_stage_instance_delete;
// not discord dispatch events
typedef sigc::signal<void, Snowflake> type_signal_added_to_thread;
@@ -477,6 +494,7 @@ public:
using type_signal_voice_user_disconnect = sigc::signal<void(Snowflake, Snowflake)>;
using type_signal_voice_user_connect = sigc::signal<void(Snowflake, Snowflake)>;
using type_signal_voice_state_set = sigc::signal<void(Snowflake, Snowflake, VoiceStateFlags)>;
+ using type_signal_voice_speaker_state_changed = sigc::signal<void(Snowflake /* channel_id */, Snowflake /* user_id */, bool /* is_speaker */)>;
type_signal_gateway_ready signal_gateway_ready();
type_signal_gateway_ready_supplemental signal_gateway_ready_supplemental();
@@ -519,6 +537,9 @@ public:
type_signal_thread_member_list_update signal_thread_member_list_update();
type_signal_message_ack signal_message_ack();
type_signal_guild_members_chunk signal_guild_members_chunk();
+ type_signal_stage_instance_create signal_stage_instance_create();
+ type_signal_stage_instance_update signal_stage_instance_update();
+ type_signal_stage_instance_delete signal_stage_instance_delete();
type_signal_added_to_thread signal_added_to_thread();
type_signal_removed_from_thread signal_removed_from_thread();
@@ -546,6 +567,7 @@ public:
type_signal_voice_user_disconnect signal_voice_user_disconnect();
type_signal_voice_user_connect signal_voice_user_connect();
type_signal_voice_state_set signal_voice_state_set();
+ type_signal_voice_speaker_state_changed signal_voice_speaker_state_changed();
protected:
type_signal_gateway_ready m_signal_gateway_ready;
@@ -589,6 +611,9 @@ protected:
type_signal_thread_member_list_update m_signal_thread_member_list_update;
type_signal_message_ack m_signal_message_ack;
type_signal_guild_members_chunk m_signal_guild_members_chunk;
+ type_signal_stage_instance_create m_signal_stage_instance_create;
+ type_signal_stage_instance_update m_signal_stage_instance_update;
+ type_signal_stage_instance_delete m_signal_stage_instance_delete;
type_signal_removed_from_thread m_signal_removed_from_thread;
type_signal_added_to_thread m_signal_added_to_thread;
@@ -616,4 +641,5 @@ protected:
type_signal_voice_user_disconnect m_signal_voice_user_disconnect;
type_signal_voice_user_connect m_signal_voice_user_connect;
type_signal_voice_state_set m_signal_voice_state_set;
+ type_signal_voice_speaker_state_changed m_signal_voice_speaker_state_changed;
};
diff --git a/src/discord/guild.cpp b/src/discord/guild.cpp
index 06c4acf..9cf94c2 100644
--- a/src/discord/guild.cpp
+++ b/src/discord/guild.cpp
@@ -54,6 +54,7 @@ void from_json(const nlohmann::json &j, GuildData &m) {
JS_O("preferred_locale", m.PreferredLocale);
JS_ON("public_updates_channel_id", m.PublicUpdatesChannelID);
JS_O("max_video_channel_users", m.MaxVideoChannelUsers);
+ JS_ON("stage_instances", m.StageInstances);
JS_O("approximate_member_count", tmp);
if (tmp.has_value())
m.ApproximateMemberCount = std::stol(*tmp);
diff --git a/src/discord/guild.hpp b/src/discord/guild.hpp
index 4895d30..1ea858d 100644
--- a/src/discord/guild.hpp
+++ b/src/discord/guild.hpp
@@ -4,6 +4,7 @@
#include "role.hpp"
#include "channel.hpp"
#include "emoji.hpp"
+#include "stage.hpp"
#include <vector>
#include <string>
#include <unordered_set>
@@ -90,6 +91,7 @@ struct GuildData {
std::optional<int> ApproximateMemberCount;
std::optional<int> ApproximatePresenceCount;
std::optional<std::vector<ChannelData>> Threads; // only with permissions to view, id only
+ std::optional<std::vector<StageInstance>> StageInstances;
// undocumented
// std::map<std::string, Unknown> GuildHashes;
diff --git a/src/discord/objects.cpp b/src/discord/objects.cpp
index 804f10d..e6b7675 100644
--- a/src/discord/objects.cpp
+++ b/src/discord/objects.cpp
@@ -699,6 +699,18 @@ void from_json(const nlohmann::json &j, CallCreateData &m) {
JS_D("channel_id", m.ChannelID);
JS_ON("voice_states", m.VoiceStates);
}
+
+void to_json(nlohmann::json &j, const ModifyCurrentUserVoiceStateObject &m) {
+ JS_IF("channel_id", m.ChannelID);
+ JS_IF("suppress", m.Suppress);
+ if (m.RequestToSpeakTimestamp.has_value()) {
+ if (m.RequestToSpeakTimestamp->empty()) {
+ j["request_to_speak_timestamp"] = nullptr;
+ } else {
+ j["request_to_speak_timestamp"] = *m.RequestToSpeakTimestamp;
+ }
+ }
+}
#endif
void from_json(const nlohmann::json &j, VoiceState &m) {
@@ -714,4 +726,5 @@ void from_json(const nlohmann::json &j, VoiceState &m) {
JS_D("user_id", m.UserID);
JS_ON("member", m.Member);
JS_D("session_id", m.SessionID);
+ JS_ON("request_to_speak_timestamp", m.RequestToSpeakTimestamp);
}
diff --git a/src/discord/objects.hpp b/src/discord/objects.hpp
index dfe99f0..44afe8d 100644
--- a/src/discord/objects.hpp
+++ b/src/discord/objects.hpp
@@ -20,6 +20,7 @@
#include "auditlog.hpp"
#include "relationship.hpp"
#include "errors.hpp"
+#include "stage.hpp"
// most stuff below should just be objects that get processed and thrown away immediately
@@ -110,6 +111,9 @@ enum class GatewayEvent : int {
VOICE_STATE_UPDATE,
VOICE_SERVER_UPDATE,
CALL_CREATE,
+ STAGE_INSTANCE_CREATE,
+ STAGE_INSTANCE_UPDATE,
+ STAGE_INSTANCE_DELETE,
};
enum class GatewayCloseCode : uint16_t {
@@ -917,6 +921,7 @@ struct VoiceState {
std::string SessionID;
bool IsSuppressed;
Snowflake UserID;
+ std::optional<std::string> RequestToSpeakTimestamp;
friend void from_json(const nlohmann::json &j, VoiceState &m);
};
@@ -952,4 +957,12 @@ struct CallCreateData {
friend void from_json(const nlohmann::json &j, CallCreateData &m);
};
+
+struct ModifyCurrentUserVoiceStateObject {
+ std::optional<Snowflake> ChannelID;
+ std::optional<bool> Suppress;
+ std::optional<std::string> RequestToSpeakTimestamp;
+
+ friend void to_json(nlohmann::json &j, const ModifyCurrentUserVoiceStateObject &m);
+};
#endif
diff --git a/src/discord/snowflake.cpp b/src/discord/snowflake.cpp
index 43fe91e..361bd9e 100644
--- a/src/discord/snowflake.cpp
+++ b/src/discord/snowflake.cpp
@@ -6,6 +6,8 @@
#include "util.hpp"
+#include <glibmm/datetime.h>
+
constexpr static uint64_t DiscordEpochSeconds = 1420070400;
const Snowflake Snowflake::Invalid = -1ULL;
@@ -56,11 +58,8 @@ bool Snowflake::IsValid() const {
}
Glib::ustring Snowflake::GetLocalTimestamp() const {
- const time_t secs_since_epoch = (m_num / SecondsInterval) + DiscordEpochSeconds;
- const std::tm tm = *localtime(&secs_since_epoch);
- std::array<char, 256> tmp {};
- std::strftime(tmp.data(), sizeof(tmp), "%X %x", &tm);
- return tmp.data();
+ const gint64 secs_since_epoch = (m_num / SecondsInterval) + DiscordEpochSeconds;
+ return FormatUnixEpoch(secs_since_epoch);
}
uint64_t Snowflake::GetUnixMilliseconds() const noexcept {
diff --git a/src/discord/snowflake.hpp b/src/discord/snowflake.hpp
index 68cb5ea..2208f29 100644
--- a/src/discord/snowflake.hpp
+++ b/src/discord/snowflake.hpp
@@ -44,7 +44,7 @@ private:
template<>
struct fmt::formatter<Snowflake> : fmt::formatter<std::string> {
- auto format(Snowflake id, format_context &ctx) -> decltype(ctx.out()) {
+ auto format(Snowflake id, format_context &ctx) const -> decltype(ctx.out()) {
return format_to(ctx.out(), "[id: {}]", static_cast<uint64_t>(id));
}
};
diff --git a/src/discord/stage.cpp b/src/discord/stage.cpp
new file mode 100644
index 0000000..428e1f3
--- /dev/null
+++ b/src/discord/stage.cpp
@@ -0,0 +1,12 @@
+#include "stage.hpp"
+
+#include "json.hpp"
+
+void from_json(const nlohmann::json &j, StageInstance &m) {
+ JS_D("id", m.ID);
+ JS_D("guild_id", m.GuildID);
+ JS_D("channel_id", m.ChannelID);
+ JS_N("topic", m.Topic);
+ JS_N("privacy_level", m.PrivacyLevel);
+ JS_N("guild_scheduled_event_id", m.GuildScheduledEventID);
+}
diff --git a/src/discord/stage.hpp b/src/discord/stage.hpp
new file mode 100644
index 0000000..3df4433
--- /dev/null
+++ b/src/discord/stage.hpp
@@ -0,0 +1,32 @@
+#pragma once
+
+#include <nlohmann/json.hpp>
+
+#include "snowflake.hpp"
+
+enum class StagePrivacy {
+ PUBLIC = 1,
+ GUILD_ONLY = 2,
+};
+
+constexpr const char *GetStagePrivacyDisplayString(StagePrivacy e) {
+ switch (e) {
+ case StagePrivacy::PUBLIC:
+ return "Public";
+ case StagePrivacy::GUILD_ONLY:
+ return "Guild Only";
+ default:
+ return "Unknown";
+ }
+}
+
+struct StageInstance {
+ Snowflake ID;
+ Snowflake GuildID;
+ Snowflake ChannelID;
+ std::string Topic;
+ StagePrivacy PrivacyLevel;
+ Snowflake GuildScheduledEventID;
+
+ friend void from_json(const nlohmann::json &j, StageInstance &m);
+};
diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp
index 212b878..f021e49 100644
--- a/src/discord/voiceclient.cpp
+++ b/src/discord/voiceclient.cpp
@@ -250,6 +250,7 @@ bool DiscordVoiceClient::IsConnecting() const noexcept {
}
void DiscordVoiceClient::OnGatewayMessage(const std::string &str) {
+ m_log->trace("IN: {}", str);
VoiceGatewayMessage msg = nlohmann::json::parse(str);
switch (msg.Opcode) {
case VoiceGatewayOp::Hello:
@@ -462,6 +463,19 @@ void DiscordVoiceClient::SetState(State state) {
m_signal_state_update.emit(state);
}
+size_t GetPayloadOffset(const uint8_t *buf, size_t num_bytes) {
+ const bool has_extension_header = (buf[0] & 0b00010000) != 0;
+ const int csrc_count = buf[0] & 0b00001111;
+
+ size_t offset = 12 + csrc_count * 4;
+
+ if (has_extension_header && num_bytes > 4) {
+ offset += 4 + 4 * ((buf[offset + 2] << 8) | buf[offset + 3]);
+ }
+
+ return offset;
+}
+
void DiscordVoiceClient::OnUDPData(std::vector<uint8_t> data) {
uint8_t *payload = data.data() + 12;
uint32_t ssrc = (data[8] << 24) |
@@ -473,7 +487,9 @@ void DiscordVoiceClient::OnUDPData(std::vector<uint8_t> data) {
if (crypto_secretbox_open_easy(payload, payload, data.size() - 12, nonce.data(), m_secret_key.data())) {
// spdlog::get("voice")->trace("UDP payload decryption failure");
} else {
- Abaddon::Get().GetAudio().FeedMeOpus(ssrc, { payload, payload + data.size() - 12 - crypto_box_MACBYTES });
+ size_t opus_offset = GetPayloadOffset(data.data(), data.size());
+ payload = data.data() + opus_offset;
+ Abaddon::Get().GetAudio().FeedMeOpus(ssrc, { payload, payload + data.size() - opus_offset - crypto_box_MACBYTES });
}
}
diff --git a/src/discord/voiceclient.hpp b/src/discord/voiceclient.hpp
index 0112749..aa1014c 100644
--- a/src/discord/voiceclient.hpp
+++ b/src/discord/voiceclient.hpp
@@ -43,6 +43,23 @@ enum class VoiceGatewayOp : int {
Hello = 8,
Resumed = 9,
ClientDisconnect = 13,
+ SessionUpdate = 14,
+ MediaSinkWants = 15,
+ VoiceBackendVersion = 16,
+ ChannelOptionsUpdate = 17,
+ Flags = 18,
+ SpeedTest = 19,
+ Platform = 20,
+ SecureFramesPrepareProtocolTransition = 21,
+ SecureFramesExecuteTransition = 22,
+ SecureFramesReadyForTransition = 23,
+ SecureFramesPrepareEpoch = 24,
+ MlsExternalSenderPackage = 25,
+ MlsKeyPackage = 26,
+ MlsProposals = 27,
+ MlsCommitWelcome = 28,
+ MlsPrepareCommitTransition = 29,
+ MlsWelcome = 30,
};
struct VoiceGatewayMessage {
@@ -156,11 +173,11 @@ public:
private:
void ReadThread();
- #ifdef _WIN32
+#ifdef _WIN32
SOCKET m_socket;
- #else
+#else
int m_socket;
- #endif
+#endif
sockaddr_in m_server;
std::atomic<bool> m_running = false;
diff --git a/src/discord/voicestate.cpp b/src/discord/voicestate.cpp
new file mode 100644
index 0000000..05c050d
--- /dev/null
+++ b/src/discord/voicestate.cpp
@@ -0,0 +1,5 @@
+#include "voicestate.hpp"
+
+bool PackedVoiceState::IsSpeaker() const noexcept {
+ return ((Flags & VoiceStateFlags::Suppressed) != VoiceStateFlags::Suppressed) && !RequestToSpeakTimestamp.has_value();
+}
diff --git a/src/discord/voicestateflags.hpp b/src/discord/voicestate.hpp
index 01fb762..cc75b0c 100644
--- a/src/discord/voicestateflags.hpp
+++ b/src/discord/voicestate.hpp
@@ -1,7 +1,10 @@
#pragma once
#include <cstdint>
+#include <optional>
+#include <string>
#include "misc/bitwise.hpp"
+// this is packed into a enum cuz it makes implementing tree models easier
enum class VoiceStateFlags : uint8_t {
Clear = 0,
Deaf = 1 << 0,
@@ -10,6 +13,14 @@ enum class VoiceStateFlags : uint8_t {
SelfMute = 1 << 3,
SelfStream = 1 << 4,
SelfVideo = 1 << 5,
+ Suppressed = 1 << 6,
+};
+
+struct PackedVoiceState {
+ VoiceStateFlags Flags;
+ std::optional<std::string> RequestToSpeakTimestamp;
+
+ [[nodiscard]] bool IsSpeaker() const noexcept;
};
template<>
diff --git a/src/misc/bitwise.hpp b/src/misc/bitwise.hpp
index ecce333..4d4cf8f 100644
--- a/src/misc/bitwise.hpp
+++ b/src/misc/bitwise.hpp
@@ -1,6 +1,13 @@
#pragma once
#include <type_traits>
+namespace util {
+template<typename T>
+bool FlagSet(T flags, T value) {
+ return (flags & value) == value;
+}
+} // namespace util
+
template<typename T>
struct Bitwise {
static const bool enable = false;
diff --git a/src/startup.cpp b/src/startup.cpp
index ed42358..50b8744 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -32,22 +32,11 @@ std::optional<std::pair<std::string, std::string>> ParseCookie(const Glib::ustri
}
std::optional<Glib::ustring> GetJavascriptFileFromAppPage(const Glib::ustring &contents) {
- auto regex = Glib::Regex::create(R"(/assets/\w+\.?\w{20}\.js)");
- std::vector<Glib::ustring> matches;
+ auto regex = Glib::Regex::create(R"(/assets/sentry.*?\.js)");
- // regex->match_all doesnt work for some reason
- int start_position = 0;
Glib::MatchInfo match;
- while (regex->match(contents, start_position, match)) {
- const auto str = match.fetch(0);
- matches.push_back(str);
- int foo;
- match.fetch_pos(0, start_position, foo);
- start_position += str.size();
- }
-
- if (matches.size() >= 9) {
- return matches[matches.size() - 9];
+ if (regex->match(contents, match)) {
+ return match.fetch(0);
}
return {};
@@ -67,7 +56,7 @@ std::optional<uint32_t> GetBuildNumberFromJSURL(const Glib::ustring &url, const
auto res = req.execute();
if (res.error) return {};
- auto regex = Glib::Regex::create(R"(Build Number: "\).concat\("(\d+))");
+ auto regex = Glib::Regex::create(R"(buildNumber",\(.="(\d+))");
Glib::MatchInfo match;
Glib::ustring string = res.text;
if (regex->match(string, match)) {
@@ -130,6 +119,9 @@ void DiscordStartupDialog::RunAsync() {
auto js_url = GetJavascriptFileFromAppPage(app_page);
if (js_url.has_value()) {
m_build_number = GetBuildNumberFromJSURL(*js_url, *opt_cookie);
+ if (m_build_number.has_value()) {
+ spdlog::get("discord")->debug("Found build number: {}", *m_build_number);
+ }
}
}
m_dispatcher.emit();
diff --git a/src/util.cpp b/src/util.cpp
index 09bb368..85d4b44 100644
--- a/src/util.cpp
+++ b/src/util.cpp
@@ -63,27 +63,33 @@ int GetTimezoneOffset() {
return static_cast<int>(local_secs - gmt_secs);
}
-std::string FormatISO8601(const std::string &in, int extra_offset, const std::string &fmt) {
- int yr, mon, day, hr, min, sec, tzhr, tzmin;
- float milli;
- std::sscanf(in.c_str(), "%d-%d-%dT%d:%d:%d%f+%d:%d",
- &yr, &mon, &day, &hr, &min, &sec, &milli, &tzhr, &tzmin);
+Glib::ustring FormatUnixEpoch(gint64 time, const std::string &fmt) {
+ auto dt = Glib::wrap(g_date_time_new_from_unix_utc(time));
+
+#ifdef _WIN32
std::tm tm {};
- tm.tm_year = yr - 1900;
- tm.tm_mon = mon - 1;
- tm.tm_mday = day;
- tm.tm_hour = hr;
- tm.tm_min = min;
- tm.tm_sec = sec;
+ tm.tm_year = dt.get_year() - 1900;
+ tm.tm_mon = dt.get_month() - 1;
+ tm.tm_mday = dt.get_day_of_month();
+ tm.tm_hour = dt.get_hour() + 1;
+ tm.tm_min = dt.get_minute();
+ tm.tm_sec = dt.get_second();
tm.tm_wday = 0;
tm.tm_yday = 0;
tm.tm_isdst = -1;
- int offset = GetTimezoneOffset();
- tm.tm_sec += offset + extra_offset;
+ tm.tm_sec += GetTimezoneOffset();
mktime(&tm);
std::array<char, 512> tmp {};
std::strftime(tmp.data(), sizeof(tmp), fmt.c_str(), &tm);
return tmp.data();
+#else
+ return dt.format(fmt);
+#endif
+}
+
+Glib::ustring FormatISO8601(const std::string &in, int extra_offset, const std::string &fmt) {
+ const auto epoch = Glib::DateTime::create_from_iso8601(in).add_seconds(extra_offset).to_unix();
+ return FormatUnixEpoch(epoch);
}
void ScrollListBoxToSelected(Gtk::ListBox &list) {
diff --git a/src/util.hpp b/src/util.hpp
index fc9568b..072a8e2 100644
--- a/src/util.hpp
+++ b/src/util.hpp
@@ -11,6 +11,7 @@
#include <regex>
#include <mutex>
#include <condition_variable>
+#include <glib.h>
#include <optional>
#include <type_traits>
@@ -50,7 +51,8 @@ std::string GetExtension(std::string url);
bool IsURLViewableImage(const std::string &url);
std::vector<uint8_t> ReadWholeFile(const std::string &path);
std::string HumanReadableBytes(uint64_t bytes);
-std::string FormatISO8601(const std::string &in, int extra_offset = 0, const std::string &fmt = "%x %X");
+Glib::ustring FormatUnixEpoch(gint64 time, const std::string &fmt = "%x %X");
+Glib::ustring FormatISO8601(const std::string &in, int extra_offset = 0, const std::string &fmt = "%x %X");
void AddPointerCursor(Gtk::Widget &widget);
template<typename T>
diff --git a/src/windows/voicewindow.cpp b/src/windows/voice/voicewindow.cpp
index a005e79..a9e9682 100644
--- a/src/windows/voicewindow.cpp
+++ b/src/windows/voice/voicewindow.cpp
@@ -1,89 +1,19 @@
+#include "util.hpp"
#ifdef WITH_VOICE
// clang-format off
+#include "voicewindow.hpp"
+
#include "abaddon.hpp"
#include "audio/manager.hpp"
#include "components/lazyimage.hpp"
-#include "voicesettingswindow.hpp"
-#include "voicewindow.hpp"
+#include "voicewindowaudiencelistentry.hpp"
+#include "voicewindowspeakerlistentry.hpp"
+#include "windows/voicesettingswindow.hpp"
// clang-format on
-class VoiceWindowUserListEntry : public Gtk::ListBoxRow {
-public:
- VoiceWindowUserListEntry(Snowflake id)
- : m_main(Gtk::ORIENTATION_VERTICAL)
- , m_horz(Gtk::ORIENTATION_HORIZONTAL)
- , m_avatar(32, 32)
- , m_mute("Mute") {
- m_name.set_halign(Gtk::ALIGN_START);
- m_name.set_hexpand(true);
- m_mute.set_halign(Gtk::ALIGN_END);
-
- m_volume.set_range(0.0, 200.0);
- m_volume.set_value_pos(Gtk::POS_LEFT);
- m_volume.set_value(100.0);
- m_volume.signal_value_changed().connect([this]() {
- m_signal_volume.emit(m_volume.get_value() * 0.01);
- });
-
- m_horz.add(m_avatar);
- m_horz.add(m_name);
- m_horz.add(m_mute);
- m_main.add(m_horz);
- m_main.add(m_volume);
- m_main.add(m_meter);
- add(m_main);
- show_all_children();
-
- auto &discord = Abaddon::Get().GetDiscordClient();
- const auto user = discord.GetUser(id);
- if (user.has_value()) {
- m_name.set_text(user->GetUsername());
- m_avatar.SetURL(user->GetAvatarURL("png", "32"));
- } else {
- m_name.set_text("Unknown user");
- }
-
- m_mute.signal_toggled().connect([this]() {
- m_signal_mute_cs.emit(m_mute.get_active());
- });
- }
-
- void SetVolumeMeter(double frac) {
- m_meter.SetVolume(frac);
- }
-
- void RestoreGain(double frac) {
- m_volume.set_value(frac * 100.0);
- }
-
-private:
- Gtk::Box m_main;
- Gtk::Box m_horz;
- LazyImage m_avatar;
- Gtk::Label m_name;
- Gtk::CheckButton m_mute;
- Gtk::Scale m_volume;
- VolumeMeter m_meter;
-
-public:
- using type_signal_mute_cs = sigc::signal<void(bool)>;
- using type_signal_volume = sigc::signal<void(double)>;
- type_signal_mute_cs signal_mute_cs() {
- return m_signal_mute_cs;
- }
-
- type_signal_volume signal_volume() {
- return m_signal_volume;
- }
-
-private:
- type_signal_mute_cs m_signal_mute_cs;
- type_signal_volume m_signal_volume;
-};
-
VoiceWindow::VoiceWindow(Snowflake channel_id)
: m_main(Gtk::ORIENTATION_VERTICAL)
, m_controls(Gtk::ORIENTATION_HORIZONTAL)
@@ -91,7 +21,11 @@ VoiceWindow::VoiceWindow(Snowflake channel_id)
, m_deafen("Deafen")
, m_noise_suppression("Suppress Noise")
, m_mix_mono("Mix Mono")
+ , m_stage_command("Request to Speak")
, m_disconnect("Disconnect")
+ , m_stage_invite_lbl("You've been invited to speak")
+ , m_stage_accept("Accept")
+ , m_stage_decline("Decline")
, m_channel_id(channel_id)
, m_menu_view("View")
, m_menu_view_settings("More _Settings", true) {
@@ -102,14 +36,19 @@ VoiceWindow::VoiceWindow(Snowflake channel_id)
auto &discord = Abaddon::Get().GetDiscordClient();
auto &audio = Abaddon::Get().GetAudio();
+ const auto channel = discord.GetChannel(m_channel_id);
+ m_is_stage = channel.has_value() && channel->Type == ChannelType::GUILD_STAGE_VOICE;
+
SetUsers(discord.GetUsersInVoiceChannel(m_channel_id));
discord.signal_voice_user_disconnect().connect(sigc::mem_fun(*this, &VoiceWindow::OnUserDisconnect));
discord.signal_voice_user_connect().connect(sigc::mem_fun(*this, &VoiceWindow::OnUserConnect));
+ discord.signal_voice_speaker_state_changed().connect(sigc::mem_fun(*this, &VoiceWindow::OnSpeakerStateChanged));
+ discord.signal_voice_state_set().connect(sigc::mem_fun(*this, &VoiceWindow::OnVoiceStateUpdate));
if (const auto self_state = discord.GetVoiceState(discord.GetUserData().ID); self_state.has_value()) {
- m_mute.set_active((self_state->second & VoiceStateFlags::SelfMute) == VoiceStateFlags::SelfMute);
- m_deafen.set_active((self_state->second & VoiceStateFlags::SelfDeaf) == VoiceStateFlags::SelfDeaf);
+ m_mute.set_active(util::FlagSet(self_state->second.Flags, VoiceStateFlags::SelfMute));
+ m_deafen.set_active(util::FlagSet(self_state->second.Flags, VoiceStateFlags::SelfDeaf));
}
m_mute.signal_toggled().connect(sigc::mem_fun(*this, &VoiceWindow::OnMuteChanged));
@@ -176,10 +115,12 @@ VoiceWindow::VoiceWindow(Snowflake channel_id)
UpdateVADParamValue();
});
+#ifdef WITH_RNNOISE
m_noise_suppression.set_active(audio.GetSuppressNoise());
m_noise_suppression.signal_toggled().connect([this]() {
Abaddon::Get().GetAudio().SetSuppressNoise(m_noise_suppression.get_active());
});
+#endif
m_mix_mono.set_active(audio.GetMixMono());
m_mix_mono.signal_toggled().connect([this]() {
@@ -253,35 +194,97 @@ VoiceWindow::VoiceWindow(Snowflake channel_id)
combos_combos->pack_start(m_playback_combo);
combos_combos->pack_start(m_capture_combo);
- m_scroll.add(m_user_list);
+ if (const auto instance = discord.GetStageInstanceFromChannel(channel_id); instance.has_value()) {
+ m_stage_topic_label.show();
+ UpdateStageTopicLabel(instance->Topic);
+ } else {
+ m_stage_topic_label.hide();
+ }
+
+ discord.signal_stage_instance_create().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceCreate));
+ discord.signal_stage_instance_update().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceUpdate));
+ discord.signal_stage_instance_delete().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceDelete));
+
+ m_stage_command.signal_clicked().connect([this]() {
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto user_id = discord.GetUserData().ID;
+ const bool is_moderator = discord.IsStageModerator(user_id, m_channel_id);
+ const bool is_speaker = discord.IsUserSpeaker(user_id);
+ const bool is_invited_to_speak = discord.IsUserInvitedToSpeak(user_id);
+
+ if (is_speaker) {
+ discord.SetStageSpeaking(m_channel_id, false, NOOP_CALLBACK);
+ } else if (is_moderator) {
+ discord.SetStageSpeaking(m_channel_id, true, NOOP_CALLBACK);
+ } else if (is_invited_to_speak) {
+ discord.DeclineInviteToSpeak(m_channel_id, NOOP_CALLBACK);
+ } else {
+ const bool requested = discord.HasUserRequestedToSpeak(user_id);
+ discord.RequestToSpeak(m_channel_id, !requested, NOOP_CALLBACK);
+ }
+ });
+
+ m_stage_accept.signal_clicked().connect([this]() {
+ Abaddon::Get().GetDiscordClient().SetStageSpeaking(m_channel_id, true, NOOP_CALLBACK);
+ });
+
+ m_stage_decline.signal_clicked().connect([this]() {
+ Abaddon::Get().GetDiscordClient().DeclineInviteToSpeak(m_channel_id, NOOP_CALLBACK);
+ });
+
+ m_speakers_label.set_markup("<b>Speakers</b>");
+ if (m_is_stage) m_listing.pack_start(m_speakers_label, false, true);
+ m_listing.pack_start(m_speakers_list, false, true);
+ m_audience_label.set_markup("<b>Audience</b>");
+ if (m_is_stage) m_listing.pack_start(m_audience_label, false, true);
+ if (m_is_stage) m_listing.pack_start(m_audience_list, false, true);
+ m_scroll.add(m_listing);
m_controls.add(m_mute);
m_controls.add(m_deafen);
m_controls.add(m_noise_suppression);
m_controls.add(m_mix_mono);
- m_controls.pack_end(m_disconnect, false, true);
+ m_buttons.set_halign(Gtk::ALIGN_CENTER);
+ if (m_is_stage) m_buttons.pack_start(m_stage_command, false, true);
+ m_buttons.pack_start(m_disconnect, false, true);
+ m_stage_invite_box.pack_start(m_stage_invite_lbl, false, true);
+ m_stage_invite_box.pack_start(m_stage_invite_btns);
+ m_stage_invite_btns.set_halign(Gtk::ALIGN_CENTER);
+ m_stage_invite_btns.pack_start(m_stage_accept, false, true);
+ m_stage_invite_btns.pack_start(m_stage_decline, false, true);
m_main.pack_start(m_menu_bar, false, true);
m_main.pack_start(m_controls, false, true);
+ m_main.pack_start(m_buttons, false, true);
+ m_main.pack_start(m_stage_invite_box, false, true);
m_main.pack_start(m_vad_value, false, true);
m_main.pack_start(*Gtk::make_managed<Gtk::Label>("Input Settings"), false, true);
m_main.pack_start(*sliders_container, false, true);
m_main.pack_start(m_scroll);
+ m_stage_topic_label.set_ellipsize(Pango::ELLIPSIZE_END);
+ m_stage_topic_label.set_halign(Gtk::ALIGN_CENTER);
+ m_main.pack_start(m_stage_topic_label, false, true);
m_main.pack_start(*combos_container, false, true, 2);
add(m_main);
show_all_children();
Glib::signal_timeout().connect(sigc::mem_fun(*this, &VoiceWindow::UpdateVoiceMeters), 40);
+
+ UpdateStageCommand();
}
void VoiceWindow::SetUsers(const std::unordered_set<Snowflake> &user_ids) {
- const auto me = Abaddon::Get().GetDiscordClient().GetUserData().ID;
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto me = discord.GetUserData().ID;
for (auto id : user_ids) {
- if (id == me) continue;
- m_user_list.add(*CreateRow(id));
+ if (!m_is_stage || discord.IsUserSpeaker(id)) {
+ if (id != me) m_speakers_list.add(*CreateSpeakerRow(id));
+ } else {
+ m_audience_list.add(*CreateAudienceRow(id));
+ }
}
}
-Gtk::ListBoxRow *VoiceWindow::CreateRow(Snowflake id) {
- auto *row = Gtk::make_managed<VoiceWindowUserListEntry>(id);
+Gtk::ListBoxRow *VoiceWindow::CreateSpeakerRow(Snowflake id) {
+ auto *row = Gtk::make_managed<VoiceWindowSpeakerListEntry>(id);
m_rows[id] = row;
auto &vc = Abaddon::Get().GetDiscordClient().GetVoiceClient();
row->RestoreGain(vc.GetUserVolume(id));
@@ -291,7 +294,14 @@ Gtk::ListBoxRow *VoiceWindow::CreateRow(Snowflake id) {
row->signal_volume().connect([this, id](double volume) {
m_signal_user_volume_changed.emit(id, volume);
});
- row->show_all();
+ row->show();
+ return row;
+}
+
+Gtk::ListBoxRow *VoiceWindow::CreateAudienceRow(Snowflake id) {
+ auto *row = Gtk::make_managed<VoiceWindowAudienceListEntry>(id);
+ m_rows[id] = row;
+ row->show();
return row;
}
@@ -303,6 +313,13 @@ void VoiceWindow::OnDeafenChanged() {
m_signal_deafen.emit(m_deafen.get_active());
}
+void VoiceWindow::TryDeleteRow(Snowflake id) {
+ if (auto it = m_rows.find(id); it != m_rows.end()) {
+ delete it->second;
+ m_rows.erase(it);
+ }
+}
+
bool VoiceWindow::UpdateVoiceMeters() {
auto &audio = Abaddon::Get().GetAudio();
switch (audio.GetVADMethod()) {
@@ -319,7 +336,9 @@ bool VoiceWindow::UpdateVoiceMeters() {
for (auto [id, row] : m_rows) {
const auto ssrc = Abaddon::Get().GetDiscordClient().GetSSRCOfUser(id);
if (ssrc.has_value()) {
- row->SetVolumeMeter(audio.GetSSRCVolumeLevel(*ssrc));
+ if (auto *speaker_row = dynamic_cast<VoiceWindowSpeakerListEntry *>(row)) {
+ speaker_row->SetVolumeMeter(audio.GetSSRCVolumeLevel(*ssrc));
+ }
}
}
return true;
@@ -339,23 +358,80 @@ void VoiceWindow::UpdateVADParamValue() {
}
}
+void VoiceWindow::UpdateStageCommand() {
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto user_id = discord.GetUserData().ID;
+
+ m_has_requested_to_speak = discord.HasUserRequestedToSpeak(user_id);
+ const bool is_moderator = discord.IsStageModerator(user_id, m_channel_id);
+ const bool is_speaker = discord.IsUserSpeaker(user_id);
+ const bool is_invited_to_speak = discord.IsUserInvitedToSpeak(user_id);
+
+ m_stage_invite_box.set_visible(is_invited_to_speak);
+
+ if (is_speaker) {
+ m_stage_command.set_label("Leave the Stage");
+ } else if (is_moderator) {
+ m_stage_command.set_label("Speak on Stage");
+ } else if (m_has_requested_to_speak) {
+ m_stage_command.set_label("Cancel Request");
+ } else if (is_invited_to_speak) {
+ m_stage_command.set_label("Decline Invite");
+ } else {
+ m_stage_command.set_label("Request to Speak");
+ }
+}
+
+void VoiceWindow::UpdateStageTopicLabel(const std::string &topic) {
+ m_stage_topic_label.set_markup("Topic: " + topic);
+}
+
void VoiceWindow::OnUserConnect(Snowflake user_id, Snowflake to_channel_id) {
if (m_channel_id == to_channel_id) {
if (auto it = m_rows.find(user_id); it == m_rows.end()) {
- m_user_list.add(*CreateRow(user_id));
+ if (Abaddon::Get().GetDiscordClient().IsUserSpeaker(user_id)) {
+ m_speakers_list.add(*CreateSpeakerRow(user_id));
+ } else {
+ m_audience_list.add(*CreateAudienceRow(user_id));
+ }
}
}
}
void VoiceWindow::OnUserDisconnect(Snowflake user_id, Snowflake from_channel_id) {
- if (m_channel_id == from_channel_id) {
- if (auto it = m_rows.find(user_id); it != m_rows.end()) {
- delete it->second;
- m_rows.erase(it);
- }
+ if (m_channel_id == from_channel_id) TryDeleteRow(user_id);
+}
+
+void VoiceWindow::OnSpeakerStateChanged(Snowflake channel_id, Snowflake user_id, bool is_speaker) {
+ if (m_channel_id != channel_id) return;
+ TryDeleteRow(user_id);
+ if (is_speaker) {
+ m_speakers_list.add(*CreateSpeakerRow(user_id));
+ } else {
+ m_audience_list.add(*CreateAudienceRow(user_id));
}
}
+void VoiceWindow::OnVoiceStateUpdate(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags) {
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ if (user_id != discord.GetUserData().ID) return;
+
+ UpdateStageCommand();
+}
+
+void VoiceWindow::OnStageInstanceCreate(const StageInstance &instance) {
+ m_stage_topic_label.show();
+ UpdateStageTopicLabel(instance.Topic);
+}
+
+void VoiceWindow::OnStageInstanceUpdate(const StageInstance &instance) {
+ UpdateStageTopicLabel(instance.Topic);
+}
+
+void VoiceWindow::OnStageInstanceDelete(const StageInstance &instance) {
+ m_stage_topic_label.hide();
+}
+
VoiceWindow::type_signal_mute VoiceWindow::signal_mute() {
return m_signal_mute;
}
diff --git a/src/windows/voicewindow.hpp b/src/windows/voice/voicewindow.hpp
index fb64010..05033d9 100644
--- a/src/windows/voicewindow.hpp
+++ b/src/windows/voice/voicewindow.hpp
@@ -1,4 +1,6 @@
#pragma once
+#include "discord/stage.hpp"
+#include "discord/voicestate.hpp"
#ifdef WITH_VOICE
// clang-format off
@@ -16,7 +18,6 @@
#include <unordered_set>
// clang-format on
-class VoiceWindowUserListEntry;
class VoiceWindow : public Gtk::Window {
public:
VoiceWindow(Snowflake channel_id);
@@ -24,17 +25,25 @@ public:
private:
void SetUsers(const std::unordered_set<Snowflake> &user_ids);
- Gtk::ListBoxRow *CreateRow(Snowflake id);
+ Gtk::ListBoxRow *CreateSpeakerRow(Snowflake id);
+ Gtk::ListBoxRow *CreateAudienceRow(Snowflake id);
void OnUserConnect(Snowflake user_id, Snowflake to_channel_id);
void OnUserDisconnect(Snowflake user_id, Snowflake from_channel_id);
+ void OnSpeakerStateChanged(Snowflake channel_id, Snowflake user_id, bool is_speaker);
+ void OnVoiceStateUpdate(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags);
+ void OnStageInstanceCreate(const StageInstance &instance);
+ void OnStageInstanceUpdate(const StageInstance &instance);
+ void OnStageInstanceDelete(const StageInstance &instance);
void OnMuteChanged();
void OnDeafenChanged();
+ void TryDeleteRow(Snowflake id);
bool UpdateVoiceMeters();
-
void UpdateVADParamValue();
+ void UpdateStageCommand();
+ void UpdateStageTopicLabel(const std::string &topic);
Gtk::Box m_main;
Gtk::Box m_controls;
@@ -43,7 +52,9 @@ private:
Gtk::CheckButton m_deafen;
Gtk::ScrolledWindow m_scroll;
- Gtk::ListBox m_user_list;
+ Gtk::VBox m_listing;
+ Gtk::ListBox m_speakers_list;
+ Gtk::ListBox m_audience_list;
// Shows volume for gate VAD method
// Shows probability for RNNoise VAD method
@@ -56,21 +67,36 @@ private:
Gtk::CheckButton m_noise_suppression;
Gtk::CheckButton m_mix_mono;
+ Gtk::HBox m_buttons;
Gtk::Button m_disconnect;
+ Gtk::Button m_stage_command;
+
+ Gtk::VBox m_stage_invite_box;
+ Gtk::Label m_stage_invite_lbl;
+ Gtk::HBox m_stage_invite_btns;
+ Gtk::Button m_stage_accept;
+ Gtk::Button m_stage_decline;
+
+ bool m_has_requested_to_speak = false;
Gtk::ComboBoxText m_vad_combo;
Gtk::ComboBox m_playback_combo;
Gtk::ComboBox m_capture_combo;
Snowflake m_channel_id;
+ bool m_is_stage;
- std::unordered_map<Snowflake, VoiceWindowUserListEntry *> m_rows;
+ std::unordered_map<Snowflake, Gtk::ListBoxRow *> m_rows;
Gtk::MenuBar m_menu_bar;
Gtk::MenuItem m_menu_view;
Gtk::Menu m_menu_view_sub;
Gtk::MenuItem m_menu_view_settings;
+ Gtk::Label m_stage_topic_label;
+ Gtk::Label m_speakers_label;
+ Gtk::Label m_audience_label;
+
public:
using type_signal_mute = sigc::signal<void(bool)>;
using type_signal_deafen = sigc::signal<void(bool)>;
diff --git a/src/windows/voice/voicewindowaudiencelistentry.cpp b/src/windows/voice/voicewindowaudiencelistentry.cpp
new file mode 100644
index 0000000..cf93343
--- /dev/null
+++ b/src/windows/voice/voicewindowaudiencelistentry.cpp
@@ -0,0 +1,23 @@
+#include "voicewindowaudiencelistentry.hpp"
+#include "abaddon.hpp"
+
+VoiceWindowAudienceListEntry::VoiceWindowAudienceListEntry(Snowflake id)
+ : m_main(Gtk::ORIENTATION_HORIZONTAL)
+ , m_avatar(32, 32) {
+ m_name.set_halign(Gtk::ALIGN_START);
+ m_name.set_hexpand(true);
+
+ m_main.add(m_avatar);
+ m_main.add(m_name);
+ add(m_main);
+ show_all_children();
+
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto user = discord.GetUser(id);
+ if (user.has_value()) {
+ m_name.set_text(user->GetUsername());
+ m_avatar.SetURL(user->GetAvatarURL("png", "32"));
+ } else {
+ m_name.set_text("Unknown user");
+ }
+}
diff --git a/src/windows/voice/voicewindowaudiencelistentry.hpp b/src/windows/voice/voicewindowaudiencelistentry.hpp
new file mode 100644
index 0000000..e7bdbb1
--- /dev/null
+++ b/src/windows/voice/voicewindowaudiencelistentry.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include "components/lazyimage.hpp"
+#include "discord/snowflake.hpp"
+
+#include <gtkmm/box.h>
+#include <gtkmm/label.h>
+#include <gtkmm/listboxrow.h>
+
+class VoiceWindowAudienceListEntry : public Gtk::ListBoxRow {
+public:
+ VoiceWindowAudienceListEntry(Snowflake id);
+
+private:
+ Gtk::Box m_main;
+ LazyImage m_avatar;
+ Gtk::Label m_name;
+};
diff --git a/src/windows/voice/voicewindowspeakerlistentry.cpp b/src/windows/voice/voicewindowspeakerlistentry.cpp
new file mode 100644
index 0000000..a7bf2b8
--- /dev/null
+++ b/src/windows/voice/voicewindowspeakerlistentry.cpp
@@ -0,0 +1,58 @@
+#include "voicewindowspeakerlistentry.hpp"
+
+#include "abaddon.hpp"
+
+VoiceWindowSpeakerListEntry::VoiceWindowSpeakerListEntry(Snowflake id)
+ : m_main(Gtk::ORIENTATION_VERTICAL)
+ , m_horz(Gtk::ORIENTATION_HORIZONTAL)
+ , m_avatar(32, 32)
+ , m_mute("Mute") {
+ m_name.set_halign(Gtk::ALIGN_START);
+ m_name.set_hexpand(true);
+ m_mute.set_halign(Gtk::ALIGN_END);
+
+ m_volume.set_range(0.0, 200.0);
+ m_volume.set_value_pos(Gtk::POS_LEFT);
+ m_volume.set_value(100.0);
+ m_volume.signal_value_changed().connect([this]() {
+ m_signal_volume.emit(m_volume.get_value() * 0.01);
+ });
+
+ m_horz.add(m_avatar);
+ m_horz.add(m_name);
+ m_horz.add(m_mute);
+ m_main.add(m_horz);
+ m_main.add(m_volume);
+ m_main.add(m_meter);
+ add(m_main);
+ show_all_children();
+
+ auto &discord = Abaddon::Get().GetDiscordClient();
+ const auto user = discord.GetUser(id);
+ if (user.has_value()) {
+ m_name.set_text(user->GetUsername());
+ m_avatar.SetURL(user->GetAvatarURL("png", "32"));
+ } else {
+ m_name.set_text("Unknown user");
+ }
+
+ m_mute.signal_toggled().connect([this]() {
+ m_signal_mute_cs.emit(m_mute.get_active());
+ });
+}
+
+void VoiceWindowSpeakerListEntry::SetVolumeMeter(double frac) {
+ m_meter.SetVolume(frac);
+}
+
+void VoiceWindowSpeakerListEntry::RestoreGain(double frac) {
+ m_volume.set_value(frac * 100.0);
+}
+
+VoiceWindowSpeakerListEntry::type_signal_mute_cs VoiceWindowSpeakerListEntry::signal_mute_cs() {
+ return m_signal_mute_cs;
+}
+
+VoiceWindowSpeakerListEntry::type_signal_volume VoiceWindowSpeakerListEntry::signal_volume() {
+ return m_signal_volume;
+}
diff --git a/src/windows/voice/voicewindowspeakerlistentry.hpp b/src/windows/voice/voicewindowspeakerlistentry.hpp
new file mode 100644
index 0000000..a3b6429
--- /dev/null
+++ b/src/windows/voice/voicewindowspeakerlistentry.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include "components/lazyimage.hpp"
+#include "components/volumemeter.hpp"
+#include "discord/snowflake.hpp"
+
+#include <gtkmm/box.h>
+#include <gtkmm/checkbutton.h>
+#include <gtkmm/label.h>
+#include <gtkmm/listboxrow.h>
+#include <gtkmm/scale.h>
+
+class VoiceWindowSpeakerListEntry : public Gtk::ListBoxRow {
+public:
+ VoiceWindowSpeakerListEntry(Snowflake id);
+
+ void SetVolumeMeter(double frac);
+ void RestoreGain(double frac);
+
+private:
+ Gtk::Box m_main;
+ Gtk::Box m_horz;
+ LazyImage m_avatar;
+ Gtk::Label m_name;
+ Gtk::CheckButton m_mute;
+ Gtk::Scale m_volume;
+ VolumeMeter m_meter;
+
+public:
+ using type_signal_mute_cs = sigc::signal<void(bool)>;
+ using type_signal_volume = sigc::signal<void(double)>;
+ type_signal_mute_cs signal_mute_cs();
+ type_signal_volume signal_volume();
+
+private:
+ type_signal_mute_cs m_signal_mute_cs;
+ type_signal_volume m_signal_volume;
+};