From a51a54bc5979a2491f152abc47ad54e6b63f27c8 Mon Sep 17 00:00:00 2001 From: Dylam De La Torre Date: Tue, 23 Nov 2021 05:21:56 +0100 Subject: Restructure source and resource files (#46) importantly, res is now res/res and css is now res/css --- src/MurmurHash3.cpp | 389 ++++ src/MurmurHash3.h | 37 + src/abaddon.cpp | 733 ++++++++ src/abaddon.hpp | 138 ++ src/components/cellrendererpixbufanimation.cpp | 94 + src/components/cellrendererpixbufanimation.hpp | 41 + src/components/channels.cpp | 1248 +++++++++++++ src/components/channels.hpp | 250 +++ src/components/chatinput.cpp | 66 + src/components/chatinput.hpp | 28 + src/components/chatinputindicator.cpp | 121 ++ src/components/chatinputindicator.hpp | 28 + src/components/chatlist.cpp | 368 ++++ src/components/chatlist.hpp | 115 ++ src/components/chatmessage.cpp | 1245 +++++++++++++ src/components/chatmessage.hpp | 125 ++ src/components/chatwindow.cpp | 239 +++ src/components/chatwindow.hpp | 90 + src/components/completer.cpp | 392 ++++ src/components/completer.hpp | 68 + src/components/draglistbox.cpp | 141 ++ src/components/draglistbox.hpp | 45 + src/components/friendslist.cpp | 354 ++++ src/components/friendslist.hpp | 92 + src/components/lazyimage.cpp | 48 + src/components/lazyimage.hpp | 21 + src/components/memberlist.cpp | 228 +++ src/components/memberlist.hpp | 44 + src/components/ratelimitindicator.cpp | 137 ++ src/components/ratelimitindicator.hpp | 31 + src/components/statusindicator.cpp | 130 ++ src/components/statusindicator.hpp | 30 + src/config.h.in | 1 + src/constants.hpp | 4 + src/dialogs/confirm.cpp | 36 + src/dialogs/confirm.hpp | 15 + src/dialogs/editmessage.cpp | 45 + src/dialogs/editmessage.hpp | 21 + src/dialogs/friendpicker.cpp | 93 + src/dialogs/friendpicker.hpp | 35 + src/dialogs/joinguild.cpp | 97 + src/dialogs/joinguild.hpp | 31 + src/dialogs/setstatus.cpp | 72 + src/dialogs/setstatus.hpp | 22 + src/dialogs/token.cpp | 43 + src/dialogs/token.hpp | 19 + src/dialogs/verificationgate.cpp | 51 + src/dialogs/verificationgate.hpp | 22 + src/discord/activity.cpp | 116 ++ src/discord/activity.hpp | 137 ++ src/discord/auditlog.cpp | 34 + src/discord/auditlog.hpp | 120 ++ src/discord/ban.cpp | 6 + src/discord/ban.hpp | 10 + src/discord/channel.cpp | 97 + src/discord/channel.hpp | 92 + src/discord/discord.cpp | 2310 ++++++++++++++++++++++++ src/discord/discord.hpp | 449 +++++ src/discord/emoji.cpp | 51 + src/discord/emoji.hpp | 25 + src/discord/errors.hpp | 36 + src/discord/guild.cpp | 221 +++ src/discord/guild.hpp | 100 + src/discord/httpclient.cpp | 139 ++ src/discord/httpclient.hpp | 39 + src/discord/interactions.cpp | 11 + src/discord/interactions.hpp | 25 + src/discord/invite.cpp | 39 + src/discord/invite.hpp | 41 + src/discord/json.hpp | 148 ++ src/discord/member.cpp | 40 + src/discord/member.hpp | 27 + src/discord/message.cpp | 265 +++ src/discord/message.hpp | 218 +++ src/discord/objects.cpp | 534 ++++++ src/discord/objects.hpp | 747 ++++++++ src/discord/permissions.cpp | 11 + src/discord/permissions.hpp | 224 +++ src/discord/relationship.cpp | 6 + src/discord/relationship.hpp | 21 + src/discord/role.cpp | 14 + src/discord/role.hpp | 20 + src/discord/snowflake.cpp | 67 + src/discord/snowflake.hpp | 55 + src/discord/sticker.cpp | 52 + src/discord/sticker.hpp | 40 + src/discord/store.cpp | 2232 +++++++++++++++++++++++ src/discord/store.hpp | 302 ++++ src/discord/user.cpp | 197 ++ src/discord/user.hpp | 82 + src/discord/usersettings.cpp | 40 + src/discord/usersettings.hpp | 47 + src/discord/webhook.cpp | 13 + src/discord/webhook.hpp | 24 + src/discord/websocket.cpp | 66 + src/discord/websocket.hpp | 41 + src/emojis.cpp | 118 ++ src/emojis.hpp | 27 + src/filecache.cpp | 226 +++ src/filecache.hpp | 79 + src/http.cpp | 123 ++ src/http.hpp | 129 ++ src/imgmanager.cpp | 123 ++ src/imgmanager.hpp | 38 + src/platform.cpp | 157 ++ src/platform.hpp | 9 + src/settings.cpp | 115 ++ src/settings.hpp | 62 + src/state.cpp | 37 + src/state.hpp | 27 + src/util.cpp | 217 +++ src/util.hpp | 119 ++ src/windows/guildsettings/auditlogpane.cpp | 636 +++++++ src/windows/guildsettings/auditlogpane.hpp | 19 + src/windows/guildsettings/banspane.cpp | 161 ++ src/windows/guildsettings/banspane.hpp | 45 + src/windows/guildsettings/emojispane.cpp | 257 +++ src/windows/guildsettings/emojispane.hpp | 53 + src/windows/guildsettings/infopane.cpp | 220 +++ src/windows/guildsettings/infopane.hpp | 26 + src/windows/guildsettings/invitespane.cpp | 136 ++ src/windows/guildsettings/invitespane.hpp | 43 + src/windows/guildsettings/memberspane.cpp | 410 +++++ src/windows/guildsettings/memberspane.hpp | 135 ++ src/windows/guildsettings/rolespane.cpp | 419 +++++ src/windows/guildsettings/rolespane.hpp | 102 ++ src/windows/guildsettingswindow.cpp | 76 + src/windows/guildsettingswindow.hpp | 30 + src/windows/mainwindow.cpp | 308 ++++ src/windows/mainwindow.hpp | 99 + src/windows/pinnedwindow.cpp | 47 + src/windows/pinnedwindow.hpp | 22 + src/windows/profile/mutualfriendspane.cpp | 58 + src/windows/profile/mutualfriendspane.hpp | 28 + src/windows/profile/mutualguildspane.cpp | 73 + src/windows/profile/mutualguildspane.hpp | 26 + src/windows/profile/userinfopane.cpp | 236 +++ src/windows/profile/userinfopane.hpp | 65 + src/windows/profilewindow.cpp | 127 ++ src/windows/profilewindow.hpp | 31 + src/windows/threadswindow.cpp | 149 ++ src/windows/threadswindow.hpp | 79 + 142 files changed, 23076 insertions(+) create mode 100644 src/MurmurHash3.cpp create mode 100644 src/MurmurHash3.h create mode 100644 src/abaddon.cpp create mode 100644 src/abaddon.hpp create mode 100644 src/components/cellrendererpixbufanimation.cpp create mode 100644 src/components/cellrendererpixbufanimation.hpp create mode 100644 src/components/channels.cpp create mode 100644 src/components/channels.hpp create mode 100644 src/components/chatinput.cpp create mode 100644 src/components/chatinput.hpp create mode 100644 src/components/chatinputindicator.cpp create mode 100644 src/components/chatinputindicator.hpp create mode 100644 src/components/chatlist.cpp create mode 100644 src/components/chatlist.hpp create mode 100644 src/components/chatmessage.cpp create mode 100644 src/components/chatmessage.hpp create mode 100644 src/components/chatwindow.cpp create mode 100644 src/components/chatwindow.hpp create mode 100644 src/components/completer.cpp create mode 100644 src/components/completer.hpp create mode 100644 src/components/draglistbox.cpp create mode 100644 src/components/draglistbox.hpp create mode 100644 src/components/friendslist.cpp create mode 100644 src/components/friendslist.hpp create mode 100644 src/components/lazyimage.cpp create mode 100644 src/components/lazyimage.hpp create mode 100644 src/components/memberlist.cpp create mode 100644 src/components/memberlist.hpp create mode 100644 src/components/ratelimitindicator.cpp create mode 100644 src/components/ratelimitindicator.hpp create mode 100644 src/components/statusindicator.cpp create mode 100644 src/components/statusindicator.hpp create mode 100644 src/config.h.in create mode 100644 src/constants.hpp create mode 100644 src/dialogs/confirm.cpp create mode 100644 src/dialogs/confirm.hpp create mode 100644 src/dialogs/editmessage.cpp create mode 100644 src/dialogs/editmessage.hpp create mode 100644 src/dialogs/friendpicker.cpp create mode 100644 src/dialogs/friendpicker.hpp create mode 100644 src/dialogs/joinguild.cpp create mode 100644 src/dialogs/joinguild.hpp create mode 100644 src/dialogs/setstatus.cpp create mode 100644 src/dialogs/setstatus.hpp create mode 100644 src/dialogs/token.cpp create mode 100644 src/dialogs/token.hpp create mode 100644 src/dialogs/verificationgate.cpp create mode 100644 src/dialogs/verificationgate.hpp create mode 100644 src/discord/activity.cpp create mode 100644 src/discord/activity.hpp create mode 100644 src/discord/auditlog.cpp create mode 100644 src/discord/auditlog.hpp create mode 100644 src/discord/ban.cpp create mode 100644 src/discord/ban.hpp create mode 100644 src/discord/channel.cpp create mode 100644 src/discord/channel.hpp create mode 100644 src/discord/discord.cpp create mode 100644 src/discord/discord.hpp create mode 100644 src/discord/emoji.cpp create mode 100644 src/discord/emoji.hpp create mode 100644 src/discord/errors.hpp create mode 100644 src/discord/guild.cpp create mode 100644 src/discord/guild.hpp create mode 100644 src/discord/httpclient.cpp create mode 100644 src/discord/httpclient.hpp create mode 100644 src/discord/interactions.cpp create mode 100644 src/discord/interactions.hpp create mode 100644 src/discord/invite.cpp create mode 100644 src/discord/invite.hpp create mode 100644 src/discord/json.hpp create mode 100644 src/discord/member.cpp create mode 100644 src/discord/member.hpp create mode 100644 src/discord/message.cpp create mode 100644 src/discord/message.hpp create mode 100644 src/discord/objects.cpp create mode 100644 src/discord/objects.hpp create mode 100644 src/discord/permissions.cpp create mode 100644 src/discord/permissions.hpp create mode 100644 src/discord/relationship.cpp create mode 100644 src/discord/relationship.hpp create mode 100644 src/discord/role.cpp create mode 100644 src/discord/role.hpp create mode 100644 src/discord/snowflake.cpp create mode 100644 src/discord/snowflake.hpp create mode 100644 src/discord/sticker.cpp create mode 100644 src/discord/sticker.hpp create mode 100644 src/discord/store.cpp create mode 100644 src/discord/store.hpp create mode 100644 src/discord/user.cpp create mode 100644 src/discord/user.hpp create mode 100644 src/discord/usersettings.cpp create mode 100644 src/discord/usersettings.hpp create mode 100644 src/discord/webhook.cpp create mode 100644 src/discord/webhook.hpp create mode 100644 src/discord/websocket.cpp create mode 100644 src/discord/websocket.hpp create mode 100644 src/emojis.cpp create mode 100644 src/emojis.hpp create mode 100644 src/filecache.cpp create mode 100644 src/filecache.hpp create mode 100644 src/http.cpp create mode 100644 src/http.hpp create mode 100644 src/imgmanager.cpp create mode 100644 src/imgmanager.hpp create mode 100644 src/platform.cpp create mode 100644 src/platform.hpp create mode 100644 src/settings.cpp create mode 100644 src/settings.hpp create mode 100644 src/state.cpp create mode 100644 src/state.hpp create mode 100644 src/util.cpp create mode 100644 src/util.hpp create mode 100644 src/windows/guildsettings/auditlogpane.cpp create mode 100644 src/windows/guildsettings/auditlogpane.hpp create mode 100644 src/windows/guildsettings/banspane.cpp create mode 100644 src/windows/guildsettings/banspane.hpp create mode 100644 src/windows/guildsettings/emojispane.cpp create mode 100644 src/windows/guildsettings/emojispane.hpp create mode 100644 src/windows/guildsettings/infopane.cpp create mode 100644 src/windows/guildsettings/infopane.hpp create mode 100644 src/windows/guildsettings/invitespane.cpp create mode 100644 src/windows/guildsettings/invitespane.hpp create mode 100644 src/windows/guildsettings/memberspane.cpp create mode 100644 src/windows/guildsettings/memberspane.hpp create mode 100644 src/windows/guildsettings/rolespane.cpp create mode 100644 src/windows/guildsettings/rolespane.hpp create mode 100644 src/windows/guildsettingswindow.cpp create mode 100644 src/windows/guildsettingswindow.hpp create mode 100644 src/windows/mainwindow.cpp create mode 100644 src/windows/mainwindow.hpp create mode 100644 src/windows/pinnedwindow.cpp create mode 100644 src/windows/pinnedwindow.hpp create mode 100644 src/windows/profile/mutualfriendspane.cpp create mode 100644 src/windows/profile/mutualfriendspane.hpp create mode 100644 src/windows/profile/mutualguildspane.cpp create mode 100644 src/windows/profile/mutualguildspane.hpp create mode 100644 src/windows/profile/userinfopane.cpp create mode 100644 src/windows/profile/userinfopane.hpp create mode 100644 src/windows/profilewindow.cpp create mode 100644 src/windows/profilewindow.hpp create mode 100644 src/windows/threadswindow.cpp create mode 100644 src/windows/threadswindow.hpp (limited to 'src') diff --git a/src/MurmurHash3.cpp b/src/MurmurHash3.cpp new file mode 100644 index 0000000..f26ffa1 --- /dev/null +++ b/src/MurmurHash3.cpp @@ -0,0 +1,389 @@ +//----------------------------------------------------------------------------- +// MurmurHash3 was written by Austin Appleby, and is placed in the public +// domain. The author hereby disclaims copyright to this source code. + +// Note - The x86 and x64 versions do _not_ produce the same results, as the +// algorithms are optimized for their respective platforms. You can still +// compile and run any of them on any platform, but your performance with the +// non-native version will be less than optimal. + +#include "MurmurHash3.h" + +//----------------------------------------------------------------------------- +// Platform-specific functions and macros + +// Microsoft Visual Studio + +#if defined(_MSC_VER) + + #define FORCE_INLINE __forceinline + + #include + + #define ROTL32(x, y) _rotl(x, y) + #define ROTL64(x, y) _rotl64(x, y) + + #define BIG_CONSTANT(x) (x) + +// Other compilers + +#else // defined(_MSC_VER) + + #define FORCE_INLINE inline __attribute__((always_inline)) + +inline uint32_t rotl32(uint32_t x, int8_t r) { + return (x << r) | (x >> (32 - r)); +} + +inline uint64_t rotl64(uint64_t x, int8_t r) { + return (x << r) | (x >> (64 - r)); +} + + #define ROTL32(x, y) rotl32(x, y) + #define ROTL64(x, y) rotl64(x, y) + + #define BIG_CONSTANT(x) (x##LLU) + +#endif // !defined(_MSC_VER) + +//----------------------------------------------------------------------------- +// Block read - if your platform needs to do endian-swapping or can only +// handle aligned reads, do the conversion here + +FORCE_INLINE uint32_t getblock32(const uint32_t *p, int i) { + return p[i]; +} + +FORCE_INLINE uint64_t getblock64(const uint64_t *p, int i) { + return p[i]; +} + +//----------------------------------------------------------------------------- +// Finalization mix - force all bits of a hash block to avalanche + +FORCE_INLINE uint32_t fmix32(uint32_t h) { + h ^= h >> 16; + h *= 0x85ebca6b; + h ^= h >> 13; + h *= 0xc2b2ae35; + h ^= h >> 16; + + return h; +} + +//---------- + +FORCE_INLINE uint64_t fmix64(uint64_t k) { + k ^= k >> 33; + k *= BIG_CONSTANT(0xff51afd7ed558ccd); + k ^= k >> 33; + k *= BIG_CONSTANT(0xc4ceb9fe1a85ec53); + k ^= k >> 33; + + return k; +} + +//----------------------------------------------------------------------------- + +void MurmurHash3_x86_32(const void *key, int len, + uint32_t seed, void *out) { + const uint8_t *data = (const uint8_t *)key; + const int nblocks = len / 4; + + uint32_t h1 = seed; + + const uint32_t c1 = 0xcc9e2d51; + const uint32_t c2 = 0x1b873593; + + //---------- + // body + + const uint32_t *blocks = (const uint32_t *)(data + nblocks * 4); + + for (int i = -nblocks; i; i++) { + uint32_t k1 = getblock32(blocks, i); + + k1 *= c1; + k1 = ROTL32(k1, 15); + k1 *= c2; + + h1 ^= k1; + h1 = ROTL32(h1, 13); + h1 = h1 * 5 + 0xe6546b64; + } + + //---------- + // tail + + const uint8_t *tail = (const uint8_t *)(data + nblocks * 4); + + uint32_t k1 = 0; + + switch (len & 3) { + case 3: k1 ^= tail[2] << 16; + case 2: k1 ^= tail[1] << 8; + case 1: + k1 ^= tail[0]; + k1 *= c1; + k1 = ROTL32(k1, 15); + k1 *= c2; + h1 ^= k1; + }; + + //---------- + // finalization + + h1 ^= len; + + h1 = fmix32(h1); + + *(uint32_t *)out = h1; +} + +//----------------------------------------------------------------------------- + +void MurmurHash3_x86_128(const void *key, const int len, + uint32_t seed, void *out) { + const uint8_t *data = (const uint8_t *)key; + const int nblocks = len / 16; + + uint32_t h1 = seed; + uint32_t h2 = seed; + uint32_t h3 = seed; + uint32_t h4 = seed; + + const uint32_t c1 = 0x239b961b; + const uint32_t c2 = 0xab0e9789; + const uint32_t c3 = 0x38b34ae5; + const uint32_t c4 = 0xa1e38b93; + + //---------- + // body + + const uint32_t *blocks = (const uint32_t *)(data + nblocks * 16); + + for (int i = -nblocks; i; i++) { + uint32_t k1 = getblock32(blocks, i * 4 + 0); + uint32_t k2 = getblock32(blocks, i * 4 + 1); + uint32_t k3 = getblock32(blocks, i * 4 + 2); + uint32_t k4 = getblock32(blocks, i * 4 + 3); + + k1 *= c1; + k1 = ROTL32(k1, 15); + k1 *= c2; + h1 ^= k1; + + h1 = ROTL32(h1, 19); + h1 += h2; + h1 = h1 * 5 + 0x561ccd1b; + + k2 *= c2; + k2 = ROTL32(k2, 16); + k2 *= c3; + h2 ^= k2; + + h2 = ROTL32(h2, 17); + h2 += h3; + h2 = h2 * 5 + 0x0bcaa747; + + k3 *= c3; + k3 = ROTL32(k3, 17); + k3 *= c4; + h3 ^= k3; + + h3 = ROTL32(h3, 15); + h3 += h4; + h3 = h3 * 5 + 0x96cd1c35; + + k4 *= c4; + k4 = ROTL32(k4, 18); + k4 *= c1; + h4 ^= k4; + + h4 = ROTL32(h4, 13); + h4 += h1; + h4 = h4 * 5 + 0x32ac3b17; + } + + //---------- + // tail + + const uint8_t *tail = (const uint8_t *)(data + nblocks * 16); + + uint32_t k1 = 0; + uint32_t k2 = 0; + uint32_t k3 = 0; + uint32_t k4 = 0; + + switch (len & 15) { + case 15: k4 ^= tail[14] << 16; + case 14: k4 ^= tail[13] << 8; + case 13: + k4 ^= tail[12] << 0; + k4 *= c4; + k4 = ROTL32(k4, 18); + k4 *= c1; + h4 ^= k4; + + case 12: k3 ^= tail[11] << 24; + case 11: k3 ^= tail[10] << 16; + case 10: k3 ^= tail[9] << 8; + case 9: + k3 ^= tail[8] << 0; + k3 *= c3; + k3 = ROTL32(k3, 17); + k3 *= c4; + h3 ^= k3; + + case 8: k2 ^= tail[7] << 24; + case 7: k2 ^= tail[6] << 16; + case 6: k2 ^= tail[5] << 8; + case 5: + k2 ^= tail[4] << 0; + k2 *= c2; + k2 = ROTL32(k2, 16); + k2 *= c3; + h2 ^= k2; + + case 4: k1 ^= tail[3] << 24; + case 3: k1 ^= tail[2] << 16; + case 2: k1 ^= tail[1] << 8; + case 1: + k1 ^= tail[0] << 0; + k1 *= c1; + k1 = ROTL32(k1, 15); + k1 *= c2; + h1 ^= k1; + }; + + //---------- + // finalization + + h1 ^= len; + h2 ^= len; + h3 ^= len; + h4 ^= len; + + h1 += h2; + h1 += h3; + h1 += h4; + h2 += h1; + h3 += h1; + h4 += h1; + + h1 = fmix32(h1); + h2 = fmix32(h2); + h3 = fmix32(h3); + h4 = fmix32(h4); + + h1 += h2; + h1 += h3; + h1 += h4; + h2 += h1; + h3 += h1; + h4 += h1; + + ((uint32_t *)out)[0] = h1; + ((uint32_t *)out)[1] = h2; + ((uint32_t *)out)[2] = h3; + ((uint32_t *)out)[3] = h4; +} + +//----------------------------------------------------------------------------- + +void MurmurHash3_x64_128(const void *key, const int len, + const uint32_t seed, void *out) { + const uint8_t *data = (const uint8_t *)key; + const int nblocks = len / 16; + + uint64_t h1 = seed; + uint64_t h2 = seed; + + const uint64_t c1 = BIG_CONSTANT(0x87c37b91114253d5); + const uint64_t c2 = BIG_CONSTANT(0x4cf5ad432745937f); + + //---------- + // body + + const uint64_t *blocks = (const uint64_t *)(data); + + for (int i = 0; i < nblocks; i++) { + uint64_t k1 = getblock64(blocks, i * 2 + 0); + uint64_t k2 = getblock64(blocks, i * 2 + 1); + + k1 *= c1; + k1 = ROTL64(k1, 31); + k1 *= c2; + h1 ^= k1; + + h1 = ROTL64(h1, 27); + h1 += h2; + h1 = h1 * 5 + 0x52dce729; + + k2 *= c2; + k2 = ROTL64(k2, 33); + k2 *= c1; + h2 ^= k2; + + h2 = ROTL64(h2, 31); + h2 += h1; + h2 = h2 * 5 + 0x38495ab5; + } + + //---------- + // tail + + const uint8_t *tail = (const uint8_t *)(data + nblocks * 16); + + uint64_t k1 = 0; + uint64_t k2 = 0; + + switch (len & 15) { + case 15: k2 ^= ((uint64_t)tail[14]) << 48; + case 14: k2 ^= ((uint64_t)tail[13]) << 40; + case 13: k2 ^= ((uint64_t)tail[12]) << 32; + case 12: k2 ^= ((uint64_t)tail[11]) << 24; + case 11: k2 ^= ((uint64_t)tail[10]) << 16; + case 10: k2 ^= ((uint64_t)tail[9]) << 8; + case 9: + k2 ^= ((uint64_t)tail[8]) << 0; + k2 *= c2; + k2 = ROTL64(k2, 33); + k2 *= c1; + h2 ^= k2; + + case 8: k1 ^= ((uint64_t)tail[7]) << 56; + case 7: k1 ^= ((uint64_t)tail[6]) << 48; + case 6: k1 ^= ((uint64_t)tail[5]) << 40; + case 5: k1 ^= ((uint64_t)tail[4]) << 32; + case 4: k1 ^= ((uint64_t)tail[3]) << 24; + case 3: k1 ^= ((uint64_t)tail[2]) << 16; + case 2: k1 ^= ((uint64_t)tail[1]) << 8; + case 1: + k1 ^= ((uint64_t)tail[0]) << 0; + k1 *= c1; + k1 = ROTL64(k1, 31); + k1 *= c2; + h1 ^= k1; + }; + + //---------- + // finalization + + h1 ^= len; + h2 ^= len; + + h1 += h2; + h2 += h1; + + h1 = fmix64(h1); + h2 = fmix64(h2); + + h1 += h2; + h2 += h1; + + ((uint64_t *)out)[0] = h1; + ((uint64_t *)out)[1] = h2; +} + +//----------------------------------------------------------------------------- diff --git a/src/MurmurHash3.h b/src/MurmurHash3.h new file mode 100644 index 0000000..c28c12d --- /dev/null +++ b/src/MurmurHash3.h @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------------- +// MurmurHash3 was written by Austin Appleby, and is placed in the public +// domain. The author hereby disclaims copyright to this source code. + +#ifndef _MURMURHASH3_H_ +#define _MURMURHASH3_H_ + +//----------------------------------------------------------------------------- +// Platform-specific functions and macros + +// Microsoft Visual Studio + +#if defined(_MSC_VER) && (_MSC_VER < 1600) + +typedef unsigned char uint8_t; +typedef unsigned int uint32_t; +typedef unsigned __int64 uint64_t; + + // Other compilers + +#else // defined(_MSC_VER) + + #include + +#endif // !defined(_MSC_VER) + +//----------------------------------------------------------------------------- + +void MurmurHash3_x86_32(const void *key, int len, uint32_t seed, void *out); + +void MurmurHash3_x86_128(const void *key, int len, uint32_t seed, void *out); + +void MurmurHash3_x64_128(const void *key, int len, uint32_t seed, void *out); + +//----------------------------------------------------------------------------- + +#endif // _MURMURHASH3_H_ diff --git a/src/abaddon.cpp b/src/abaddon.cpp new file mode 100644 index 0000000..f0f8574 --- /dev/null +++ b/src/abaddon.cpp @@ -0,0 +1,733 @@ +#include +#include +#include +#include +#include "platform.hpp" +#include "discord/discord.hpp" +#include "dialogs/token.hpp" +#include "dialogs/editmessage.hpp" +#include "dialogs/joinguild.hpp" +#include "dialogs/confirm.hpp" +#include "dialogs/setstatus.hpp" +#include "dialogs/friendpicker.hpp" +#include "dialogs/verificationgate.hpp" +#include "abaddon.hpp" +#include "windows/guildsettingswindow.hpp" +#include "windows/profilewindow.hpp" +#include "windows/pinnedwindow.hpp" +#include "windows/threadswindow.hpp" + +#ifdef _WIN32 + #pragma comment(lib, "crypt32.lib") +#endif + +Abaddon::Abaddon() + : m_settings(Platform::FindConfigFile()) + , m_discord(m_settings.GetUseMemoryDB()) // stupid but easy + , m_emojis(GetResPath("/emojis.bin")) { + LoadFromSettings(); + + // todo: set user agent for non-client(?) + std::string ua = m_settings.GetUserAgent(); + m_discord.SetUserAgent(ua); + + m_discord.signal_gateway_ready().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnReady)); + m_discord.signal_message_create().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnMessageCreate)); + m_discord.signal_message_delete().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnMessageDelete)); + m_discord.signal_message_update().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnMessageUpdate)); + m_discord.signal_guild_member_list_update().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnGuildMemberListUpdate)); + m_discord.signal_thread_member_list_update().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnThreadMemberListUpdate)); + m_discord.signal_reaction_add().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnReactionAdd)); + m_discord.signal_reaction_remove().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnReactionRemove)); + m_discord.signal_guild_join_request_create().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnGuildJoinRequestCreate)); + m_discord.signal_thread_update().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnThreadUpdate)); + m_discord.signal_message_sent().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnMessageSent)); + m_discord.signal_disconnected().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnDisconnect)); + if (m_settings.GetPrefetch()) + m_discord.signal_message_create().connect([this](const Message &message) { + if (message.Author.HasAvatar()) + m_img_mgr.Prefetch(message.Author.GetAvatarURL()); + for (const auto &attachment : message.Attachments) { + if (IsURLViewableImage(attachment.ProxyURL)) + m_img_mgr.Prefetch(attachment.ProxyURL); + } + }); +} + +Abaddon::~Abaddon() { + m_settings.Close(); +} + +Abaddon &Abaddon::Get() { + static Abaddon instance; + return instance; +} + +int Abaddon::StartGTK() { + m_gtk_app = Gtk::Application::create("com.github.uowuo.abaddon"); + + m_css_provider = Gtk::CssProvider::create(); + m_css_provider->signal_parsing_error().connect([this](const Glib::RefPtr §ion, const Glib::Error &error) { + Gtk::MessageDialog dlg(*m_main_window, "css failed parsing (" + error.what() + ")", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + }); + + m_css_low_provider = Gtk::CssProvider::create(); + m_css_low_provider->signal_parsing_error().connect([this](const Glib::RefPtr §ion, const Glib::Error &error) { + Gtk::MessageDialog dlg(*m_main_window, "low-priority css failed parsing (" + error.what() + ")", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + }); + + m_main_window = std::make_unique(); + m_main_window->set_title(APP_TITLE); + m_main_window->set_position(Gtk::WIN_POS_CENTER); + + if (!m_settings.IsValid()) { + Gtk::MessageDialog dlg(*m_main_window, "The settings file could not be created!", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + + if (!m_emojis.Load()) { + Gtk::MessageDialog dlg(*m_main_window, "The emoji file couldn't be loaded!", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + + if (!m_discord.IsStoreValid()) { + Gtk::MessageDialog dlg(*m_main_window, "The Discord cache could not be created!", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + return 1; + } + + // store must be checked before this can be called + m_main_window->UpdateComponents(); + + // crashes for some stupid reason if i put it somewhere else + SetupUserMenu(); + + m_main_window->signal_action_connect().connect(sigc::mem_fun(*this, &Abaddon::ActionConnect)); + m_main_window->signal_action_disconnect().connect(sigc::mem_fun(*this, &Abaddon::ActionDisconnect)); + m_main_window->signal_action_set_token().connect(sigc::mem_fun(*this, &Abaddon::ActionSetToken)); + m_main_window->signal_action_reload_css().connect(sigc::mem_fun(*this, &Abaddon::ActionReloadCSS)); + m_main_window->signal_action_join_guild().connect(sigc::mem_fun(*this, &Abaddon::ActionJoinGuildDialog)); + m_main_window->signal_action_set_status().connect(sigc::mem_fun(*this, &Abaddon::ActionSetStatus)); + m_main_window->signal_action_add_recipient().connect(sigc::mem_fun(*this, &Abaddon::ActionAddRecipient)); + m_main_window->signal_action_view_pins().connect(sigc::mem_fun(*this, &Abaddon::ActionViewPins)); + m_main_window->signal_action_view_threads().connect(sigc::mem_fun(*this, &Abaddon::ActionViewThreads)); + + m_main_window->GetChannelList()->signal_action_channel_item_select().connect(sigc::mem_fun(*this, &Abaddon::ActionChannelOpened)); + m_main_window->GetChannelList()->signal_action_guild_leave().connect(sigc::mem_fun(*this, &Abaddon::ActionLeaveGuild)); + m_main_window->GetChannelList()->signal_action_guild_settings().connect(sigc::mem_fun(*this, &Abaddon::ActionGuildSettings)); + + m_main_window->GetChatWindow()->signal_action_message_edit().connect(sigc::mem_fun(*this, &Abaddon::ActionChatEditMessage)); + m_main_window->GetChatWindow()->signal_action_chat_submit().connect(sigc::mem_fun(*this, &Abaddon::ActionChatInputSubmit)); + m_main_window->GetChatWindow()->signal_action_chat_load_history().connect(sigc::mem_fun(*this, &Abaddon::ActionChatLoadHistory)); + m_main_window->GetChatWindow()->signal_action_channel_click().connect(sigc::mem_fun(*this, &Abaddon::ActionChannelOpened)); + m_main_window->GetChatWindow()->signal_action_insert_mention().connect(sigc::mem_fun(*this, &Abaddon::ActionInsertMention)); + m_main_window->GetChatWindow()->signal_action_reaction_add().connect(sigc::mem_fun(*this, &Abaddon::ActionReactionAdd)); + m_main_window->GetChatWindow()->signal_action_reaction_remove().connect(sigc::mem_fun(*this, &Abaddon::ActionReactionRemove)); + + ActionReloadCSS(); + + m_gtk_app->signal_shutdown().connect(sigc::mem_fun(*this, &Abaddon::StopDiscord), false); + + m_main_window->show(); + return m_gtk_app->run(*m_main_window); +} + +void Abaddon::LoadFromSettings() { + std::string token = m_settings.GetDiscordToken(); + if (token.size()) { + m_discord_token = token; + m_discord.UpdateToken(m_discord_token); + } +} + +void Abaddon::StartDiscord() { + m_discord.Start(); +} + +void Abaddon::StopDiscord() { + m_discord.Stop(); + SaveState(); +} + +bool Abaddon::IsDiscordActive() const { + return m_discord.IsStarted(); +} + +std::string Abaddon::GetDiscordToken() const { + return m_discord_token; +} + +DiscordClient &Abaddon::GetDiscordClient() { + std::scoped_lock guard(m_mutex); + return m_discord; +} + +const DiscordClient &Abaddon::GetDiscordClient() const { + std::scoped_lock guard(m_mutex); + return m_discord; +} + +void Abaddon::DiscordOnReady() { + m_main_window->UpdateComponents(); + LoadState(); +} + +void Abaddon::DiscordOnMessageCreate(const Message &message) { + m_main_window->UpdateChatNewMessage(message); +} + +void Abaddon::DiscordOnMessageDelete(Snowflake id, Snowflake channel_id) { + m_main_window->UpdateChatMessageDeleted(id, channel_id); +} + +void Abaddon::DiscordOnMessageUpdate(Snowflake id, Snowflake channel_id) { + m_main_window->UpdateChatMessageUpdated(id, channel_id); +} + +void Abaddon::DiscordOnGuildMemberListUpdate(Snowflake guild_id) { + m_main_window->UpdateMembers(); +} + +void Abaddon::DiscordOnThreadMemberListUpdate(const ThreadMemberListUpdateData &data) { + m_main_window->UpdateMembers(); +} + +void Abaddon::DiscordOnReactionAdd(Snowflake message_id, const Glib::ustring ¶m) { + m_main_window->UpdateChatReactionAdd(message_id, param); +} + +void Abaddon::DiscordOnReactionRemove(Snowflake message_id, const Glib::ustring ¶m) { + m_main_window->UpdateChatReactionAdd(message_id, param); +} + +// this will probably cause issues when actual applications are rolled out but that doesn't seem like it will happen for a while +void Abaddon::DiscordOnGuildJoinRequestCreate(const GuildJoinRequestCreateData &data) { + if (data.Status == GuildApplicationStatus::STARTED) { + ShowGuildVerificationGateDialog(data.GuildID); + } +} + +void Abaddon::DiscordOnMessageSent(const Message &data) { + m_main_window->UpdateChatNewMessage(data); +} + +void Abaddon::DiscordOnDisconnect(bool is_reconnecting, GatewayCloseCode close_code) { + m_channels_history_loaded.clear(); + m_channels_history_loading.clear(); + m_channels_requested.clear(); + if (is_reconnecting) return; + m_main_window->set_title(APP_TITLE); + m_main_window->UpdateComponents(); + + if (close_code == GatewayCloseCode::AuthenticationFailed) { + Gtk::MessageDialog dlg(*m_main_window, "Discord rejected your token", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } else if (close_code != GatewayCloseCode::Normal) { + Gtk::MessageDialog dlg(*m_main_window, + "Lost connection with Discord's gateway. Try reconnecting (code " + std::to_string(static_cast(close_code)) + ")", + false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } +} + +void Abaddon::DiscordOnThreadUpdate(const ThreadUpdateData &data) { + if (data.Thread.ID == m_main_window->GetChatActiveChannel()) { + if (data.Thread.ThreadMetadata->IsArchived) + m_main_window->GetChatWindow()->SetTopic("This thread is archived. Sending a message will unarchive it"); + else + m_main_window->GetChatWindow()->SetTopic(""); + } +} + +const SettingsManager &Abaddon::GetSettings() const { + return m_settings; +} + +Glib::RefPtr Abaddon::GetStyleProvider() { + return m_css_provider; +} + +void Abaddon::ShowUserMenu(const GdkEvent *event, Snowflake id, Snowflake guild_id) { + m_shown_user_menu_id = id; + m_shown_user_menu_guild_id = guild_id; + + const auto guild = m_discord.GetGuild(guild_id); + const auto me = m_discord.GetUserData().ID; + const auto user = m_discord.GetMember(id, guild_id); + + for (const auto child : m_user_menu_roles_submenu->get_children()) + delete child; + if (guild.has_value() && user.has_value()) { + const auto roles = user->GetSortedRoles(); + m_user_menu_roles->set_visible(roles.size() > 0); + for (const auto &role : roles) { + auto *item = Gtk::manage(new Gtk::MenuItem(role.Name)); + if (role.Color != 0) { + Gdk::RGBA color; + color.set_red(((role.Color & 0xFF0000) >> 16) / 255.0); + color.set_green(((role.Color & 0x00FF00) >> 8) / 255.0); + color.set_blue(((role.Color & 0x0000FF) >> 0) / 255.0); + color.set_alpha(1.0); + item->override_color(color); + } + item->show(); + m_user_menu_roles_submenu->append(*item); + } + } else + m_user_menu_roles->set_visible(false); + + if (me == id) { + m_user_menu_ban->set_visible(false); + m_user_menu_kick->set_visible(false); + m_user_menu_open_dm->set_visible(false); + } else { + const bool has_kick = m_discord.HasGuildPermission(me, guild_id, Permission::KICK_MEMBERS); + const bool has_ban = m_discord.HasGuildPermission(me, guild_id, Permission::BAN_MEMBERS); + const bool can_manage = m_discord.CanManageMember(guild_id, me, id); + + m_user_menu_kick->set_visible(has_kick && can_manage); + m_user_menu_ban->set_visible(has_ban && can_manage); + m_user_menu_open_dm->set_visible(true); + } + + m_user_menu_remove_recipient->hide(); + if (me != id) { + const auto channel_id = m_main_window->GetChatActiveChannel(); + const auto channel = m_discord.GetChannel(channel_id); + if (channel.has_value() && channel->Type == ChannelType::GROUP_DM && me == *channel->OwnerID) + m_user_menu_remove_recipient->show(); + } + + m_user_menu->popup_at_pointer(event); +} + +void Abaddon::ShowGuildVerificationGateDialog(Snowflake guild_id) { + VerificationGateDialog dlg(*m_main_window, guild_id); + if (dlg.run() == Gtk::RESPONSE_OK) { + const auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg(*m_main_window, "Failed to accept the verification gate.", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + m_discord.AcceptVerificationGate(guild_id, dlg.GetVerificationGate(), cb); + } +} + +void Abaddon::SetupUserMenu() { + m_user_menu = Gtk::manage(new Gtk::Menu); + m_user_menu_insert_mention = Gtk::manage(new Gtk::MenuItem("Insert Mention")); + m_user_menu_ban = Gtk::manage(new Gtk::MenuItem("Ban")); + m_user_menu_kick = Gtk::manage(new Gtk::MenuItem("Kick")); + m_user_menu_copy_id = Gtk::manage(new Gtk::MenuItem("Copy ID")); + m_user_menu_open_dm = Gtk::manage(new Gtk::MenuItem("Open DM")); + m_user_menu_roles = Gtk::manage(new Gtk::MenuItem("Roles")); + m_user_menu_info = Gtk::manage(new Gtk::MenuItem("View Profile")); + m_user_menu_remove_recipient = Gtk::manage(new Gtk::MenuItem("Remove From Group")); + m_user_menu_roles_submenu = Gtk::manage(new Gtk::Menu); + m_user_menu_roles->set_submenu(*m_user_menu_roles_submenu); + m_user_menu_insert_mention->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_insert_mention)); + m_user_menu_ban->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_ban)); + m_user_menu_kick->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_kick)); + m_user_menu_copy_id->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_copy_id)); + m_user_menu_open_dm->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_open_dm)); + m_user_menu_remove_recipient->signal_activate().connect(sigc::mem_fun(*this, &Abaddon::on_user_menu_remove_recipient)); + m_user_menu_info->signal_activate().connect([this]() { + auto *window = new ProfileWindow(m_shown_user_menu_id); + ManageHeapWindow(window); + window->show(); + }); + + m_user_menu_remove_recipient->override_color(Gdk::RGBA("#BE3C3D")); + + m_user_menu->append(*m_user_menu_info); + m_user_menu->append(*m_user_menu_insert_mention); + m_user_menu->append(*Gtk::manage(new Gtk::SeparatorMenuItem)); + m_user_menu->append(*m_user_menu_ban); + m_user_menu->append(*m_user_menu_kick); + m_user_menu->append(*Gtk::manage(new Gtk::SeparatorMenuItem)); + m_user_menu->append(*m_user_menu_open_dm); + m_user_menu->append(*m_user_menu_roles); + m_user_menu->append(*Gtk::manage(new Gtk::SeparatorMenuItem)); + m_user_menu->append(*m_user_menu_remove_recipient); + m_user_menu->append(*Gtk::manage(new Gtk::SeparatorMenuItem)); + m_user_menu->append(*m_user_menu_copy_id); + + m_user_menu->show_all(); +} + +void Abaddon::SaveState() { + if (!m_settings.GetSaveState()) return; + + AbaddonApplicationState state; + state.ActiveChannel = m_main_window->GetChatActiveChannel(); + state.Expansion = m_main_window->GetChannelList()->GetExpansionState(); + + const auto path = GetStateCachePath(); + if (!util::IsFolder(path)) { + std::error_code ec; + std::filesystem::create_directories(path, ec); + } + + auto *fp = std::fopen(GetStateCachePath("/state.json").c_str(), "wb"); + if (fp == nullptr) return; + const auto s = nlohmann::json(state).dump(4); + std::fwrite(s.c_str(), 1, s.size(), fp); + std::fclose(fp); +} + +void Abaddon::LoadState() { + if (!m_settings.GetSaveState()) return; + + const auto data = ReadWholeFile(GetStateCachePath("/state.json")); + if (data.empty()) return; + try { + AbaddonApplicationState state = nlohmann::json::parse(data.begin(), data.end()); + m_main_window->GetChannelList()->UseExpansionState(state.Expansion); + ActionChannelOpened(state.ActiveChannel); + } catch (const std::exception &e) { + printf("failed to load application state: %s\n", e.what()); + } +} + +void Abaddon::ManageHeapWindow(Gtk::Window *window) { + window->signal_hide().connect([this, window]() { + delete window; + // for some reason if ShowUserMenu is called multiple times with events across windows + // and one of the windows is closed, then it throws errors when the menu is opened again + // i dont think this is my fault so this semi-hacky solution will have to do + delete m_user_menu; + SetupUserMenu(); + }); +} + +void Abaddon::on_user_menu_insert_mention() { + ActionInsertMention(m_shown_user_menu_id); +} + +void Abaddon::on_user_menu_ban() { + ActionBanMember(m_shown_user_menu_id, m_shown_user_menu_guild_id); +} + +void Abaddon::on_user_menu_kick() { + ActionKickMember(m_shown_user_menu_id, m_shown_user_menu_guild_id); +} + +void Abaddon::on_user_menu_copy_id() { + Gtk::Clipboard::get()->set_text(std::to_string(m_shown_user_menu_id)); +} + +void Abaddon::on_user_menu_open_dm() { + const auto existing = m_discord.FindDM(m_shown_user_menu_id); + if (existing.has_value()) + ActionChannelOpened(*existing); + else + m_discord.CreateDM(m_shown_user_menu_id, [this](DiscordError code, Snowflake channel_id) { + if (code == DiscordError::NONE) { + // give the gateway a little window to send CHANNEL_CREATE + auto cb = [this, channel_id] { + ActionChannelOpened(channel_id); + }; + Glib::signal_timeout().connect_once(sigc::track_obj(cb, *this), 200); + } + }); +} + +void Abaddon::on_user_menu_remove_recipient() { + m_discord.RemoveGroupDMRecipient(m_main_window->GetChatActiveChannel(), m_shown_user_menu_id); +} + +std::string Abaddon::GetCSSPath() { + const static auto path = Platform::FindResourceFolder() + "/css"; + return path; +} + +std::string Abaddon::GetResPath() { + const static auto path = Platform::FindResourceFolder() + "/res"; + return path; +} + +std::string Abaddon::GetStateCachePath() { + const static auto path = Platform::FindStateCacheFolder() + "/state"; + return path; +} + +std::string Abaddon::GetCSSPath(const std::string &path) { + return GetCSSPath() + path; +} + +std::string Abaddon::GetResPath(const std::string &path) { + return GetResPath() + path; +} + +std::string Abaddon::GetStateCachePath(const std::string &path) { + return GetStateCachePath() + path; +} + +void Abaddon::ActionConnect() { + if (!m_discord.IsStarted()) + StartDiscord(); + m_main_window->UpdateComponents(); +} + +void Abaddon::ActionDisconnect() { + StopDiscord(); +} + +void Abaddon::ActionSetToken() { + TokenDialog dlg(*m_main_window); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) { + m_discord_token = dlg.GetToken(); + m_discord.UpdateToken(m_discord_token); + m_main_window->UpdateComponents(); + m_settings.SetSetting("discord", "token", m_discord_token); + } +} + +void Abaddon::ActionJoinGuildDialog() { + JoinGuildDialog dlg(*m_main_window); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) { + auto code = dlg.GetCode(); + m_discord.JoinGuild(code); + } +} + +void Abaddon::ActionChannelOpened(Snowflake id) { + if (!id.IsValid() || id == m_main_window->GetChatActiveChannel()) return; + + m_main_window->GetChatWindow()->SetTopic(""); + + const auto channel = m_discord.GetChannel(id); + if (!channel.has_value()) return; + if (channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::GUILD_NEWS) + m_main_window->set_title(std::string(APP_TITLE) + " - #" + *channel->Name); + else { + std::string display; + const auto recipients = channel->GetDMRecipients(); + if (recipients.size() > 1) + display = std::to_string(recipients.size()) + " users"; + else if (recipients.size() == 1) + display = recipients[0].Username; + else + display = "Empty group"; + m_main_window->set_title(std::string(APP_TITLE) + " - " + display); + } + m_main_window->UpdateChatActiveChannel(id); + if (m_channels_requested.find(id) == m_channels_requested.end()) { + m_discord.FetchMessagesInChannel(id, [this, id](const std::vector &msgs) { + m_main_window->UpdateChatWindowContents(); + m_channels_requested.insert(id); + }); + } else { + m_main_window->UpdateChatWindowContents(); + } + + if (channel->IsThread()) { + m_discord.SendThreadLazyLoad(id); + if (channel->ThreadMetadata->IsArchived) + m_main_window->GetChatWindow()->SetTopic("This thread is archived. Sending a message will unarchive it"); + } else if (channel->Type != ChannelType::DM && channel->Type != ChannelType::GROUP_DM && channel->GuildID.has_value()) { + m_discord.SendLazyLoad(id); + + if (m_discord.IsVerificationRequired(*channel->GuildID)) + ShowGuildVerificationGateDialog(*channel->GuildID); + } +} + +void Abaddon::ActionChatLoadHistory(Snowflake id) { + if (m_channels_history_loaded.find(id) != m_channels_history_loaded.end()) + return; + + if (m_channels_history_loading.find(id) != m_channels_history_loading.end()) + return; + + Snowflake before_id = m_main_window->GetChatOldestListedMessage(); + auto msgs = m_discord.GetMessagesBefore(id, before_id); + + if (msgs.size() >= 50) { + m_main_window->UpdateChatPrependHistory(msgs); + return; + } + + m_channels_history_loading.insert(id); + + m_discord.FetchMessagesInChannelBefore(id, before_id, [this, id](const std::vector &msgs) { + m_channels_history_loading.erase(id); + + if (msgs.size() == 0) { + m_channels_history_loaded.insert(id); + } else { + m_main_window->UpdateChatPrependHistory(msgs); + } + }); +} + +void Abaddon::ActionChatInputSubmit(std::string msg, Snowflake channel, Snowflake referenced_message) { + if (msg.substr(0, 7) == "/shrug " || msg == "/shrug") + msg = msg.substr(6) + "\xC2\xAF\x5C\x5F\x28\xE3\x83\x84\x29\x5F\x2F\xC2\xAF"; // this is important + if (referenced_message.IsValid()) + m_discord.SendChatMessage(msg, channel, referenced_message); + else + m_discord.SendChatMessage(msg, channel); +} + +void Abaddon::ActionChatEditMessage(Snowflake channel_id, Snowflake id) { + const auto msg = m_discord.GetMessage(id); + if (!msg.has_value()) return; + EditMessageDialog dlg(*m_main_window); + dlg.SetContent(msg->Content); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) { + auto new_content = dlg.GetContent(); + m_discord.EditMessage(channel_id, id, new_content); + } +} + +void Abaddon::ActionInsertMention(Snowflake id) { + m_main_window->InsertChatInput("<@" + std::to_string(id) + ">"); +} + +void Abaddon::ActionLeaveGuild(Snowflake id) { + ConfirmDialog dlg(*m_main_window); + const auto guild = m_discord.GetGuild(id); + if (guild.has_value()) + dlg.SetConfirmText("Are you sure you want to leave " + guild->Name + "?"); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) + m_discord.LeaveGuild(id); +} + +void Abaddon::ActionKickMember(Snowflake user_id, Snowflake guild_id) { + ConfirmDialog dlg(*m_main_window); + const auto user = m_discord.GetUser(user_id); + if (user.has_value()) + dlg.SetConfirmText("Are you sure you want to kick " + user->Username + "#" + user->Discriminator + "?"); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) + m_discord.KickUser(user_id, guild_id); +} + +void Abaddon::ActionBanMember(Snowflake user_id, Snowflake guild_id) { + ConfirmDialog dlg(*m_main_window); + const auto user = m_discord.GetUser(user_id); + if (user.has_value()) + dlg.SetConfirmText("Are you sure you want to ban " + user->Username + "#" + user->Discriminator + "?"); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) + m_discord.BanUser(user_id, guild_id); +} + +void Abaddon::ActionSetStatus() { + SetStatusDialog dlg(*m_main_window); + const auto response = dlg.run(); + if (response != Gtk::RESPONSE_OK || !m_discord.IsStarted()) return; + const auto status = dlg.GetStatusType(); + const auto activity_type = dlg.GetActivityType(); + const auto activity_name = dlg.GetActivityName(); + if (activity_name == "") { + m_discord.UpdateStatus(status, false); + } else { + ActivityData activity; + activity.Name = activity_name; + activity.Type = activity_type; + m_discord.UpdateStatus(status, false, activity); + } +} + +void Abaddon::ActionReactionAdd(Snowflake id, const Glib::ustring ¶m) { + m_discord.AddReaction(id, param); +} + +void Abaddon::ActionReactionRemove(Snowflake id, const Glib::ustring ¶m) { + m_discord.RemoveReaction(id, param); +} + +void Abaddon::ActionGuildSettings(Snowflake id) { + auto window = new GuildSettingsWindow(id); + ManageHeapWindow(window); + window->show(); +} + +void Abaddon::ActionAddRecipient(Snowflake channel_id) { + FriendPickerDialog dlg(*m_main_window); + auto response = dlg.run(); + if (response == Gtk::RESPONSE_OK) { + auto user_id = dlg.GetUserID(); + m_discord.AddGroupDMRecipient(channel_id, user_id); + } +} + +void Abaddon::ActionViewPins(Snowflake channel_id) { + const auto data = m_discord.GetChannel(channel_id); + if (!data.has_value()) return; + auto window = new PinnedWindow(*data); + ManageHeapWindow(window); + window->show(); +} + +void Abaddon::ActionViewThreads(Snowflake channel_id) { + auto data = m_discord.GetChannel(channel_id); + if (!data.has_value()) return; + if (data->IsThread()) { + data = m_discord.GetChannel(*data->ParentID); + if (!data.has_value()) return; + } + auto window = new ThreadsWindow(*data); + ManageHeapWindow(window); + window->show(); +} + +bool Abaddon::ShowConfirm(const Glib::ustring &prompt, Gtk::Window *window) { + ConfirmDialog dlg(window != nullptr ? *window : *m_main_window); + dlg.SetConfirmText(prompt); + return dlg.run() == Gtk::RESPONSE_OK; +} + +void Abaddon::ActionReloadCSS() { + try { + Gtk::StyleContext::remove_provider_for_screen(Gdk::Screen::get_default(), m_css_provider); + m_css_provider->load_from_path(GetCSSPath("/" + m_settings.GetMainCSS())); + Gtk::StyleContext::add_provider_for_screen(Gdk::Screen::get_default(), m_css_provider, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + + Gtk::StyleContext::remove_provider_for_screen(Gdk::Screen::get_default(), m_css_low_provider); + m_css_low_provider->load_from_path(GetCSSPath("/application-low-priority.css")); + Gtk::StyleContext::add_provider_for_screen(Gdk::Screen::get_default(), m_css_low_provider, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION - 1); + } catch (Glib::Error &e) { + Gtk::MessageDialog dlg(*m_main_window, "css failed to load (" + e.what() + ")", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } +} + +ImageManager &Abaddon::GetImageManager() { + return m_img_mgr; +} + +EmojiResource &Abaddon::GetEmojis() { + return m_emojis; +} + +int main(int argc, char **argv) { + if (std::getenv("ABADDON_NO_FC") == nullptr) + Platform::SetupFonts(); +#if defined(_WIN32) && defined(_MSC_VER) + TCHAR buf[2] { 0 }; + GetEnvironmentVariableA("GTK_CSD", buf, sizeof(buf)); + if (buf[0] != '1') + SetEnvironmentVariableA("GTK_CSD", "0"); +#endif + Gtk::Main::init_gtkmm_internals(); // why??? + return Abaddon::Get().StartGTK(); +} diff --git a/src/abaddon.hpp b/src/abaddon.hpp new file mode 100644 index 0000000..0fb4f1f --- /dev/null +++ b/src/abaddon.hpp @@ -0,0 +1,138 @@ +#include +#include +#include +#include +#include +#include "discord/discord.hpp" +#include "windows/mainwindow.hpp" +#include "settings.hpp" +#include "imgmanager.hpp" +#include "emojis.hpp" + +#define APP_TITLE "Abaddon" + +class Abaddon { +private: + Abaddon(); + ~Abaddon(); + Abaddon(const Abaddon &) = delete; + Abaddon &operator=(const Abaddon &) = delete; + Abaddon(Abaddon &&) = delete; + Abaddon &operator=(Abaddon &&) = delete; + +public: + static Abaddon &Get(); + + int StartGTK(); + void StartDiscord(); + void StopDiscord(); + + void LoadFromSettings(); + + void ActionConnect(); + void ActionDisconnect(); + void ActionSetToken(); + void ActionJoinGuildDialog(); + void ActionChannelOpened(Snowflake id); + void ActionChatInputSubmit(std::string msg, Snowflake channel, Snowflake referenced_message); + void ActionChatLoadHistory(Snowflake id); + void ActionChatEditMessage(Snowflake channel_id, Snowflake id); + void ActionInsertMention(Snowflake id); + void ActionLeaveGuild(Snowflake id); + void ActionKickMember(Snowflake user_id, Snowflake guild_id); + void ActionBanMember(Snowflake user_id, Snowflake guild_id); + void ActionSetStatus(); + void ActionReactionAdd(Snowflake id, const Glib::ustring ¶m); + void ActionReactionRemove(Snowflake id, const Glib::ustring ¶m); + void ActionGuildSettings(Snowflake id); + void ActionAddRecipient(Snowflake channel_id); + void ActionViewPins(Snowflake channel_id); + void ActionViewThreads(Snowflake channel_id); + + bool ShowConfirm(const Glib::ustring &prompt, Gtk::Window *window = nullptr); + + void ActionReloadCSS(); + + ImageManager &GetImageManager(); + EmojiResource &GetEmojis(); + + std::string GetDiscordToken() const; + bool IsDiscordActive() const; + + DiscordClient &GetDiscordClient(); + const DiscordClient &GetDiscordClient() const; + void DiscordOnReady(); + void DiscordOnMessageCreate(const Message &message); + void DiscordOnMessageDelete(Snowflake id, Snowflake channel_id); + void DiscordOnMessageUpdate(Snowflake id, Snowflake channel_id); + void DiscordOnGuildMemberListUpdate(Snowflake guild_id); + void DiscordOnThreadMemberListUpdate(const ThreadMemberListUpdateData &data); + void DiscordOnReactionAdd(Snowflake message_id, const Glib::ustring ¶m); + void DiscordOnReactionRemove(Snowflake message_id, const Glib::ustring ¶m); + void DiscordOnGuildJoinRequestCreate(const GuildJoinRequestCreateData &data); + void DiscordOnMessageSent(const Message &data); + void DiscordOnDisconnect(bool is_reconnecting, GatewayCloseCode close_code); + void DiscordOnThreadUpdate(const ThreadUpdateData &data); + + const SettingsManager &GetSettings() const; + + Glib::RefPtr GetStyleProvider(); + + void ShowUserMenu(const GdkEvent *event, Snowflake id, Snowflake guild_id); + + void ManageHeapWindow(Gtk::Window *window); + + static std::string GetCSSPath(); + static std::string GetResPath(); + static std::string GetStateCachePath(); + static std::string GetCSSPath(const std::string &path); + static std::string GetResPath(const std::string &path); + static std::string GetStateCachePath(const std::string &path); + +protected: + void ShowGuildVerificationGateDialog(Snowflake guild_id); + + void SetupUserMenu(); + void SaveState(); + void LoadState(); + + Snowflake m_shown_user_menu_id; + Snowflake m_shown_user_menu_guild_id; + + Gtk::Menu *m_user_menu; + Gtk::MenuItem *m_user_menu_info; + Gtk::MenuItem *m_user_menu_insert_mention; + Gtk::MenuItem *m_user_menu_ban; + Gtk::MenuItem *m_user_menu_kick; + Gtk::MenuItem *m_user_menu_copy_id; + Gtk::MenuItem *m_user_menu_open_dm; + Gtk::MenuItem *m_user_menu_roles; + Gtk::MenuItem *m_user_menu_remove_recipient; + Gtk::Menu *m_user_menu_roles_submenu; + + void on_user_menu_insert_mention(); + void on_user_menu_ban(); + void on_user_menu_kick(); + void on_user_menu_copy_id(); + void on_user_menu_open_dm(); + void on_user_menu_remove_recipient(); + +private: + SettingsManager m_settings; + + DiscordClient m_discord; + std::string m_discord_token; + + std::unordered_set m_channels_requested; + std::unordered_set m_channels_history_loaded; + std::unordered_set m_channels_history_loading; + + ImageManager m_img_mgr; + EmojiResource m_emojis; + + mutable std::mutex m_mutex; + Glib::RefPtr m_gtk_app; + Glib::RefPtr m_css_provider; + Glib::RefPtr m_css_low_provider; // registered with a lower priority to allow better customization + std::unique_ptr m_main_window; // wah wah cant create a gtkstylecontext fuck you +}; diff --git a/src/components/cellrendererpixbufanimation.cpp b/src/components/cellrendererpixbufanimation.cpp new file mode 100644 index 0000000..2658967 --- /dev/null +++ b/src/components/cellrendererpixbufanimation.cpp @@ -0,0 +1,94 @@ +#include "cellrendererpixbufanimation.hpp" + +CellRendererPixbufAnimation::CellRendererPixbufAnimation() + : Glib::ObjectBase(typeid(CellRendererPixbufAnimation)) + , Gtk::CellRenderer() + , m_property_pixbuf(*this, "pixbuf") + , m_property_pixbuf_animation(*this, "pixbuf-animation") { + property_mode() = Gtk::CELL_RENDERER_MODE_ACTIVATABLE; + property_xpad() = 2; + property_ypad() = 2; +} + +CellRendererPixbufAnimation::~CellRendererPixbufAnimation() {} + +Glib::PropertyProxy> CellRendererPixbufAnimation::property_pixbuf() { + return m_property_pixbuf.get_proxy(); +} + +Glib::PropertyProxy> CellRendererPixbufAnimation::property_pixbuf_animation() { + return m_property_pixbuf_animation.get_proxy(); +} + +void CellRendererPixbufAnimation::get_preferred_width_vfunc(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + int width = 0; + + if (auto pixbuf = m_property_pixbuf_animation.get_value()) + width = pixbuf->get_width(); + else if (auto pixbuf = m_property_pixbuf.get_value()) + width = pixbuf->get_width(); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_width = natural_width = xpad * 2 + width; +} + +void CellRendererPixbufAnimation::get_preferred_width_for_height_vfunc(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + get_preferred_width_vfunc(widget, minimum_width, natural_width); +} + +void CellRendererPixbufAnimation::get_preferred_height_vfunc(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + int height = 0; + + if (auto pixbuf = m_property_pixbuf_animation.get_value()) + height = pixbuf->get_height(); + else if (auto pixbuf = m_property_pixbuf.get_value()) + height = pixbuf->get_height(); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_height = natural_height = ypad * 2 + height; +} + +void CellRendererPixbufAnimation::get_preferred_height_for_width_vfunc(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + get_preferred_height_vfunc(widget, minimum_height, natural_height); +} + +void CellRendererPixbufAnimation::render_vfunc(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags) { + Gtk::Requisition minimum, natural; + get_preferred_size(widget, minimum, natural); + int xpad, ypad; + get_padding(xpad, ypad); + int pix_x = cell_area.get_x() + xpad; + int pix_y = cell_area.get_y() + ypad; + natural.width -= xpad * 2; + natural.height -= ypad * 2; + + Gdk::Rectangle pix_rect(pix_x, pix_y, natural.width, natural.height); + if (!cell_area.intersects(pix_rect)) + return; + + if (auto anim = m_property_pixbuf_animation.get_value()) { + auto map_iter = m_pixbuf_animation_iters.find(anim); + if (map_iter == m_pixbuf_animation_iters.end()) + m_pixbuf_animation_iters[anim] = anim->get_iter(nullptr); + auto pb_iter = m_pixbuf_animation_iters.at(anim); + + const auto cb = [this, &widget, anim] { + if (m_pixbuf_animation_iters.at(anim)->advance()) + widget.queue_draw(); + }; + Glib::signal_timeout().connect_once(sigc::track_obj(cb, widget), pb_iter->get_delay_time()); + Gdk::Cairo::set_source_pixbuf(cr, pb_iter->get_pixbuf(), pix_x, pix_y); + cr->rectangle(pix_x, pix_y, natural.width, natural.height); + cr->fill(); + } else if (auto pixbuf = m_property_pixbuf.get_value()) { + Gdk::Cairo::set_source_pixbuf(cr, pixbuf, pix_x, pix_y); + cr->rectangle(pix_x, pix_y, natural.width, natural.height); + cr->fill(); + } +} diff --git a/src/components/cellrendererpixbufanimation.hpp b/src/components/cellrendererpixbufanimation.hpp new file mode 100644 index 0000000..f47e928 --- /dev/null +++ b/src/components/cellrendererpixbufanimation.hpp @@ -0,0 +1,41 @@ +#pragma once +#include +#include + +// handles both static and animated +class CellRendererPixbufAnimation : public Gtk::CellRenderer { +public: + CellRendererPixbufAnimation(); + virtual ~CellRendererPixbufAnimation(); + + Glib::PropertyProxy> property_pixbuf(); + Glib::PropertyProxy> property_pixbuf_animation(); + +protected: + void get_preferred_width_vfunc(Gtk::Widget &widget, int &minimum_width, int &natural_width) const override; + + void get_preferred_width_for_height_vfunc(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const override; + + void get_preferred_height_vfunc(Gtk::Widget &widget, int &minimum_height, int &natural_height) const override; + + void get_preferred_height_for_width_vfunc(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const override; + + void render_vfunc(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flag) override; + +private: + Glib::Property> m_property_pixbuf; + Glib::Property> m_property_pixbuf_animation; + /* one cellrenderer is used for every animation and i dont know how to + store data per-pixbuf (in this case the iter) so this little map thing will have to do + i would try set_data on the pixbuf but i dont know if that will cause memory leaks + this would mean if a row's pixbuf animation is changed more than once then it wont be released immediately + but thats not a problem for me in this case + + unordered_map doesnt compile cuz theres no hash overload, i guess + */ + std::map, Glib::RefPtr> m_pixbuf_animation_iters; +}; diff --git a/src/components/channels.cpp b/src/components/channels.cpp new file mode 100644 index 0000000..da31de0 --- /dev/null +++ b/src/components/channels.cpp @@ -0,0 +1,1248 @@ +#include "channels.hpp" +#include +#include +#include +#include "abaddon.hpp" +#include "imgmanager.hpp" +#include "util.hpp" +#include "statusindicator.hpp" + +ChannelList::ChannelList() + : Glib::ObjectBase(typeid(ChannelList)) + , Gtk::ScrolledWindow() + , m_model(Gtk::TreeStore::create(m_columns)) + , m_menu_guild_copy_id("_Copy ID", true) + , m_menu_guild_settings("View _Settings", true) + , m_menu_guild_leave("_Leave", true) + , m_menu_category_copy_id("_Copy ID", true) + , m_menu_channel_copy_id("_Copy ID", true) + , m_menu_dm_copy_id("_Copy ID", true) + , m_menu_dm_close("") // changes depending on if group or not + , m_menu_thread_copy_id("_Copy ID", true) + , m_menu_thread_leave("_Leave", true) + , m_menu_thread_archive("_Archive", true) + , m_menu_thread_unarchive("_Unarchive", true) { + get_style_context()->add_class("channel-list"); + + const auto cb = [this](const Gtk::TreeModel::Path &path, Gtk::TreeViewColumn *column) { + auto row = *m_model->get_iter(path); + const auto type = row[m_columns.m_type]; + // text channels should not be allowed to be collapsed + // maybe they should be but it seems a little difficult to handle expansion to permit this + if (type != RenderType::TextChannel) { + if (row[m_columns.m_expanded]) { + m_view.collapse_row(path); + row[m_columns.m_expanded] = false; + } else { + m_view.expand_row(path, false); + row[m_columns.m_expanded] = true; + } + } + + if (type == RenderType::TextChannel || type == RenderType::DM || type == RenderType::Thread) { + m_signal_action_channel_item_select.emit(static_cast(row[m_columns.m_id])); + } + }; + m_view.signal_row_activated().connect(cb, false); + m_view.signal_row_collapsed().connect(sigc::mem_fun(*this, &ChannelList::OnRowCollapsed), false); + m_view.signal_row_expanded().connect(sigc::mem_fun(*this, &ChannelList::OnRowExpanded), false); + m_view.set_activate_on_single_click(true); + m_view.get_selection()->set_mode(Gtk::SELECTION_SINGLE); + m_view.get_selection()->set_select_function(sigc::mem_fun(*this, &ChannelList::SelectionFunc)); + m_view.signal_button_press_event().connect(sigc::mem_fun(*this, &ChannelList::OnButtonPressEvent), false); + + m_view.set_hexpand(true); + m_view.set_vexpand(true); + + m_view.set_show_expanders(false); + m_view.set_enable_search(false); + m_view.set_headers_visible(false); + m_view.set_model(m_model); + m_model->set_sort_column(m_columns.m_sort, Gtk::SORT_ASCENDING); + + m_model->signal_row_inserted().connect([this](const Gtk::TreeModel::Path &path, const Gtk::TreeModel::iterator &iter) { + if (m_updating_listing) return; + if (auto parent = iter->parent(); parent && (*parent)[m_columns.m_expanded]) + m_view.expand_row(m_model->get_path(parent), false); + }); + + m_view.show(); + + add(m_view); + + auto *column = Gtk::manage(new Gtk::TreeView::Column("display")); + auto *renderer = Gtk::manage(new CellRendererChannels); + column->pack_start(*renderer); + column->add_attribute(renderer->property_type(), m_columns.m_type); + column->add_attribute(renderer->property_icon(), m_columns.m_icon); + column->add_attribute(renderer->property_icon_animation(), m_columns.m_icon_anim); + column->add_attribute(renderer->property_name(), m_columns.m_name); + column->add_attribute(renderer->property_expanded(), m_columns.m_expanded); + column->add_attribute(renderer->property_nsfw(), m_columns.m_nsfw); + m_view.append_column(*column); + + m_menu_guild_copy_id.signal_activate().connect([this] { + Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_guild_settings.signal_activate().connect([this] { + m_signal_action_guild_settings.emit(static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_guild_leave.signal_activate().connect([this] { + m_signal_action_guild_leave.emit(static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_guild.append(m_menu_guild_copy_id); + m_menu_guild.append(m_menu_guild_settings); + m_menu_guild.append(m_menu_guild_leave); + m_menu_guild.show_all(); + + m_menu_category_copy_id.signal_activate().connect([this] { + Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_category.append(m_menu_category_copy_id); + m_menu_category.show_all(); + + m_menu_channel_copy_id.signal_activate().connect([this] { + Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_channel.append(m_menu_channel_copy_id); + m_menu_channel.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])); + }); + m_menu_dm_close.signal_activate().connect([this] { + const auto id = static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]); + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto channel = discord.GetChannel(id); + if (!channel.has_value()) return; + + if (channel->Type == ChannelType::DM) + discord.CloseDM(id); + else if (Abaddon::Get().ShowConfirm("Are you sure you want to leave this group DM?")) + Abaddon::Get().GetDiscordClient().CloseDM(id); + }); + m_menu_dm.append(m_menu_dm_copy_id); + m_menu_dm.append(m_menu_dm_close); + m_menu_dm.show_all(); + + m_menu_thread_copy_id.signal_activate().connect([this] { + Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); + }); + m_menu_thread_leave.signal_activate().connect([this] { + if (Abaddon::Get().ShowConfirm("Are you sure you want to leave this thread?")) + Abaddon::Get().GetDiscordClient().LeaveThread(static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]), "Context%20Menu", [](...) {}); + }); + m_menu_thread_archive.signal_activate().connect([this] { + Abaddon::Get().GetDiscordClient().ArchiveThread(static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]), [](...) {}); + }); + m_menu_thread_unarchive.signal_activate().connect([this] { + Abaddon::Get().GetDiscordClient().UnArchiveThread(static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]), [](...) {}); + }); + m_menu_thread.append(m_menu_thread_copy_id); + m_menu_thread.append(m_menu_thread_leave); + m_menu_thread.append(m_menu_thread_archive); + m_menu_thread.append(m_menu_thread_unarchive); + m_menu_thread.show_all(); + + m_menu_thread.signal_popped_up().connect(sigc::mem_fun(*this, &ChannelList::OnThreadSubmenuPopup)); + + auto &discord = Abaddon::Get().GetDiscordClient(); + discord.signal_message_create().connect(sigc::mem_fun(*this, &ChannelList::OnMessageCreate)); + discord.signal_guild_create().connect(sigc::mem_fun(*this, &ChannelList::UpdateNewGuild)); + discord.signal_guild_delete().connect(sigc::mem_fun(*this, &ChannelList::UpdateRemoveGuild)); + discord.signal_channel_delete().connect(sigc::mem_fun(*this, &ChannelList::UpdateRemoveChannel)); + discord.signal_channel_update().connect(sigc::mem_fun(*this, &ChannelList::UpdateChannel)); + discord.signal_channel_create().connect(sigc::mem_fun(*this, &ChannelList::UpdateCreateChannel)); + discord.signal_thread_delete().connect(sigc::mem_fun(*this, &ChannelList::OnThreadDelete)); + discord.signal_thread_update().connect(sigc::mem_fun(*this, &ChannelList::OnThreadUpdate)); + discord.signal_thread_list_sync().connect(sigc::mem_fun(*this, &ChannelList::OnThreadListSync)); + discord.signal_added_to_thread().connect(sigc::mem_fun(*this, &ChannelList::OnThreadJoined)); + discord.signal_removed_from_thread().connect(sigc::mem_fun(*this, &ChannelList::OnThreadRemoved)); + discord.signal_guild_update().connect(sigc::mem_fun(*this, &ChannelList::UpdateGuild)); +} + +void ChannelList::UpdateListing() { + m_updating_listing = true; + + m_model->clear(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + const auto guild_ids = discord.GetUserSortedGuilds(); + int sortnum = 0; + for (const auto &guild_id : guild_ids) { + const auto guild = discord.GetGuild(guild_id); + if (!guild.has_value()) continue; + + auto iter = AddGuild(*guild); + (*iter)[m_columns.m_sort] = sortnum++; + } + + m_updating_listing = false; + + AddPrivateChannels(); +} + +void ChannelList::UpdateNewGuild(const GuildData &guild) { + AddGuild(guild); + // update sort order + int sortnum = 0; + for (const auto guild_id : Abaddon::Get().GetDiscordClient().GetUserSortedGuilds()) { + auto iter = GetIteratorForGuildFromID(guild_id); + if (iter) + (*iter)[m_columns.m_sort] = ++sortnum; + } +} + +void ChannelList::UpdateRemoveGuild(Snowflake id) { + auto iter = GetIteratorForGuildFromID(id); + if (!iter) return; + m_model->erase(iter); +} + +void ChannelList::UpdateRemoveChannel(Snowflake id) { + auto iter = GetIteratorForChannelFromID(id); + if (!iter) return; + m_model->erase(iter); +} + +void ChannelList::UpdateChannel(Snowflake id) { + auto iter = GetIteratorForChannelFromID(id); + auto channel = Abaddon::Get().GetDiscordClient().GetChannel(id); + if (!iter || !channel.has_value()) return; + if (channel->Type == ChannelType::GUILD_CATEGORY) return UpdateChannelCategory(*channel); + if (!IsTextChannel(channel->Type)) return; + + // refresh stuff that might have changed + const bool is_orphan_TMP = !channel->ParentID.has_value(); + (*iter)[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel->Name); + (*iter)[m_columns.m_nsfw] = channel->NSFW(); + (*iter)[m_columns.m_sort] = *channel->Position + (is_orphan_TMP ? OrphanChannelSortOffset : 0); + + // check if the parent has changed + Gtk::TreeModel::iterator new_parent; + if (channel->ParentID.has_value()) + new_parent = GetIteratorForChannelFromID(*channel->ParentID); + else if (channel->GuildID.has_value()) + new_parent = GetIteratorForGuildFromID(*channel->GuildID); + + if (new_parent && iter->parent() != new_parent) + MoveRow(iter, new_parent); +} + +void ChannelList::UpdateCreateChannel(const ChannelData &channel) { + ; + if (channel.Type == ChannelType::GUILD_CATEGORY) return (void)UpdateCreateChannelCategory(channel); + if (channel.Type == ChannelType::DM || channel.Type == ChannelType::GROUP_DM) return UpdateCreateDMChannel(channel); + if (channel.Type != ChannelType::GUILD_TEXT && channel.Type != ChannelType::GUILD_NEWS) return; + + Gtk::TreeRow channel_row; + bool orphan; + if (channel.ParentID.has_value()) { + orphan = false; + auto iter = GetIteratorForChannelFromID(*channel.ParentID); + channel_row = *m_model->append(iter->children()); + } else { + orphan = true; + auto iter = GetIteratorForGuildFromID(*channel.GuildID); + channel_row = *m_model->append(iter->children()); + } + channel_row[m_columns.m_type] = RenderType::TextChannel; + channel_row[m_columns.m_id] = channel.ID; + channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name); + channel_row[m_columns.m_nsfw] = channel.NSFW(); + if (orphan) + channel_row[m_columns.m_sort] = *channel.Position + OrphanChannelSortOffset; + else + channel_row[m_columns.m_sort] = *channel.Position; +} + +void ChannelList::UpdateGuild(Snowflake id) { + auto iter = GetIteratorForGuildFromID(id); + auto &img = Abaddon::Get().GetImageManager(); + const auto guild = Abaddon::Get().GetDiscordClient().GetGuild(id); + if (!iter || !guild.has_value()) return; + + static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + + (*iter)[m_columns.m_name] = "" + Glib::Markup::escape_text(guild->Name) + ""; + (*iter)[m_columns.m_icon] = img.GetPlaceholder(GuildIconSize); + if (show_animations && guild->HasAnimatedIcon()) { + const auto cb = [this, id](const Glib::RefPtr &pb) { + auto iter = GetIteratorForGuildFromID(id); + if (iter) (*iter)[m_columns.m_icon_anim] = pb; + }; + img.LoadAnimationFromURL(guild->GetIconURL("gif", "32"), GuildIconSize, GuildIconSize, sigc::track_obj(cb, *this)); + } else if (guild->HasIcon()) { + const auto cb = [this, id](const Glib::RefPtr &pb) { + // iter might be invalid + auto iter = GetIteratorForGuildFromID(id); + if (iter) (*iter)[m_columns.m_icon] = pb->scale_simple(GuildIconSize, GuildIconSize, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(guild->GetIconURL("png", "32"), sigc::track_obj(cb, *this)); + } +} + +void ChannelList::OnThreadJoined(Snowflake id) { + if (GetIteratorForChannelFromID(id)) return; + const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(id); + if (!channel.has_value()) return; + const auto parent = GetIteratorForChannelFromID(*channel->ParentID); + if (parent) + CreateThreadRow(parent->children(), *channel); +} + +void ChannelList::OnThreadRemoved(Snowflake id) { + DeleteThreadRow(id); +} + +void ChannelList::OnThreadDelete(const ThreadDeleteData &data) { + DeleteThreadRow(data.ID); +} + +// todo probably make the row stick around if its selected until the selection changes +void ChannelList::OnThreadUpdate(const ThreadUpdateData &data) { + auto iter = GetIteratorForChannelFromID(data.Thread.ID); + if (iter) + (*iter)[m_columns.m_name] = "- " + Glib::Markup::escape_text(*data.Thread.Name); + + if (data.Thread.ThreadMetadata->IsArchived) + DeleteThreadRow(data.Thread.ID); +} + +void ChannelList::OnThreadListSync(const ThreadListSyncData &data) { + // get the threads in the guild + std::vector threads; + auto guild_iter = GetIteratorForGuildFromID(data.GuildID); + std::queue queue; + queue.push(guild_iter); + + while (!queue.empty()) { + auto item = queue.front(); + queue.pop(); + if ((*item)[m_columns.m_type] == RenderType::Thread) + threads.push_back(static_cast((*item)[m_columns.m_id])); + for (auto child : item->children()) + queue.push(child); + } + + // delete all threads not present in the synced data + for (auto thread_id : threads) { + if (std::find_if(data.Threads.begin(), data.Threads.end(), [thread_id](const auto &x) { return x.ID == thread_id; }) == data.Threads.end()) { + auto iter = GetIteratorForChannelFromID(thread_id); + m_model->erase(iter); + } + } + + // delete all archived threads + for (auto thread : data.Threads) { + if (thread.ThreadMetadata->IsArchived) { + if (auto iter = GetIteratorForChannelFromID(thread.ID)) + m_model->erase(iter); + } + } +} + +void ChannelList::DeleteThreadRow(Snowflake id) { + auto iter = GetIteratorForChannelFromID(id); + if (iter) + m_model->erase(iter); +} + +// create a temporary channel row for non-joined threads +// and delete them when the active channel switches off of them if still not joined +void ChannelList::SetActiveChannel(Snowflake id) { + if (m_temporary_thread_row) { + const auto thread_id = static_cast((*m_temporary_thread_row)[m_columns.m_id]); + const auto thread = Abaddon::Get().GetDiscordClient().GetChannel(thread_id); + if (thread.has_value() && (!thread->IsJoinedThread() || thread->ThreadMetadata->IsArchived)) + m_model->erase(m_temporary_thread_row); + m_temporary_thread_row = {}; + } + + const auto channel_iter = GetIteratorForChannelFromID(id); + if (channel_iter) { + m_view.expand_to_path(m_model->get_path(channel_iter)); + m_view.get_selection()->select(channel_iter); + } else { + m_view.get_selection()->unselect_all(); + // SetActiveChannel should probably just take the channel object + const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(id); + if (!channel.has_value() || !channel->IsThread()) return; + auto parent_iter = GetIteratorForChannelFromID(*channel->ParentID); + if (!parent_iter) return; + m_temporary_thread_row = CreateThreadRow(parent_iter->children(), *channel); + m_view.get_selection()->select(m_temporary_thread_row); + } +} + +void ChannelList::UseExpansionState(const ExpansionStateRoot &root) { + auto recurse = [this](auto &self, const ExpansionStateRoot &root) -> void { + // and these are only channels + for (const auto &[id, state] : root.Children) { + if (const auto iter = GetIteratorForChannelFromID(id)) { + if (state.IsExpanded) + m_view.expand_row(m_model->get_path(iter), false); + else + m_view.collapse_row(m_model->get_path(iter)); + } + + self(self, state.Children); + } + }; + + // top level is guild + for (const auto &[id, state] : root.Children) { + if (const auto iter = GetIteratorForGuildFromID(id)) { + if (state.IsExpanded) + m_view.expand_row(m_model->get_path(iter), false); + else + m_view.collapse_row(m_model->get_path(iter)); + } + + recurse(recurse, state.Children); + } +} + +ExpansionStateRoot ChannelList::GetExpansionState() const { + ExpansionStateRoot r; + + auto recurse = [this](auto &self, const Gtk::TreeRow &row) -> ExpansionState { + ExpansionState r; + + r.IsExpanded = row[m_columns.m_expanded]; + for (const auto &child : row.children()) + r.Children.Children[static_cast(child[m_columns.m_id])] = self(self, child); + + return r; + }; + + for (const auto &child : m_model->children()) { + const auto id = static_cast(child[m_columns.m_id]); + if (static_cast(id) == 0ULL) continue; // dont save DM header + r.Children[id] = recurse(recurse, child); + } + + return r; +} + +Gtk::TreeModel::iterator ChannelList::AddGuild(const GuildData &guild) { + auto &discord = Abaddon::Get().GetDiscordClient(); + auto &img = Abaddon::Get().GetImageManager(); + + auto guild_row = *m_model->append(); + guild_row[m_columns.m_type] = RenderType::Guild; + guild_row[m_columns.m_id] = guild.ID; + guild_row[m_columns.m_name] = "" + Glib::Markup::escape_text(guild.Name) + ""; + guild_row[m_columns.m_icon] = img.GetPlaceholder(GuildIconSize); + + static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + + if (show_animations && guild.HasAnimatedIcon()) { + const auto cb = [this, id = guild.ID](const Glib::RefPtr &pb) { + auto iter = GetIteratorForGuildFromID(id); + if (iter) (*iter)[m_columns.m_icon_anim] = pb; + }; + img.LoadAnimationFromURL(guild.GetIconURL("gif", "32"), GuildIconSize, GuildIconSize, sigc::track_obj(cb, *this)); + } else if (guild.HasIcon()) { + const auto cb = [this, id = guild.ID](const Glib::RefPtr &pb) { + auto iter = GetIteratorForGuildFromID(id); + if (iter) (*iter)[m_columns.m_icon] = pb->scale_simple(GuildIconSize, GuildIconSize, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(guild.GetIconURL("png", "32"), sigc::track_obj(cb, *this)); + } + + if (!guild.Channels.has_value()) return guild_row; + + // separate out the channels + std::vector orphan_channels; + std::map> categories; + + 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) { + if (channel->ParentID.has_value()) + categories[*channel->ParentID].push_back(*channel); + else + orphan_channels.push_back(*channel); + } else if (channel->Type == ChannelType::GUILD_CATEGORY) { + categories[channel->ID]; + } + } + + std::map> threads; + for (const auto &tmp : *guild.Threads) { + const auto thread = discord.GetChannel(tmp.ID); + if (thread.has_value()) + threads[*thread->ParentID].push_back(*thread); + } + const auto add_threads = [&](const ChannelData &channel, Gtk::TreeRow row) { + row[m_columns.m_expanded] = true; + + const auto it = threads.find(channel.ID); + if (it == threads.end()) return; + + for (const auto &thread : it->second) + CreateThreadRow(row.children(), thread); + }; + + for (const auto &channel : orphan_channels) { + auto channel_row = *m_model->append(guild_row.children()); + channel_row[m_columns.m_type] = RenderType::TextChannel; + channel_row[m_columns.m_id] = channel.ID; + channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name); + channel_row[m_columns.m_sort] = *channel.Position + OrphanChannelSortOffset; + channel_row[m_columns.m_nsfw] = channel.NSFW(); + add_threads(channel, channel_row); + } + + for (const auto &[category_id, channels] : categories) { + const auto category = discord.GetChannel(category_id); + if (!category.has_value()) continue; + auto cat_row = *m_model->append(guild_row.children()); + cat_row[m_columns.m_type] = RenderType::Category; + cat_row[m_columns.m_id] = category_id; + cat_row[m_columns.m_name] = Glib::Markup::escape_text(*category->Name); + cat_row[m_columns.m_sort] = *category->Position; + cat_row[m_columns.m_expanded] = true; + // m_view.expand_row wont work because it might not have channels + + for (const auto &channel : channels) { + auto channel_row = *m_model->append(cat_row.children()); + channel_row[m_columns.m_type] = RenderType::TextChannel; + channel_row[m_columns.m_id] = channel.ID; + channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name); + channel_row[m_columns.m_sort] = *channel.Position; + channel_row[m_columns.m_nsfw] = channel.NSFW(); + add_threads(channel, channel_row); + } + } + + return guild_row; +} + +Gtk::TreeModel::iterator ChannelList::UpdateCreateChannelCategory(const ChannelData &channel) { + const auto iter = GetIteratorForGuildFromID(*channel.GuildID); + if (!iter) return {}; + + auto cat_row = *m_model->append(iter->children()); + cat_row[m_columns.m_type] = RenderType::Category; + cat_row[m_columns.m_id] = channel.ID; + cat_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); + cat_row[m_columns.m_sort] = *channel.Position; + cat_row[m_columns.m_expanded] = true; + + return cat_row; +} + +Gtk::TreeModel::iterator ChannelList::CreateThreadRow(const Gtk::TreeNodeChildren &children, const ChannelData &channel) { + auto thread_iter = m_model->append(children); + auto thread_row = *thread_iter; + thread_row[m_columns.m_type] = RenderType::Thread; + thread_row[m_columns.m_id] = channel.ID; + thread_row[m_columns.m_name] = "- " + Glib::Markup::escape_text(*channel.Name); + thread_row[m_columns.m_sort] = channel.ID; + thread_row[m_columns.m_nsfw] = false; + + return thread_iter; +} + +void ChannelList::UpdateChannelCategory(const ChannelData &channel) { + auto iter = GetIteratorForChannelFromID(channel.ID); + if (!iter) return; + + (*iter)[m_columns.m_sort] = *channel.Position; + (*iter)[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); +} + +Gtk::TreeModel::iterator ChannelList::GetIteratorForGuildFromID(Snowflake id) { + for (const auto &child : m_model->children()) { + if (child[m_columns.m_id] == id) + return child; + } + return {}; +} + +Gtk::TreeModel::iterator ChannelList::GetIteratorForChannelFromID(Snowflake id) { + std::queue queue; + for (const auto &child : m_model->children()) + for (const auto &child2 : child.children()) + queue.push(child2); + + while (!queue.empty()) { + auto item = queue.front(); + if ((*item)[m_columns.m_id] == id) return item; + for (const auto &child : item->children()) + queue.push(child); + queue.pop(); + } + + return {}; +} + +bool ChannelList::IsTextChannel(ChannelType type) { + return type == ChannelType::GUILD_TEXT || type == ChannelType::GUILD_NEWS; +} + +// this should be unncessary but something is behaving strange so its just in case +void ChannelList::OnRowCollapsed(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::Path &path) { + (*iter)[m_columns.m_expanded] = false; +} + +void ChannelList::OnRowExpanded(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::Path &path) { + // restore previous expansion + for (auto it = iter->children().begin(); it != iter->children().end(); it++) { + if ((*it)[m_columns.m_expanded]) + m_view.expand_row(m_model->get_path(it), false); + } + + // try and restore selection if previous collapsed + if (auto selection = m_view.get_selection(); selection && !selection->get_selected()) { + selection->select(m_last_selected); + } + + (*iter)[m_columns.m_expanded] = true; +} + +bool ChannelList::SelectionFunc(const Glib::RefPtr &model, const Gtk::TreeModel::Path &path, bool is_currently_selected) { + if (auto selection = m_view.get_selection()) + if (auto row = selection->get_selected()) + m_last_selected = m_model->get_path(row); + + auto type = (*m_model->get_iter(path))[m_columns.m_type]; + return type == RenderType::TextChannel || type == RenderType::DM || type == RenderType::Thread; +} + +void ChannelList::AddPrivateChannels() { + auto header_row = *m_model->append(); + header_row[m_columns.m_type] = RenderType::DMHeader; + header_row[m_columns.m_sort] = -1; + header_row[m_columns.m_name] = "Direct Messages"; + m_dm_header = m_model->get_path(header_row); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto &img = Abaddon::Get().GetImageManager(); + + const auto dm_ids = discord.GetPrivateChannels(); + for (const auto dm_id : dm_ids) { + const auto dm = discord.GetChannel(dm_id); + if (!dm.has_value()) continue; + + std::optional top_recipient; + const auto recipients = dm->GetDMRecipients(); + if (recipients.size() > 0) + top_recipient = recipients[0]; + + auto iter = m_model->append(header_row->children()); + auto row = *iter; + row[m_columns.m_type] = RenderType::DM; + row[m_columns.m_id] = dm_id; + row[m_columns.m_sort] = -(dm->LastMessageID.has_value() ? *dm->LastMessageID : dm_id); + row[m_columns.m_icon] = img.GetPlaceholder(DMIconSize); + + if (dm->Type == ChannelType::DM && top_recipient.has_value()) + row[m_columns.m_name] = Glib::Markup::escape_text(top_recipient->Username); + else if (dm->Type == ChannelType::GROUP_DM) + row[m_columns.m_name] = std::to_string(recipients.size()) + " members"; + + if (top_recipient.has_value()) { + const auto cb = [this, iter](const Glib::RefPtr &pb) { + if (iter) + (*iter)[m_columns.m_icon] = pb->scale_simple(DMIconSize, DMIconSize, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(top_recipient->GetAvatarURL("png", "32"), sigc::track_obj(cb, *this)); + } + } +} + +void ChannelList::UpdateCreateDMChannel(const ChannelData &dm) { + auto header_row = m_model->get_iter(m_dm_header); + auto &img = Abaddon::Get().GetImageManager(); + + std::optional top_recipient; + const auto recipients = dm.GetDMRecipients(); + if (recipients.size() > 0) + top_recipient = recipients[0]; + + auto iter = m_model->append(header_row->children()); + auto row = *iter; + row[m_columns.m_type] = RenderType::DM; + row[m_columns.m_id] = dm.ID; + row[m_columns.m_sort] = -(dm.LastMessageID.has_value() ? *dm.LastMessageID : dm.ID); + row[m_columns.m_icon] = img.GetPlaceholder(DMIconSize); + + if (dm.Type == ChannelType::DM && top_recipient.has_value()) + row[m_columns.m_name] = Glib::Markup::escape_text(top_recipient->Username); + else if (dm.Type == ChannelType::GROUP_DM) + row[m_columns.m_name] = std::to_string(recipients.size()) + " members"; + + if (top_recipient.has_value()) { + const auto cb = [this, iter](const Glib::RefPtr &pb) { + if (iter) + (*iter)[m_columns.m_icon] = pb->scale_simple(DMIconSize, DMIconSize, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(top_recipient->GetAvatarURL("png", "32"), sigc::track_obj(cb, *this)); + } +} + +void ChannelList::OnMessageCreate(const Message &msg) { + const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(msg.ChannelID); + if (!channel.has_value()) return; + if (channel->Type != ChannelType::DM && channel->Type != ChannelType::GROUP_DM) return; + auto iter = GetIteratorForChannelFromID(msg.ChannelID); + if (iter) + (*iter)[m_columns.m_sort] = -msg.ID; +} + +bool ChannelList::OnButtonPressEvent(GdkEventButton *ev) { + if (ev->button == GDK_BUTTON_SECONDARY && ev->type == GDK_BUTTON_PRESS) { + if (m_view.get_path_at_pos(ev->x, ev->y, m_path_for_menu)) { + auto row = (*m_model->get_iter(m_path_for_menu)); + switch (static_cast(row[m_columns.m_type])) { + case RenderType::Guild: + m_menu_guild.popup_at_pointer(reinterpret_cast(ev)); + break; + case RenderType::Category: + m_menu_category.popup_at_pointer(reinterpret_cast(ev)); + break; + case RenderType::TextChannel: + m_menu_channel.popup_at_pointer(reinterpret_cast(ev)); + break; + case RenderType::DM: { + const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(static_cast(row[m_columns.m_id])); + if (channel.has_value()) { + m_menu_dm_close.set_label(channel->Type == ChannelType::DM ? "Close" : "Leave"); + m_menu_dm_close.show(); + } else + m_menu_dm_close.hide(); + m_menu_dm.popup_at_pointer(reinterpret_cast(ev)); + } break; + case RenderType::Thread: { + m_menu_thread.popup_at_pointer(reinterpret_cast(ev)); + break; + } break; + default: + break; + } + } + return true; + } + return false; +} + +void ChannelList::MoveRow(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::iterator &new_parent) { + // duplicate the row data under the new parent and then delete the old row + auto row = *m_model->append(new_parent->children()); + // would be nice to be able to get all columns out at runtime so i dont need this +#define M(name) \ + row[m_columns.name] = static_cast((*iter)[m_columns.name]); + M(m_type); + M(m_id); + M(m_name); + M(m_icon); + M(m_icon_anim); + M(m_sort); + M(m_nsfw); + M(m_expanded); +#undef M + + // recursively move children + // weird construct to work around iterator invalidation (at least i think thats what the problem was) + const auto tmp = iter->children(); + const auto children = std::vector(tmp.begin(), tmp.end()); + for (size_t i = 0; i < children.size(); i++) + MoveRow(children[i], row); + + // delete original + m_model->erase(iter); +} + +void ChannelList::OnThreadSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) { + m_menu_thread_archive.set_visible(false); + m_menu_thread_unarchive.set_visible(false); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto iter = m_model->get_iter(m_path_for_menu); + if (!iter) return; + auto channel = discord.GetChannel(static_cast((*iter)[m_columns.m_id])); + if (!channel.has_value() || !channel->ThreadMetadata.has_value()) return; + if (!discord.HasGuildPermission(discord.GetUserData().ID, *channel->GuildID, Permission::MANAGE_THREADS)) return; + + m_menu_thread_archive.set_visible(!channel->ThreadMetadata->IsArchived); + m_menu_thread_unarchive.set_visible(channel->ThreadMetadata->IsArchived); +} + +ChannelList::type_signal_action_channel_item_select ChannelList::signal_action_channel_item_select() { + return m_signal_action_channel_item_select; +} + +ChannelList::type_signal_action_guild_leave ChannelList::signal_action_guild_leave() { + return m_signal_action_guild_leave; +} + +ChannelList::type_signal_action_guild_settings ChannelList::signal_action_guild_settings() { + return m_signal_action_guild_settings; +} + +ChannelList::ModelColumns::ModelColumns() { + add(m_type); + add(m_id); + add(m_name); + add(m_icon); + add(m_icon_anim); + add(m_sort); + add(m_nsfw); + add(m_expanded); +} + +CellRendererChannels::CellRendererChannels() + : Glib::ObjectBase(typeid(CellRendererChannels)) + , Gtk::CellRenderer() + , m_property_type(*this, "render-type") + , m_property_name(*this, "name") + , m_property_pixbuf(*this, "pixbuf") + , m_property_pixbuf_animation(*this, "pixbuf-animation") + , m_property_expanded(*this, "expanded") + , m_property_nsfw(*this, "nsfw") { + property_mode() = Gtk::CELL_RENDERER_MODE_ACTIVATABLE; + property_xpad() = 2; + property_ypad() = 2; + m_property_name.get_proxy().signal_changed().connect([this] { + m_renderer_text.property_markup() = m_property_name; + }); +} + +CellRendererChannels::~CellRendererChannels() { +} + +Glib::PropertyProxy CellRendererChannels::property_type() { + return m_property_type.get_proxy(); +} + +Glib::PropertyProxy CellRendererChannels::property_name() { + return m_property_name.get_proxy(); +} + +Glib::PropertyProxy> CellRendererChannels::property_icon() { + return m_property_pixbuf.get_proxy(); +} + +Glib::PropertyProxy> CellRendererChannels::property_icon_animation() { + return m_property_pixbuf_animation.get_proxy(); +} + +Glib::PropertyProxy CellRendererChannels::property_expanded() { + return m_property_expanded.get_proxy(); +} + +Glib::PropertyProxy CellRendererChannels::property_nsfw() { + return m_property_nsfw.get_proxy(); +} + +void CellRendererChannels::get_preferred_width_vfunc(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + switch (m_property_type.get_value()) { + case RenderType::Guild: + return get_preferred_width_vfunc_guild(widget, minimum_width, natural_width); + case RenderType::Category: + return get_preferred_width_vfunc_category(widget, minimum_width, natural_width); + case RenderType::TextChannel: + return get_preferred_width_vfunc_channel(widget, minimum_width, natural_width); + case RenderType::Thread: + return get_preferred_width_vfunc_thread(widget, minimum_width, natural_width); + case RenderType::DMHeader: + return get_preferred_width_vfunc_dmheader(widget, minimum_width, natural_width); + case RenderType::DM: + return get_preferred_width_vfunc_dm(widget, minimum_width, natural_width); + } +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + switch (m_property_type.get_value()) { + case RenderType::Guild: + return get_preferred_width_for_height_vfunc_guild(widget, height, minimum_width, natural_width); + case RenderType::Category: + return get_preferred_width_for_height_vfunc_category(widget, height, minimum_width, natural_width); + case RenderType::TextChannel: + return get_preferred_width_for_height_vfunc_channel(widget, height, minimum_width, natural_width); + case RenderType::Thread: + return get_preferred_width_for_height_vfunc_thread(widget, height, minimum_width, natural_width); + case RenderType::DMHeader: + return get_preferred_width_for_height_vfunc_dmheader(widget, height, minimum_width, natural_width); + case RenderType::DM: + return get_preferred_width_for_height_vfunc_dm(widget, height, minimum_width, natural_width); + } +} + +void CellRendererChannels::get_preferred_height_vfunc(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + switch (m_property_type.get_value()) { + case RenderType::Guild: + return get_preferred_height_vfunc_guild(widget, minimum_height, natural_height); + case RenderType::Category: + return get_preferred_height_vfunc_category(widget, minimum_height, natural_height); + case RenderType::TextChannel: + return get_preferred_height_vfunc_channel(widget, minimum_height, natural_height); + case RenderType::Thread: + return get_preferred_height_vfunc_thread(widget, minimum_height, natural_height); + case RenderType::DMHeader: + return get_preferred_height_vfunc_dmheader(widget, minimum_height, natural_height); + case RenderType::DM: + return get_preferred_height_vfunc_dm(widget, minimum_height, natural_height); + } +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + switch (m_property_type.get_value()) { + case RenderType::Guild: + return get_preferred_height_for_width_vfunc_guild(widget, width, minimum_height, natural_height); + case RenderType::Category: + return get_preferred_height_for_width_vfunc_category(widget, width, minimum_height, natural_height); + case RenderType::TextChannel: + return get_preferred_height_for_width_vfunc_channel(widget, width, minimum_height, natural_height); + case RenderType::Thread: + return get_preferred_height_for_width_vfunc_thread(widget, width, minimum_height, natural_height); + case RenderType::DMHeader: + return get_preferred_height_for_width_vfunc_dmheader(widget, width, minimum_height, natural_height); + case RenderType::DM: + return get_preferred_height_for_width_vfunc_dm(widget, width, minimum_height, natural_height); + } +} + +void CellRendererChannels::render_vfunc(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + switch (m_property_type.get_value()) { + case RenderType::Guild: + return render_vfunc_guild(cr, widget, background_area, cell_area, flags); + case RenderType::Category: + return render_vfunc_category(cr, widget, background_area, cell_area, flags); + case RenderType::TextChannel: + return render_vfunc_channel(cr, widget, background_area, cell_area, flags); + case RenderType::Thread: + return render_vfunc_thread(cr, widget, background_area, cell_area, flags); + case RenderType::DMHeader: + return render_vfunc_dmheader(cr, widget, background_area, cell_area, flags); + case RenderType::DM: + return render_vfunc_dm(cr, widget, background_area, cell_area, flags); + } +} + +// guild functions + +void CellRendererChannels::get_preferred_width_vfunc_guild(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + int pixbuf_width = 0; + + if (auto pixbuf = m_property_pixbuf_animation.get_value()) + pixbuf_width = pixbuf->get_width(); + else if (auto pixbuf = m_property_pixbuf.get_value()) + pixbuf_width = pixbuf->get_width(); + + int text_min, text_nat; + m_renderer_text.get_preferred_width(widget, text_min, text_nat); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_width = std::max(text_min, pixbuf_width) + xpad * 2; + natural_width = std::max(text_nat, pixbuf_width) + xpad * 2; +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_guild(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + get_preferred_width_vfunc_guild(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_guild(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + int pixbuf_height = 0; + if (auto pixbuf = m_property_pixbuf_animation.get_value()) + pixbuf_height = pixbuf->get_height(); + else if (auto pixbuf = m_property_pixbuf.get_value()) + pixbuf_height = pixbuf->get_height(); + + int text_min, text_nat; + m_renderer_text.get_preferred_height(widget, text_min, text_nat); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_height = std::max(text_min, pixbuf_height) + ypad * 2; + natural_height = std::max(text_nat, pixbuf_height) + ypad * 2; +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_guild(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + get_preferred_height_vfunc_guild(widget, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_guild(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + Gtk::Requisition text_minimum, text_natural; + m_renderer_text.get_preferred_size(widget, text_minimum, text_natural); + + Gtk::Requisition minimum, natural; + get_preferred_size(widget, minimum, natural); + + int pixbuf_w, pixbuf_h = 0; + if (auto pixbuf = m_property_pixbuf_animation.get_value()) { + pixbuf_w = pixbuf->get_width(); + pixbuf_h = pixbuf->get_height(); + } else if (auto pixbuf = m_property_pixbuf.get_value()) { + pixbuf_w = pixbuf->get_width(); + pixbuf_h = pixbuf->get_height(); + } + + const double icon_w = pixbuf_w; + const double icon_h = pixbuf_h; + const double icon_x = background_area.get_x(); + const double icon_y = background_area.get_y() + background_area.get_height() / 2.0 - icon_h / 2.0; + + const double text_x = icon_x + icon_w + 5.0; + const double text_y = background_area.get_y() + background_area.get_height() / 2.0 - text_natural.height / 2.0; + const double text_w = text_natural.width; + const double text_h = text_natural.height; + + Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h); + + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); + + const static bool hover_only = Abaddon::Get().GetSettings().GetAnimatedGuildHoverOnly(); + const bool is_hovered = flags & Gtk::CELL_RENDERER_PRELIT; + auto anim = m_property_pixbuf_animation.get_value(); + + // kinda gross + if (anim) { + auto map_iter = m_pixbuf_anim_iters.find(anim); + if (map_iter == m_pixbuf_anim_iters.end()) + m_pixbuf_anim_iters[anim] = anim->get_iter(nullptr); + auto pb_iter = m_pixbuf_anim_iters.at(anim); + + const auto cb = [this, &widget, anim, icon_x, icon_y, icon_w, icon_h] { + if (m_pixbuf_anim_iters.at(anim)->advance()) + widget.queue_draw_area(icon_x, icon_y, icon_w, icon_h); + }; + + if ((hover_only && is_hovered) || !hover_only) + Glib::signal_timeout().connect_once(sigc::track_obj(cb, widget), pb_iter->get_delay_time()); + if (hover_only && !is_hovered) + m_pixbuf_anim_iters[anim] = anim->get_iter(nullptr); + + Gdk::Cairo::set_source_pixbuf(cr, pb_iter->get_pixbuf(), icon_x, icon_y); + cr->rectangle(icon_x, icon_y, icon_w, icon_h); + cr->fill(); + } else if (auto pixbuf = m_property_pixbuf.get_value()) { + Gdk::Cairo::set_source_pixbuf(cr, pixbuf, icon_x, icon_y); + cr->rectangle(icon_x, icon_y, icon_w, icon_h); + cr->fill(); + } +} + +// category + +void CellRendererChannels::get_preferred_width_vfunc_category(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_category(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width_for_height(widget, height, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_category(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height(widget, minimum_height, natural_height); +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_category(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height_for_width(widget, width, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_category(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + // todo: figure out how Gtk::Arrow is rendered because i like it better :^) + constexpr static int len = 5; + int x1, y1, x2, y2, x3, y3; + if (property_expanded()) { + x1 = background_area.get_x() + 7; + y1 = background_area.get_y() + background_area.get_height() / 2 - len; + x2 = background_area.get_x() + 7 + len; + y2 = background_area.get_y() + background_area.get_height() / 2 + len; + x3 = background_area.get_x() + 7 + len * 2; + y3 = background_area.get_y() + background_area.get_height() / 2 - len; + } else { + x1 = background_area.get_x() + 7; + y1 = background_area.get_y() + background_area.get_height() / 2 - len; + x2 = background_area.get_x() + 7 + len * 2; + y2 = background_area.get_y() + background_area.get_height() / 2; + x3 = background_area.get_x() + 7; + y3 = background_area.get_y() + background_area.get_height() / 2 + len; + } + cr->move_to(x1, y1); + cr->line_to(x2, y2); + cr->line_to(x3, y3); + static const auto expander_color = Gdk::RGBA(Abaddon::Get().GetSettings().GetChannelsExpanderColor()); + cr->set_source_rgb(expander_color.get_red(), expander_color.get_green(), expander_color.get_blue()); + cr->stroke(); + + Gtk::Requisition text_minimum, text_natural; + m_renderer_text.get_preferred_size(widget, text_minimum, text_natural); + + const int text_x = background_area.get_x() + 22; + const int text_y = background_area.get_y() + background_area.get_height() / 2 - text_natural.height / 2; + const int text_w = text_natural.width; + const int text_h = text_natural.height; + + Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h); + + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); +} + +// text channel + +void CellRendererChannels::get_preferred_width_vfunc_channel(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_channel(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width_for_height(widget, height, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_channel(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height(widget, minimum_height, natural_height); +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_channel(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height_for_width(widget, width, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_channel(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + Gtk::Requisition minimum_size, natural_size; + m_renderer_text.get_preferred_size(widget, minimum_size, natural_size); + + const int text_x = background_area.get_x() + 21; + const int text_y = background_area.get_y() + background_area.get_height() / 2 - natural_size.height / 2; + const int text_w = natural_size.width; + const int text_h = natural_size.height; + + Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h); + + const static auto nsfw_color = Gdk::RGBA(Abaddon::Get().GetSettings().GetNSFWChannelColor()); + if (m_property_nsfw.get_value()) + m_renderer_text.property_foreground_rgba() = nsfw_color; + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); + // setting property_foreground_rgba() sets this to true which makes non-nsfw cells use the property too which is bad + // so unset it + m_renderer_text.property_foreground_set() = false; +} + +// thread + +void CellRendererChannels::get_preferred_width_vfunc_thread(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_thread(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + get_preferred_width_vfunc_thread(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_thread(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height(widget, minimum_height, natural_height); +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_thread(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + get_preferred_height_vfunc_thread(widget, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_thread(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + Gtk::Requisition minimum_size, natural_size; + m_renderer_text.get_preferred_size(widget, minimum_size, natural_size); + + const int text_x = background_area.get_x() + 26; + const int text_y = background_area.get_y() + background_area.get_height() / 2 - natural_size.height / 2; + const int text_w = natural_size.width; + const int text_h = natural_size.height; + + Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h); + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); +} + +// dm header + +void CellRendererChannels::get_preferred_width_vfunc_dmheader(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_dmheader(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + m_renderer_text.get_preferred_width_for_height(widget, height, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_dmheader(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height(widget, minimum_height, natural_height); +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_dmheader(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + m_renderer_text.get_preferred_height_for_width(widget, width, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_dmheader(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + // gdk::rectangle more like gdk::stupid + Gdk::Rectangle text_cell_area( + cell_area.get_x() + 9, cell_area.get_y(), // maybe theres a better way to align this ? + cell_area.get_width(), cell_area.get_height()); + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); +} + +// dm (basically the same thing as guild) + +void CellRendererChannels::get_preferred_width_vfunc_dm(Gtk::Widget &widget, int &minimum_width, int &natural_width) const { + int pixbuf_width = 0; + if (auto pixbuf = m_property_pixbuf.get_value()) + pixbuf_width = pixbuf->get_width(); + + int text_min, text_nat; + m_renderer_text.get_preferred_width(widget, text_min, text_nat); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_width = std::max(text_min, pixbuf_width) + xpad * 2; + natural_width = std::max(text_nat, pixbuf_width) + xpad * 2; +} + +void CellRendererChannels::get_preferred_width_for_height_vfunc_dm(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const { + get_preferred_width_vfunc_guild(widget, minimum_width, natural_width); +} + +void CellRendererChannels::get_preferred_height_vfunc_dm(Gtk::Widget &widget, int &minimum_height, int &natural_height) const { + int pixbuf_height = 0; + if (auto pixbuf = m_property_pixbuf.get_value()) + pixbuf_height = pixbuf->get_height(); + + int text_min, text_nat; + m_renderer_text.get_preferred_height(widget, text_min, text_nat); + + int xpad, ypad; + get_padding(xpad, ypad); + minimum_height = std::max(text_min, pixbuf_height) + ypad * 2; + natural_height = std::max(text_nat, pixbuf_height) + ypad * 2; +} + +void CellRendererChannels::get_preferred_height_for_width_vfunc_dm(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const { + get_preferred_height_vfunc_guild(widget, minimum_height, natural_height); +} + +void CellRendererChannels::render_vfunc_dm(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { + Gtk::Requisition text_minimum, text_natural; + m_renderer_text.get_preferred_size(widget, text_minimum, text_natural); + + Gtk::Requisition minimum, natural; + get_preferred_size(widget, minimum, natural); + + auto pixbuf = m_property_pixbuf.get_value(); + + const double icon_w = pixbuf->get_width(); + const double icon_h = pixbuf->get_height(); + const double icon_x = background_area.get_x() + 2; + const double icon_y = background_area.get_y() + background_area.get_height() / 2.0 - icon_h / 2.0; + + const double text_x = icon_x + icon_w + 5.0; + const double text_y = background_area.get_y() + background_area.get_height() / 2.0 - text_natural.height / 2.0; + const double text_w = text_natural.width; + const double text_h = text_natural.height; + + Gdk::Rectangle text_cell_area(text_x, text_y, text_w, text_h); + + m_renderer_text.render(cr, widget, background_area, text_cell_area, flags); + + Gdk::Cairo::set_source_pixbuf(cr, m_property_pixbuf.get_value(), icon_x, icon_y); + cr->rectangle(icon_x, icon_y, icon_w, icon_h); + cr->fill(); +} diff --git a/src/components/channels.hpp b/src/components/channels.hpp new file mode 100644 index 0000000..1faf367 --- /dev/null +++ b/src/components/channels.hpp @@ -0,0 +1,250 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "discord/discord.hpp" +#include "state.hpp" + +constexpr static int GuildIconSize = 24; +constexpr static int DMIconSize = 20; +constexpr static int OrphanChannelSortOffset = -100; // forces orphan channels to the top of the list + +enum class RenderType : uint8_t { + Guild, + Category, + TextChannel, + Thread, + + DMHeader, + DM, +}; + +class CellRendererChannels : public Gtk::CellRenderer { +public: + CellRendererChannels(); + virtual ~CellRendererChannels(); + + Glib::PropertyProxy property_type(); + Glib::PropertyProxy property_name(); + Glib::PropertyProxy> property_icon(); + Glib::PropertyProxy> property_icon_animation(); + Glib::PropertyProxy property_expanded(); + Glib::PropertyProxy property_nsfw(); + +protected: + void get_preferred_width_vfunc(Gtk::Widget &widget, int &minimum_width, int &natural_width) const override; + void get_preferred_width_for_height_vfunc(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const override; + void get_preferred_height_vfunc(Gtk::Widget &widget, int &minimum_height, int &natural_height) const override; + void get_preferred_height_for_width_vfunc(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const override; + void render_vfunc(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags) override; + + // guild functions + void get_preferred_width_vfunc_guild(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_guild(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_guild(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_guild(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_guild(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + + // category + void get_preferred_width_vfunc_category(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_category(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_category(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_category(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_category(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + + // text channel + void get_preferred_width_vfunc_channel(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_channel(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_channel(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_channel(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_channel(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + + // thread + void get_preferred_width_vfunc_thread(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_thread(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_thread(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_thread(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_thread(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + + // dm header + void get_preferred_width_vfunc_dmheader(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_dmheader(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_dmheader(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_dmheader(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_dmheader(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + + // dm + void get_preferred_width_vfunc_dm(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; + void get_preferred_width_for_height_vfunc_dm(Gtk::Widget &widget, int height, int &minimum_width, int &natural_width) const; + void get_preferred_height_vfunc_dm(Gtk::Widget &widget, int &minimum_height, int &natural_height) const; + void get_preferred_height_for_width_vfunc_dm(Gtk::Widget &widget, int width, int &minimum_height, int &natural_height) const; + void render_vfunc_dm(const Cairo::RefPtr &cr, + Gtk::Widget &widget, + const Gdk::Rectangle &background_area, + const Gdk::Rectangle &cell_area, + Gtk::CellRendererState flags); + +private: + Gtk::CellRendererText m_renderer_text; + + Glib::Property m_property_type; // all + Glib::Property m_property_name; // all + Glib::Property> m_property_pixbuf; // guild, dm + Glib::Property> m_property_pixbuf_animation; // guild + Glib::Property m_property_expanded; // category + Glib::Property m_property_nsfw; // channel + + // same pitfalls as in https://github.com/uowuo/abaddon/blob/60404783bd4ce9be26233fe66fc3a74475d9eaa3/components/cellrendererpixbufanimation.hpp#L32-L39 + // this will manifest though since guild icons can change + // an animation or two wont be the end of the world though + std::map, Glib::RefPtr> m_pixbuf_anim_iters; +}; + +class ChannelList : public Gtk::ScrolledWindow { +public: + ChannelList(); + + void UpdateListing(); + void SetActiveChannel(Snowflake id); + + // channel list should be populated when this is called + void UseExpansionState(const ExpansionStateRoot &state); + ExpansionStateRoot GetExpansionState() const; + +protected: + void UpdateNewGuild(const GuildData &guild); + void UpdateRemoveGuild(Snowflake id); + void UpdateRemoveChannel(Snowflake id); + void UpdateChannel(Snowflake id); + void UpdateCreateChannel(const ChannelData &channel); + void UpdateGuild(Snowflake id); + void DeleteThreadRow(Snowflake id); + + void OnThreadJoined(Snowflake id); + void OnThreadRemoved(Snowflake id); + void OnThreadDelete(const ThreadDeleteData &data); + void OnThreadUpdate(const ThreadUpdateData &data); + void OnThreadListSync(const ThreadListSyncData &data); + + Gtk::TreeView m_view; + + class ModelColumns : public Gtk::TreeModel::ColumnRecord { + public: + ModelColumns(); + + Gtk::TreeModelColumn m_type; + Gtk::TreeModelColumn m_id; + Gtk::TreeModelColumn m_name; + Gtk::TreeModelColumn> m_icon; + Gtk::TreeModelColumn> m_icon_anim; + Gtk::TreeModelColumn m_sort; + Gtk::TreeModelColumn m_nsfw; + // Gtk::CellRenderer's property_is_expanded only works how i want it to if it has children + // because otherwise it doesnt count as an "expander" (property_is_expander) + // so this solution will have to do which i hate but the alternative is adding invisible children + // to all categories without children and having a filter model but that sounds worse + // of course its a lot better than the absolute travesty i had before + Gtk::TreeModelColumn m_expanded; + }; + + ModelColumns m_columns; + Glib::RefPtr m_model; + + Gtk::TreeModel::iterator AddGuild(const GuildData &guild); + Gtk::TreeModel::iterator UpdateCreateChannelCategory(const ChannelData &channel); + Gtk::TreeModel::iterator CreateThreadRow(const Gtk::TreeNodeChildren &children, const ChannelData &channel); + + void UpdateChannelCategory(const ChannelData &channel); + + // separation necessary because a channel and guild can share the same id + Gtk::TreeModel::iterator GetIteratorForGuildFromID(Snowflake id); + Gtk::TreeModel::iterator GetIteratorForChannelFromID(Snowflake id); + + bool IsTextChannel(ChannelType type); + + void OnRowCollapsed(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::Path &path); + void OnRowExpanded(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::Path &path); + bool SelectionFunc(const Glib::RefPtr &model, const Gtk::TreeModel::Path &path, bool is_currently_selected); + bool OnButtonPressEvent(GdkEventButton *ev); + + void MoveRow(const Gtk::TreeModel::iterator &iter, const Gtk::TreeModel::iterator &new_parent); + + Gtk::TreeModel::Path m_last_selected; + Gtk::TreeModel::Path m_dm_header; + + void AddPrivateChannels(); + void UpdateCreateDMChannel(const ChannelData &channel); + + void OnMessageCreate(const Message &msg); + Gtk::TreeModel::Path m_path_for_menu; + + // cant be recovered through selection + Gtk::TreeModel::iterator m_temporary_thread_row; + + Gtk::Menu m_menu_guild; + Gtk::MenuItem m_menu_guild_copy_id; + Gtk::MenuItem m_menu_guild_settings; + Gtk::MenuItem m_menu_guild_leave; + + Gtk::Menu m_menu_category; + Gtk::MenuItem m_menu_category_copy_id; + + Gtk::Menu m_menu_channel; + Gtk::MenuItem m_menu_channel_copy_id; + + Gtk::Menu m_menu_dm; + Gtk::MenuItem m_menu_dm_copy_id; + Gtk::MenuItem m_menu_dm_close; + + Gtk::Menu m_menu_thread; + Gtk::MenuItem m_menu_thread_copy_id; + Gtk::MenuItem m_menu_thread_leave; + Gtk::MenuItem m_menu_thread_archive; + Gtk::MenuItem m_menu_thread_unarchive; + + void OnThreadSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y); + + bool m_updating_listing = false; + +public: + typedef sigc::signal type_signal_action_channel_item_select; + typedef sigc::signal type_signal_action_guild_leave; + typedef sigc::signal type_signal_action_guild_settings; + + type_signal_action_channel_item_select signal_action_channel_item_select(); + type_signal_action_guild_leave signal_action_guild_leave(); + type_signal_action_guild_settings signal_action_guild_settings(); + +protected: + type_signal_action_channel_item_select m_signal_action_channel_item_select; + type_signal_action_guild_leave m_signal_action_guild_leave; + type_signal_action_guild_settings m_signal_action_guild_settings; +}; diff --git a/src/components/chatinput.cpp b/src/components/chatinput.cpp new file mode 100644 index 0000000..c3eca32 --- /dev/null +++ b/src/components/chatinput.cpp @@ -0,0 +1,66 @@ +#include "chatinput.hpp" + +ChatInput::ChatInput() { + get_style_context()->add_class("message-input"); + set_propagate_natural_height(true); + set_min_content_height(20); + set_max_content_height(250); + set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + + // hack + auto cb = [this](GdkEventKey *e) -> bool { + return event(reinterpret_cast(e)); + }; + m_textview.signal_key_press_event().connect(cb, false); + m_textview.set_hexpand(false); + m_textview.set_halign(Gtk::ALIGN_FILL); + m_textview.set_valign(Gtk::ALIGN_CENTER); + m_textview.set_wrap_mode(Gtk::WRAP_WORD_CHAR); + m_textview.show(); + add(m_textview); +} + +void ChatInput::InsertText(const Glib::ustring &text) { + GetBuffer()->insert_at_cursor(text); + m_textview.grab_focus(); +} + +Glib::RefPtr ChatInput::GetBuffer() { + return m_textview.get_buffer(); +} + +// this isnt connected directly so that the chat window can handle stuff like the completer first +bool ChatInput::ProcessKeyPress(GdkEventKey *event) { + if (event->keyval == GDK_KEY_Escape) { + m_signal_escape.emit(); + return true; + } + + if (event->keyval == GDK_KEY_Return) { + if (event->state & GDK_SHIFT_MASK) + return false; + + auto buf = GetBuffer(); + auto text = buf->get_text(); + + const bool accepted = m_signal_submit.emit(text); + if (accepted) + buf->set_text(""); + + return true; + } + + return false; +} + +void ChatInput::on_grab_focus() { + m_textview.grab_focus(); +} + +ChatInput::type_signal_submit ChatInput::signal_submit() { + return m_signal_submit; +} + +ChatInput::type_signal_escape ChatInput::signal_escape() { + return m_signal_escape; +} diff --git a/src/components/chatinput.hpp b/src/components/chatinput.hpp new file mode 100644 index 0000000..ad7f0b1 --- /dev/null +++ b/src/components/chatinput.hpp @@ -0,0 +1,28 @@ +#pragma once +#include + +class ChatInput : public Gtk::ScrolledWindow { +public: + ChatInput(); + + void InsertText(const Glib::ustring &text); + Glib::RefPtr GetBuffer(); + bool ProcessKeyPress(GdkEventKey *event); + +protected: + void on_grab_focus() override; + +private: + Gtk::TextView m_textview; + +public: + typedef sigc::signal type_signal_submit; + typedef sigc::signal type_signal_escape; + + type_signal_submit signal_submit(); + type_signal_escape signal_escape(); + +private: + type_signal_submit m_signal_submit; + type_signal_escape m_signal_escape; +}; diff --git a/src/components/chatinputindicator.cpp b/src/components/chatinputindicator.cpp new file mode 100644 index 0000000..9b063b2 --- /dev/null +++ b/src/components/chatinputindicator.cpp @@ -0,0 +1,121 @@ +#include +#include "chatinputindicator.hpp" +#include "abaddon.hpp" +#include "util.hpp" + +constexpr static const int MaxUsersInIndicator = 4; + +ChatInputIndicator::ChatInputIndicator() + : 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, &ChatInputIndicator::OnUserTypingStart)); + Abaddon::Get().GetDiscordClient().signal_message_create().connect(sigc::mem_fun(*this, &ChatInputIndicator::OnMessageCreate)); + + add(m_img); + add(m_label); + m_label.show(); + + // try loading gif + const static auto path = Abaddon::GetResPath("/typing_indicator.gif"); + if (!std::filesystem::exists(path)) return; + auto gif_data = ReadWholeFile(path); + 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 ChatInputIndicator::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 ChatInputIndicator::SetActiveChannel(Snowflake id) { + m_active_channel = id; + ComputeTypingString(); +} + +void ChatInputIndicator::SetCustomMarkup(const Glib::ustring &str) { + m_custom_markup = str; + ComputeTypingString(); +} + +void ChatInputIndicator::ClearCustom() { + m_custom_markup = ""; + ComputeTypingString(); +} + +void ChatInputIndicator::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 ChatInputIndicator::OnMessageCreate(const Message &message) { + m_typers[message.ChannelID].erase(message.Author.ID); + ComputeTypingString(); +} + +void ChatInputIndicator::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 ChatInputIndicator::ComputeTypingString() { + if (m_custom_markup != "") { + m_label.set_markup(m_custom_markup); + m_img.hide(); + return; + } + + const auto &discord = Abaddon::Get().GetDiscordClient(); + std::vector 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 (size_t i = 0; i < typers.size() - 1; i++) + str += typers[i].Username + ", "; + SetTypingString(str + "and " + typers[typers.size() - 1].Username + " are typing..."); + } else { // size() > MaxUsersInIndicator + SetTypingString("Several people are typing..."); + } +} diff --git a/src/components/chatinputindicator.hpp b/src/components/chatinputindicator.hpp new file mode 100644 index 0000000..ec70dfb --- /dev/null +++ b/src/components/chatinputindicator.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include "discord/message.hpp" +#include "discord/user.hpp" + +class ChatInputIndicator : public Gtk::Box { +public: + ChatInputIndicator(); + void SetActiveChannel(Snowflake id); + void SetCustomMarkup(const Glib::ustring &str); + void ClearCustom(); + +private: + void AddUser(Snowflake channel_id, const UserData &user, int timeout); + void OnUserTypingStart(Snowflake user_id, Snowflake channel_id); + void OnMessageCreate(const Message &message); + void SetTypingString(const Glib::ustring &str); + void ComputeTypingString(); + + Gtk::Image m_img; + Gtk::Label m_label; + + Glib::ustring m_custom_markup; + + Snowflake m_active_channel; + std::unordered_map> m_typers; // channel id -> [user id -> connection] +}; diff --git a/src/components/chatlist.cpp b/src/components/chatlist.cpp new file mode 100644 index 0000000..5b3f357 --- /dev/null +++ b/src/components/chatlist.cpp @@ -0,0 +1,368 @@ +#include "chatmessage.hpp" +#include "chatlist.hpp" +#include "abaddon.hpp" +#include "constants.hpp" + +ChatList::ChatList() { + m_list.get_style_context()->add_class("messages"); + + set_can_focus(false); + set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_ALWAYS); + signal_edge_reached().connect(sigc::mem_fun(*this, &ChatList::OnScrollEdgeOvershot)); + + auto v = get_vadjustment(); + v->signal_value_changed().connect([this, v] { + m_should_scroll_to_bottom = v->get_upper() - v->get_page_size() <= v->get_value(); + }); + + m_list.signal_size_allocate().connect([this](Gtk::Allocation &) { + if (m_should_scroll_to_bottom) + ScrollToBottom(); + }); + + m_list.set_focus_hadjustment(get_hadjustment()); + m_list.set_focus_vadjustment(get_vadjustment()); + m_list.set_selection_mode(Gtk::SELECTION_NONE); + m_list.set_hexpand(true); + m_list.set_vexpand(true); + + add(m_list); + + m_list.show(); + + m_menu_copy_id = Gtk::manage(new Gtk::MenuItem("Copy ID")); + m_menu_copy_id->signal_activate().connect([this] { + Gtk::Clipboard::get()->set_text(std::to_string(m_menu_selected_message)); + }); + m_menu_copy_id->show(); + m_menu.append(*m_menu_copy_id); + + m_menu_delete_message = Gtk::manage(new Gtk::MenuItem("Delete Message")); + m_menu_delete_message->signal_activate().connect([this] { + Abaddon::Get().GetDiscordClient().DeleteMessage(m_active_channel, m_menu_selected_message); + }); + m_menu_delete_message->show(); + m_menu.append(*m_menu_delete_message); + + m_menu_edit_message = Gtk::manage(new Gtk::MenuItem("Edit Message")); + m_menu_edit_message->signal_activate().connect([this] { + m_signal_action_message_edit.emit(m_active_channel, m_menu_selected_message); + }); + m_menu_edit_message->show(); + m_menu.append(*m_menu_edit_message); + + m_menu_copy_content = Gtk::manage(new Gtk::MenuItem("Copy Content")); + m_menu_copy_content->signal_activate().connect([this] { + const auto msg = Abaddon::Get().GetDiscordClient().GetMessage(m_menu_selected_message); + if (msg.has_value()) + Gtk::Clipboard::get()->set_text(msg->Content); + }); + m_menu_copy_content->show(); + m_menu.append(*m_menu_copy_content); + + m_menu_reply_to = Gtk::manage(new Gtk::MenuItem("Reply To")); + m_menu_reply_to->signal_activate().connect([this] { + m_signal_action_reply_to.emit(m_menu_selected_message); + }); + m_menu_reply_to->show(); + m_menu.append(*m_menu_reply_to); + + m_menu_unpin = Gtk::manage(new Gtk::MenuItem("Unpin")); + m_menu_unpin->signal_activate().connect([this] { + Abaddon::Get().GetDiscordClient().Unpin(m_active_channel, m_menu_selected_message, [](...) {}); + }); + m_menu.append(*m_menu_unpin); + + m_menu_pin = Gtk::manage(new Gtk::MenuItem("Pin")); + m_menu_pin->signal_activate().connect([this] { + Abaddon::Get().GetDiscordClient().Pin(m_active_channel, m_menu_selected_message, [](...) {}); + }); + m_menu.append(*m_menu_pin); + + m_menu.show(); +} + +void ChatList::Clear() { + auto children = m_list.get_children(); + auto it = children.begin(); + while (it != children.end()) { + delete *it; + it++; + } +} + +void ChatList::SetActiveChannel(Snowflake id) { + m_active_channel = id; +} + +void ChatList::ProcessNewMessage(const Message &data, bool prepend) { + auto &discord = Abaddon::Get().GetDiscordClient(); + if (!discord.IsStarted()) return; + + // delete preview message when gateway sends it back + if (!data.IsPending && data.Nonce.has_value() && data.Author.ID == discord.GetUserData().ID) { + for (auto [id, widget] : m_id_to_widget) { + if (dynamic_cast(widget)->Nonce == *data.Nonce) { + RemoveMessageAndHeader(widget); + m_id_to_widget.erase(id); + break; + } + } + } + + ChatMessageHeader *last_row = nullptr; + bool should_attach = false; + if (!m_separate_all && m_num_rows > 0) { + if (prepend) + last_row = dynamic_cast(m_list.get_row_at_index(0)); + else + last_row = dynamic_cast(m_list.get_row_at_index(m_num_rows - 1)); + + if (last_row != nullptr) { + const uint64_t diff = std::max(data.ID, last_row->NewestID) - std::min(data.ID, last_row->NewestID); + if (last_row->UserID == data.Author.ID && (prepend || (diff < SnowflakeSplitDifference * Snowflake::SecondsInterval))) + should_attach = true; + } + } + + m_num_messages++; + + if (m_should_scroll_to_bottom && !prepend) { + while (m_num_messages > MaxMessagesForChatCull) { + auto first_it = m_id_to_widget.begin(); + RemoveMessageAndHeader(first_it->second); + m_id_to_widget.erase(first_it); + } + } + + ChatMessageHeader *header; + if (should_attach) { + header = last_row; + } else { + const auto chan = discord.GetChannel(m_active_channel); + Snowflake guild_id; + if (chan.has_value() && chan->GuildID.has_value()) + guild_id = *chan->GuildID; + const auto user_id = data.Author.ID; + const auto user = discord.GetUser(user_id); + if (!user.has_value()) return; + + header = Gtk::manage(new ChatMessageHeader(data)); + header->signal_action_insert_mention().connect([this, user_id]() { + m_signal_action_insert_mention.emit(user_id); + }); + + header->signal_action_open_user_menu().connect([this, user_id, guild_id](const GdkEvent *event) { + m_signal_action_open_user_menu.emit(event, user_id, guild_id); + }); + + m_num_rows++; + } + + auto *content = ChatMessageItemContainer::FromMessage(data); + if (content != nullptr) { + header->AddContent(content, prepend); + m_id_to_widget[data.ID] = content; + + const auto cb = [this, id = data.ID](GdkEventButton *ev) -> bool { + if (ev->type == GDK_BUTTON_PRESS && ev->button == GDK_BUTTON_SECONDARY) { + m_menu_selected_message = id; + + const auto &client = Abaddon::Get().GetDiscordClient(); + const auto data = client.GetMessage(id); + if (!data.has_value()) return false; + const auto channel = client.GetChannel(m_active_channel); + + bool has_manage = channel.has_value() && (channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM); + if (!has_manage) + has_manage = client.HasChannelPermission(client.GetUserData().ID, m_active_channel, Permission::MANAGE_MESSAGES); + + m_menu_edit_message->set_visible(!m_use_pinned_menu); + m_menu_reply_to->set_visible(!m_use_pinned_menu); + m_menu_unpin->set_visible(has_manage && data->IsPinned); + m_menu_pin->set_visible(has_manage && !data->IsPinned); + + if (data->IsDeleted()) { + m_menu_delete_message->set_sensitive(false); + m_menu_edit_message->set_sensitive(false); + } else { + const bool can_edit = client.GetUserData().ID == data->Author.ID; + const bool can_delete = can_edit || has_manage; + m_menu_delete_message->set_sensitive(can_delete); + m_menu_edit_message->set_sensitive(can_edit); + } + + m_menu.popup_at_pointer(reinterpret_cast(ev)); + } + return false; + }; + content->signal_button_press_event().connect(cb); + + if (!data.IsPending) { + content->signal_action_reaction_add().connect([this, id = data.ID](const Glib::ustring ¶m) { + m_signal_action_reaction_add.emit(id, param); + }); + content->signal_action_reaction_remove().connect([this, id = data.ID](const Glib::ustring ¶m) { + m_signal_action_reaction_remove.emit(id, param); + }); + content->signal_action_channel_click().connect([this](const Snowflake &id) { + m_signal_action_channel_click.emit(id); + }); + } + } + + header->set_margin_left(5); + header->show_all(); + + if (!should_attach) { + if (prepend) + m_list.prepend(*header); + else + m_list.add(*header); + } +} + +void ChatList::DeleteMessage(Snowflake id) { + auto widget = m_id_to_widget.find(id); + if (widget == m_id_to_widget.end()) return; + + auto *x = dynamic_cast(widget->second); + if (x != nullptr) + x->UpdateAttributes(); +} + +void ChatList::RefetchMessage(Snowflake id) { + auto widget = m_id_to_widget.find(id); + if (widget == m_id_to_widget.end()) return; + + auto *x = dynamic_cast(widget->second); + if (x != nullptr) { + x->UpdateContent(); + x->UpdateAttributes(); + } +} + +Snowflake ChatList::GetOldestListedMessage() { + if (m_id_to_widget.size() > 0) + return m_id_to_widget.begin()->first; + else + return Snowflake::Invalid; +} + +void ChatList::UpdateMessageReactions(Snowflake id) { + auto it = m_id_to_widget.find(id); + if (it == m_id_to_widget.end()) return; + auto *widget = dynamic_cast(it->second); + if (widget == nullptr) return; + widget->UpdateReactions(); +} + +void ChatList::SetFailedByNonce(const std::string &nonce) { + for (auto [id, widget] : m_id_to_widget) { + if (auto *container = dynamic_cast(widget); container->Nonce == nonce) { + container->SetFailed(); + break; + } + } +} + +std::vector ChatList::GetRecentAuthors() { + const auto &discord = Abaddon::Get().GetDiscordClient(); + std::vector ret; + + std::map ordered(m_id_to_widget.begin(), m_id_to_widget.end()); + + for (auto it = ordered.crbegin(); it != ordered.crend(); it++) { + const auto *widget = dynamic_cast(it->second); + if (widget == nullptr) continue; + const auto msg = discord.GetMessage(widget->ID); + if (!msg.has_value()) continue; + if (std::find(ret.begin(), ret.end(), msg->Author.ID) == ret.end()) + ret.push_back(msg->Author.ID); + } + + const auto chan = discord.GetChannel(m_active_channel); + if (chan->GuildID.has_value()) { + const auto others = discord.GetUsersInGuild(*chan->GuildID); + for (const auto id : others) + if (std::find(ret.begin(), ret.end(), id) == ret.end()) + ret.push_back(id); + } + + return ret; +} + +void ChatList::SetSeparateAll(bool separate) { + m_separate_all = true; +} + +void ChatList::SetUsePinnedMenu() { + m_use_pinned_menu = true; +} + +void ChatList::ActuallyRemoveMessage(Snowflake id) { + auto it = m_id_to_widget.find(id); + if (it != m_id_to_widget.end()) + RemoveMessageAndHeader(it->second); +} + +void ChatList::OnScrollEdgeOvershot(Gtk::PositionType pos) { + if (pos == Gtk::POS_TOP) + m_signal_action_chat_load_history.emit(m_active_channel); +} + +void ChatList::ScrollToBottom() { + auto x = get_vadjustment(); + x->set_value(x->get_upper()); +} + +void ChatList::RemoveMessageAndHeader(Gtk::Widget *widget) { + auto *header = dynamic_cast(widget->get_ancestor(Gtk::ListBoxRow::get_type())); + if (header != nullptr) { + if (header->GetChildContent().size() == 1) { + m_num_rows--; + delete header; + } else { + delete widget; + } + } else { + delete widget; + } + m_num_messages--; +} + +ChatList::type_signal_action_message_edit ChatList::signal_action_message_edit() { + return m_signal_action_message_edit; +} + +ChatList::type_signal_action_chat_submit ChatList::signal_action_chat_submit() { + return m_signal_action_chat_submit; +} + +ChatList::type_signal_action_chat_load_history ChatList::signal_action_chat_load_history() { + return m_signal_action_chat_load_history; +} + +ChatList::type_signal_action_channel_click ChatList::signal_action_channel_click() { + return m_signal_action_channel_click; +} + +ChatList::type_signal_action_insert_mention ChatList::signal_action_insert_mention() { + return m_signal_action_insert_mention; +} + +ChatList::type_signal_action_open_user_menu ChatList::signal_action_open_user_menu() { + return m_signal_action_open_user_menu; +} + +ChatList::type_signal_action_reaction_add ChatList::signal_action_reaction_add() { + return m_signal_action_reaction_add; +} + +ChatList::type_signal_action_reaction_remove ChatList::signal_action_reaction_remove() { + return m_signal_action_reaction_remove; +} + +ChatList::type_signal_action_reply_to ChatList::signal_action_reply_to() { + return m_signal_action_reply_to; +} diff --git a/src/components/chatlist.hpp b/src/components/chatlist.hpp new file mode 100644 index 0000000..e5afb80 --- /dev/null +++ b/src/components/chatlist.hpp @@ -0,0 +1,115 @@ +#pragma once +#include +#include +#include +#include "discord/snowflake.hpp" + +class ChatList : public Gtk::ScrolledWindow { +public: + ChatList(); + void Clear(); + void SetActiveChannel(Snowflake id); + template + void SetMessages(Iter begin, Iter end); + template + void PrependMessages(Iter begin, Iter end); + void ProcessNewMessage(const Message &data, bool prepend); + void DeleteMessage(Snowflake id); + void RefetchMessage(Snowflake id); + Snowflake GetOldestListedMessage(); + void UpdateMessageReactions(Snowflake id); + void SetFailedByNonce(const std::string &nonce); + std::vector GetRecentAuthors(); + void SetSeparateAll(bool separate); + void SetUsePinnedMenu(); // i think i need a better way to do menus + void ActuallyRemoveMessage(Snowflake id); // perhaps not the best method name + +private: + void OnScrollEdgeOvershot(Gtk::PositionType pos); + void ScrollToBottom(); + void RemoveMessageAndHeader(Gtk::Widget *widget); + + bool m_use_pinned_menu = false; + + Gtk::Menu m_menu; + Gtk::MenuItem *m_menu_copy_id; + Gtk::MenuItem *m_menu_copy_content; + Gtk::MenuItem *m_menu_delete_message; + Gtk::MenuItem *m_menu_edit_message; + Gtk::MenuItem *m_menu_reply_to; + Gtk::MenuItem *m_menu_unpin; + Gtk::MenuItem *m_menu_pin; + Snowflake m_menu_selected_message; + + Snowflake m_active_channel; + + int m_num_messages = 0; + int m_num_rows = 0; + std::map m_id_to_widget; + + bool m_should_scroll_to_bottom = true; + Gtk::ListBox m_list; + + bool m_separate_all = false; + +public: + // these are all forwarded by the parent + using type_signal_action_message_edit = sigc::signal; + using type_signal_action_chat_submit = sigc::signal; + using type_signal_action_chat_load_history = sigc::signal; + using type_signal_action_channel_click = sigc::signal; + using type_signal_action_insert_mention = sigc::signal; + using type_signal_action_open_user_menu = sigc::signal; + using type_signal_action_reaction_add = sigc::signal; + using type_signal_action_reaction_remove = sigc::signal; + using type_signal_action_reply_to = sigc::signal; + + type_signal_action_message_edit signal_action_message_edit(); + type_signal_action_chat_submit signal_action_chat_submit(); + type_signal_action_chat_load_history signal_action_chat_load_history(); + type_signal_action_channel_click signal_action_channel_click(); + type_signal_action_insert_mention signal_action_insert_mention(); + type_signal_action_open_user_menu signal_action_open_user_menu(); + type_signal_action_reaction_add signal_action_reaction_add(); + type_signal_action_reaction_remove signal_action_reaction_remove(); + type_signal_action_reply_to signal_action_reply_to(); + +private: + type_signal_action_message_edit m_signal_action_message_edit; + type_signal_action_chat_submit m_signal_action_chat_submit; + type_signal_action_chat_load_history m_signal_action_chat_load_history; + type_signal_action_channel_click m_signal_action_channel_click; + type_signal_action_insert_mention m_signal_action_insert_mention; + type_signal_action_open_user_menu m_signal_action_open_user_menu; + type_signal_action_reaction_add m_signal_action_reaction_add; + type_signal_action_reaction_remove m_signal_action_reaction_remove; + type_signal_action_reply_to m_signal_action_reply_to; +}; + +template +inline void ChatList::SetMessages(Iter begin, Iter end) { + Clear(); + m_num_rows = 0; + m_num_messages = 0; + m_id_to_widget.clear(); + + for (Iter it = begin; it != end; it++) + ProcessNewMessage(*it, false); + + ScrollToBottom(); +} + +template +inline void ChatList::PrependMessages(Iter begin, Iter end) { + const auto old_upper = get_vadjustment()->get_upper(); + const auto old_value = get_vadjustment()->get_value(); + for (Iter it = begin; it != end; it++) + ProcessNewMessage(*it, true); + // force everything to process before getting new values + while (Gtk::Main::events_pending()) + Gtk::Main::iteration(); + const auto new_upper = get_vadjustment()->get_upper(); + if (old_value == 0.0 && (new_upper - old_upper) > 0.0) + get_vadjustment()->set_value(new_upper - old_upper); + // this isn't ideal +} diff --git a/src/components/chatmessage.cpp b/src/components/chatmessage.cpp new file mode 100644 index 0000000..aa4fc2e --- /dev/null +++ b/src/components/chatmessage.cpp @@ -0,0 +1,1245 @@ +#include "chatmessage.hpp" +#include "abaddon.hpp" +#include "util.hpp" +#include "lazyimage.hpp" +#include + +constexpr static int EmojiSize = 24; // settings eventually +constexpr static int AvatarSize = 32; +constexpr static int EmbedImageWidth = 400; +constexpr static int EmbedImageHeight = 300; +constexpr static int ThumbnailSize = 100; +constexpr static int StickerComponentSize = 160; + +ChatMessageItemContainer::ChatMessageItemContainer() + : m_main(Gtk::ORIENTATION_VERTICAL) { + add(m_main); + + m_link_menu_copy = Gtk::manage(new Gtk::MenuItem("Copy Link")); + m_link_menu_copy->signal_activate().connect(sigc::mem_fun(*this, &ChatMessageItemContainer::on_link_menu_copy)); + m_link_menu.append(*m_link_menu_copy); + + m_link_menu.show_all(); +} + +ChatMessageItemContainer *ChatMessageItemContainer::FromMessage(const Message &data) { + auto *container = Gtk::manage(new ChatMessageItemContainer); + container->ID = data.ID; + container->ChannelID = data.ChannelID; + + if (data.Nonce.has_value()) + container->Nonce = *data.Nonce; + + if (data.Content.size() > 0 || data.Type != MessageType::DEFAULT) { + container->m_text_component = container->CreateTextComponent(data); + container->AttachEventHandlers(*container->m_text_component); + container->m_main.add(*container->m_text_component); + } + + if ((data.MessageReference.has_value() || data.Interaction.has_value()) && data.Type != MessageType::CHANNEL_FOLLOW_ADD) { + auto *widget = container->CreateReplyComponent(data); + if (widget != nullptr) { + container->m_main.add(*widget); + container->m_main.child_property_position(*widget) = 0; // eek + } + } + + // there should only ever be 1 embed (i think?) + if (data.Embeds.size() == 1) { + const auto &embed = data.Embeds[0]; + if (IsEmbedImageOnly(embed)) { + auto *widget = container->CreateImageComponent(*embed.Thumbnail->ProxyURL, *embed.Thumbnail->URL, *embed.Thumbnail->Width, *embed.Thumbnail->Height); + container->AttachEventHandlers(*widget); + container->m_main.add(*widget); + } else { + container->m_embed_component = container->CreateEmbedComponent(embed); + container->AttachEventHandlers(*container->m_embed_component); + container->m_main.add(*container->m_embed_component); + } + } + + // i dont think attachments can be edited + // also this can definitely be done much better holy shit + for (const auto &a : data.Attachments) { + if (IsURLViewableImage(a.ProxyURL) && a.Width.has_value() && a.Height.has_value()) { + auto *widget = container->CreateImageComponent(a.ProxyURL, a.URL, *a.Width, *a.Height); + container->m_main.add(*widget); + } else { + auto *widget = container->CreateAttachmentComponent(a); + container->m_main.add(*widget); + } + } + + // only 1? + /* + DEPRECATED + if (data.Stickers.has_value()) { + const auto &sticker = data.Stickers.value()[0]; + // todo: lottie, proper apng + if (sticker.FormatType == StickerFormatType::PNG || sticker.FormatType == StickerFormatType::APNG) { + auto *widget = container->CreateStickerComponent(sticker); + container->m_main->add(*widget); + } + }*/ + + if (data.StickerItems.has_value()) { + auto *widget = container->CreateStickersComponent(*data.StickerItems); + container->m_main.add(*widget); + } + + if (data.Reactions.has_value() && data.Reactions->size() > 0) { + container->m_reactions_component = container->CreateReactionsComponent(data); + container->m_main.add(*container->m_reactions_component); + } + + container->UpdateAttributes(); + + return container; +} + +// this doesnt rly make sense +void ChatMessageItemContainer::UpdateContent() { + const auto data = Abaddon::Get().GetDiscordClient().GetMessage(ID); + if (m_text_component != nullptr) + UpdateTextComponent(m_text_component); + + if (m_embed_component != nullptr) { + delete m_embed_component; + m_embed_component = nullptr; + } + + if (data->Embeds.size() == 1) { + m_embed_component = CreateEmbedComponent(data->Embeds[0]); + AttachEventHandlers(*m_embed_component); + m_main.add(*m_embed_component); + } +} + +void ChatMessageItemContainer::UpdateReactions() { + if (m_reactions_component != nullptr) { + delete m_reactions_component; + m_reactions_component = nullptr; + } + + const auto data = Abaddon::Get().GetDiscordClient().GetMessage(ID); + if (data->Reactions.has_value() && data->Reactions->size() > 0) { + m_reactions_component = CreateReactionsComponent(*data); + m_reactions_component->show_all(); + m_main.add(*m_reactions_component); + } +} + +void ChatMessageItemContainer::SetFailed() { + if (m_text_component != nullptr) { + m_text_component->get_style_context()->remove_class("pending"); + m_text_component->get_style_context()->add_class("failed"); + } +} + +void ChatMessageItemContainer::UpdateAttributes() { + const auto data = Abaddon::Get().GetDiscordClient().GetMessage(ID); + if (!data.has_value()) return; + + const bool deleted = data->IsDeleted(); + const bool edited = data->IsEdited(); + + if (!deleted && !edited) return; + + if (m_attrib_label == nullptr) { + m_attrib_label = Gtk::manage(new Gtk::Label); + m_attrib_label->set_halign(Gtk::ALIGN_START); + m_attrib_label->show(); + m_main.add(*m_attrib_label); // todo: maybe insert markup into existing text widget's buffer if the circumstances are right (or pack horizontally) + } + + if (deleted) + m_attrib_label->set_markup("[deleted]"); + else if (edited) + m_attrib_label->set_markup("[edited]"); +} + +void ChatMessageItemContainer::AddClickHandler(Gtk::Widget *widget, std::string url) { + // clang-format off + widget->signal_button_press_event().connect([url](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + LaunchBrowser(url); + return false; + } + return true; + }, false); + // clang-format on +} + +Gtk::TextView *ChatMessageItemContainer::CreateTextComponent(const Message &data) { + auto *tv = Gtk::manage(new Gtk::TextView); + + if (data.IsPending) + tv->get_style_context()->add_class("pending"); + tv->get_style_context()->add_class("message-text"); + tv->set_can_focus(false); + tv->set_editable(false); + tv->set_wrap_mode(Gtk::WRAP_WORD_CHAR); + tv->set_halign(Gtk::ALIGN_FILL); + tv->set_hexpand(true); + + UpdateTextComponent(tv); + + return tv; +} + +void ChatMessageItemContainer::UpdateTextComponent(Gtk::TextView *tv) { + const auto data = Abaddon::Get().GetDiscordClient().GetMessage(ID); + if (!data.has_value()) return; + + auto b = tv->get_buffer(); + b->set_text(""); + Gtk::TextBuffer::iterator s, e; + b->get_bounds(s, e); + switch (data->Type) { + case MessageType::DEFAULT: + case MessageType::INLINE_REPLY: + b->insert(s, data->Content); + HandleUserMentions(b); + HandleLinks(*tv); + HandleChannelMentions(tv); + HandleEmojis(*tv); + break; + case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION: + b->insert_markup(s, "[boosted server]"); + break; + case MessageType::GUILD_MEMBER_JOIN: + b->insert_markup(s, "[user joined]"); + break; + case MessageType::CHANNEL_PINNED_MESSAGE: + b->insert_markup(s, "[message pinned]"); + break; + case MessageType::APPLICATION_COMMAND: { + if (data->Application.has_value()) { + static const auto regex = Glib::Regex::create(R"()"); + Glib::MatchInfo match; + if (regex->match(data->Content, match)) { + const auto cmd = match.fetch(1); + const auto app = data->Application->Name; + b->insert_markup(s, "used " + cmd + " with " + app + ""); + } + } else { + b->insert(s, data->Content); + HandleUserMentions(b); + HandleLinks(*tv); + HandleChannelMentions(tv); + HandleEmojis(*tv); + } + } break; + case MessageType::RECIPIENT_ADD: { + if (data->Mentions.size() == 0) break; + const auto &adder = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + const auto &added = data->Mentions[0]; + b->insert_markup(s, "" + adder->Username + " added " + added.Username + ""); + } break; + case MessageType::RECIPIENT_REMOVE: { + if (data->Mentions.size() == 0) break; + const auto &adder = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + const auto &added = data->Mentions[0]; + if (adder->ID == added.ID) + b->insert_markup(s, "" + adder->Username + " left"); + else + b->insert_markup(s, "" + adder->Username + " removed " + added.Username + ""); + } break; + case MessageType::CHANNEL_NAME_CHANGE: { + const auto author = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + b->insert_markup(s, "" + author->GetEscapedBoldName() + " changed the name to " + Glib::Markup::escape_text(data->Content) + ""); + } break; + case MessageType::CHANNEL_ICON_CHANGE: { + const auto author = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + b->insert_markup(s, "" + author->GetEscapedBoldName() + " changed the channel icon"); + } break; + case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1: + case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2: + case MessageType::USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3: { + const auto author = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + const auto guild = Abaddon::Get().GetDiscordClient().GetGuild(*data->GuildID); + b->insert_markup(s, "" + author->GetEscapedBoldName() + " just boosted the server " + Glib::Markup::escape_text(data->Content) + " times! " + + Glib::Markup::escape_text(guild->Name) + " has achieved Level " + std::to_string(static_cast(data->Type) - 8) + "!"); // oo cheeky me !!! + } break; + case MessageType::CHANNEL_FOLLOW_ADD: { + const auto author = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + b->insert_markup(s, "" + author->GetEscapedBoldName() + " has added " + Glib::Markup::escape_text(data->Content) + " to this channel. Its most important updates will show up here."); + } break; + case MessageType::CALL: { + b->insert_markup(s, "[started a call]"); + } break; + case MessageType::GUILD_DISCOVERY_DISQUALIFIED: { + b->insert_markup(s, "This server has been removed from Server Discovery because it no longer passes all the requirements."); + } break; + case MessageType::GUILD_DISCOVERY_REQUALIFIED: { + b->insert_markup(s, "This server is eligible for Server Discovery again and has been automatically relisted!"); + } break; + case MessageType::GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING: { + b->insert_markup(s, "This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery."); + } break; + case MessageType::GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING: { + b->insert_markup(s, "This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery."); + } break; + case MessageType::THREAD_CREATED: { + const auto author = Abaddon::Get().GetDiscordClient().GetUser(data->Author.ID); + if (data->MessageReference.has_value() && data->MessageReference->ChannelID.has_value()) { + auto iter = b->insert_markup(s, "" + author->GetEscapedBoldName() + " started a thread: "); + auto tag = b->create_tag(); + tag->property_weight() = Pango::WEIGHT_BOLD; + m_channel_tagmap[tag] = *data->MessageReference->ChannelID; + b->insert_with_tag(iter, data->Content, tag); + + tv->signal_button_press_event().connect(sigc::mem_fun(*this, &ChatMessageItemContainer::OnClickChannel), false); + } else { + b->insert_markup(s, "" + author->GetEscapedBoldName() + " started a thread: " + Glib::Markup::escape_text(data->Content) + ""); + } + } break; + default: break; + } +} + +Gtk::Widget *ChatMessageItemContainer::CreateEmbedComponent(const EmbedData &embed) { + Gtk::EventBox *ev = Gtk::manage(new Gtk::EventBox); + ev->set_can_focus(true); + Gtk::Box *main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + Gtk::Box *content = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + + if (embed.Author.has_value() && (embed.Author->Name.has_value() || embed.Author->ProxyIconURL.has_value())) { + auto *author_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + content->pack_start(*author_box); + + constexpr static int AuthorIconSize = 20; + if (embed.Author->ProxyIconURL.has_value()) { + auto *author_img = Gtk::manage(new LazyImage(*embed.Author->ProxyIconURL, AuthorIconSize, AuthorIconSize)); + author_img->set_halign(Gtk::ALIGN_START); + author_img->set_valign(Gtk::ALIGN_START); + author_img->set_margin_start(6); + author_img->set_margin_end(6); + author_img->get_style_context()->add_class("embed-author-icon"); + author_box->add(*author_img); + } + + if (embed.Author->Name.has_value()) { + auto *author_lbl = Gtk::manage(new Gtk::Label); + author_lbl->set_halign(Gtk::ALIGN_START); + author_lbl->set_valign(Gtk::ALIGN_CENTER); + author_lbl->set_line_wrap(true); + author_lbl->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + author_lbl->set_hexpand(false); + author_lbl->set_text(*embed.Author->Name); + author_lbl->get_style_context()->add_class("embed-author"); + author_box->add(*author_lbl); + } + } + + if (embed.Title.has_value()) { + auto *title_ev = Gtk::manage(new Gtk::EventBox); + auto *title_label = Gtk::manage(new Gtk::Label); + title_label->set_use_markup(true); + title_label->set_markup("" + Glib::Markup::escape_text(*embed.Title) + ""); + title_label->set_halign(Gtk::ALIGN_CENTER); + title_label->set_hexpand(false); + title_label->get_style_context()->add_class("embed-title"); + title_label->set_single_line_mode(false); + title_label->set_line_wrap(true); + title_label->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + title_label->set_max_width_chars(50); + title_ev->add(*title_label); + content->pack_start(*title_ev); + + if (embed.URL.has_value()) { + AddPointerCursor(*title_ev); + auto url = *embed.URL; + title_ev->signal_button_press_event().connect([this, url = std::move(url)](GdkEventButton *event) -> bool { + if (event->button == GDK_BUTTON_PRIMARY) { + LaunchBrowser(url); + return true; + } + return false; + }); + static auto color = Abaddon::Get().GetSettings().GetLinkColor(); + title_label->override_color(Gdk::RGBA(color)); + title_label->set_markup("" + Glib::Markup::escape_text(*embed.Title) + ""); + } + } + + if (!embed.Provider.has_value() || embed.Provider->Name != "YouTube") { // youtube link = no description + if (embed.Description.has_value()) { + auto *desc_label = Gtk::manage(new Gtk::Label); + desc_label->set_text(*embed.Description); + desc_label->set_line_wrap(true); + desc_label->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + desc_label->set_max_width_chars(50); + desc_label->set_halign(Gtk::ALIGN_START); + desc_label->set_hexpand(false); + desc_label->get_style_context()->add_class("embed-description"); + content->pack_start(*desc_label); + } + } + + // todo: handle inline fields + if (embed.Fields.has_value() && embed.Fields->size() > 0) { + auto *flow = Gtk::manage(new Gtk::FlowBox); + flow->set_orientation(Gtk::ORIENTATION_HORIZONTAL); + flow->set_min_children_per_line(3); + flow->set_max_children_per_line(3); + flow->set_halign(Gtk::ALIGN_START); + flow->set_hexpand(false); + flow->set_column_spacing(10); + flow->set_selection_mode(Gtk::SELECTION_NONE); + content->pack_start(*flow); + + for (const auto &field : *embed.Fields) { + auto *field_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + auto *field_lbl = Gtk::manage(new Gtk::Label); + auto *field_val = Gtk::manage(new Gtk::Label); + field_box->set_hexpand(false); + field_box->set_halign(Gtk::ALIGN_START); + field_box->set_valign(Gtk::ALIGN_START); + field_lbl->set_hexpand(false); + field_lbl->set_halign(Gtk::ALIGN_START); + field_lbl->set_valign(Gtk::ALIGN_START); + field_val->set_hexpand(false); + field_val->set_halign(Gtk::ALIGN_START); + field_val->set_valign(Gtk::ALIGN_START); + field_lbl->set_use_markup(true); + field_lbl->set_markup("" + Glib::Markup::escape_text(field.Name) + ""); + field_lbl->set_max_width_chars(20); + field_lbl->set_line_wrap(true); + field_lbl->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + field_val->set_text(field.Value); + field_val->set_max_width_chars(20); + field_val->set_line_wrap(true); + field_val->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + field_box->pack_start(*field_lbl); + field_box->pack_start(*field_val); + field_lbl->get_style_context()->add_class("embed-field-title"); + field_val->get_style_context()->add_class("embed-field-value"); + flow->insert(*field_box, -1); + } + } + + if (embed.Image.has_value() && embed.Image->ProxyURL.has_value()) { + int w = 0, h = 0; + GetImageDimensions(*embed.Image->Width, *embed.Image->Height, w, h, EmbedImageWidth, EmbedImageHeight); + + auto *img = Gtk::manage(new LazyImage(*embed.Image->ProxyURL, w, h, false)); + img->set_halign(Gtk::ALIGN_CENTER); + img->set_margin_top(5); + img->set_size_request(w, h); + content->pack_start(*img); + } + + if (embed.Footer.has_value()) { + auto *footer_lbl = Gtk::manage(new Gtk::Label); + footer_lbl->set_halign(Gtk::ALIGN_START); + footer_lbl->set_line_wrap(true); + footer_lbl->set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + footer_lbl->set_hexpand(false); + footer_lbl->set_text(embed.Footer->Text); + footer_lbl->get_style_context()->add_class("embed-footer"); + content->pack_start(*footer_lbl); + } + + if (embed.Thumbnail.has_value() && embed.Thumbnail->ProxyURL.has_value()) { + int w, h; + GetImageDimensions(*embed.Thumbnail->Width, *embed.Thumbnail->Height, w, h, ThumbnailSize, ThumbnailSize); + + auto *thumbnail = Gtk::manage(new LazyImage(*embed.Thumbnail->ProxyURL, w, h, false)); + thumbnail->set_size_request(w, h); + thumbnail->set_margin_start(8); + main->pack_end(*thumbnail); + } + + auto style = main->get_style_context(); + + if (embed.Color.has_value()) { + auto provider = Gtk::CssProvider::create(); // this seems wrong + std::string css = ".embed { border-left: 2px solid #" + IntToCSSColor(*embed.Color) + "; }"; + provider->load_from_data(css); + style->add_provider(provider, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + } + + style->add_class("embed"); + + main->set_margin_bottom(8); + main->set_hexpand(false); + main->set_hexpand(false); + main->set_halign(Gtk::ALIGN_START); + main->set_halign(Gtk::ALIGN_START); + main->pack_start(*content); + + ev->add(*main); + ev->show_all(); + + return ev; +} + +Gtk::Widget *ChatMessageItemContainer::CreateImageComponent(const std::string &proxy_url, const std::string &url, int inw, int inh) { + int w, h; + GetImageDimensions(inw, inh, w, h); + + Gtk::EventBox *ev = Gtk::manage(new Gtk::EventBox); + Gtk::Image *widget = Gtk::manage(new LazyImage(proxy_url, w, h, false)); + ev->add(*widget); + widget->set_halign(Gtk::ALIGN_START); + widget->set_size_request(w, h); + + AttachEventHandlers(*ev); + AddClickHandler(ev, url); + + return ev; +} + +Gtk::Widget *ChatMessageItemContainer::CreateAttachmentComponent(const AttachmentData &data) { + auto *ev = Gtk::manage(new Gtk::EventBox); + auto *btn = Gtk::manage(new Gtk::Label(data.Filename + " " + HumanReadableBytes(data.Bytes))); // Gtk::LinkButton flat out doesn't work :D + ev->set_hexpand(false); + ev->set_halign(Gtk::ALIGN_START); + ev->get_style_context()->add_class("message-attachment-box"); + ev->add(*btn); + + AttachEventHandlers(*ev); + AddClickHandler(ev, data.URL); + + return ev; +} + +Gtk::Widget *ChatMessageItemContainer::CreateStickerComponentDeprecated(const StickerData &data) { + auto *box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + auto *imgw = Gtk::manage(new Gtk::Image); + box->add(*imgw); + auto &img = Abaddon::Get().GetImageManager(); + + if (data.FormatType == StickerFormatType::PNG || data.FormatType == StickerFormatType::APNG) { + auto cb = [this, imgw](const Glib::RefPtr &pixbuf) { + imgw->property_pixbuf() = pixbuf; + }; + img.LoadFromURL(data.GetURL(), sigc::track_obj(cb, *imgw)); + } + + AttachEventHandlers(*box); + return box; +} + +Gtk::Widget *ChatMessageItemContainer::CreateStickersComponent(const std::vector &data) { + auto *box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + + for (const auto &sticker : data) { + // no lottie + if (sticker.FormatType != StickerFormatType::PNG && sticker.FormatType != StickerFormatType::APNG) continue; + auto *ev = Gtk::manage(new Gtk::EventBox); + auto *img = Gtk::manage(new LazyImage(sticker.GetURL(), StickerComponentSize, StickerComponentSize, false)); + img->set_size_request(StickerComponentSize, StickerComponentSize); // should this go in LazyImage ? + img->show(); + ev->show(); + ev->add(*img); + box->add(*ev); + } + + box->show(); + + AttachEventHandlers(*box); + return box; +} + +Gtk::Widget *ChatMessageItemContainer::CreateReactionsComponent(const Message &data) { + auto *flow = Gtk::manage(new Gtk::FlowBox); + flow->set_orientation(Gtk::ORIENTATION_HORIZONTAL); + flow->set_min_children_per_line(5); + flow->set_max_children_per_line(20); + flow->set_halign(Gtk::ALIGN_START); + flow->set_hexpand(false); + flow->set_column_spacing(2); + flow->set_selection_mode(Gtk::SELECTION_NONE); + + auto &imgr = Abaddon::Get().GetImageManager(); + auto &emojis = Abaddon::Get().GetEmojis(); + const auto &placeholder = imgr.GetPlaceholder(16); + + for (const auto &reaction : *data.Reactions) { + auto *ev = Gtk::manage(new Gtk::EventBox); + auto *box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + box->get_style_context()->add_class("reaction-box"); + ev->add(*box); + flow->add(*ev); + + bool is_stock = !reaction.Emoji.ID.IsValid(); + + bool has_reacted = reaction.HasReactedWith; + if (has_reacted) + box->get_style_context()->add_class("reacted"); + + ev->signal_button_press_event().connect([this, has_reacted, is_stock, reaction](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + Glib::ustring param; // escaped in client + if (is_stock) + param = reaction.Emoji.Name; + else + param = std::to_string(reaction.Emoji.ID); + if (has_reacted) + m_signal_action_reaction_remove.emit(param); + else + m_signal_action_reaction_add.emit(param); + return true; + } + return false; + }); + + ev->signal_realize().connect([ev]() { + auto window = ev->get_window(); + auto display = window->get_display(); + auto cursor = Gdk::Cursor::create(display, "pointer"); + window->set_cursor(cursor); + }); + + // image + if (is_stock) { // unicode/stock + const auto shortcode = emojis.GetShortCodeForPattern(reaction.Emoji.Name); + if (shortcode != "") + ev->set_tooltip_text(shortcode); + + const auto &pb = emojis.GetPixBuf(reaction.Emoji.Name); + Gtk::Image *img; + if (pb) + img = Gtk::manage(new Gtk::Image(pb->scale_simple(16, 16, Gdk::INTERP_BILINEAR))); + else + img = Gtk::manage(new Gtk::Image(placeholder)); + img->set_can_focus(false); + box->add(*img); + } else { // custom + ev->set_tooltip_text(reaction.Emoji.Name); + + auto img = Gtk::manage(new LazyImage(reaction.Emoji.GetURL(), 16, 16)); + img->set_can_focus(false); + box->add(*img); + } + + auto *lbl = Gtk::manage(new Gtk::Label(std::to_string(reaction.Count))); + lbl->set_margin_left(5); + lbl->get_style_context()->add_class("reaction-count"); + box->add(*lbl); + } + + return flow; +} + +Gtk::Widget *ChatMessageItemContainer::CreateReplyComponent(const Message &data) { + if (data.Type == MessageType::THREAD_CREATED) return nullptr; + + auto *box = Gtk::manage(new Gtk::Box); + auto *lbl = Gtk::manage(new Gtk::Label); + lbl->set_single_line_mode(true); + lbl->set_line_wrap(false); + lbl->set_use_markup(true); + lbl->set_ellipsize(Pango::ELLIPSIZE_END); + lbl->get_style_context()->add_class("message-text"); // good idea? + lbl->get_style_context()->add_class("message-reply"); + box->add(*lbl); + + const auto &discord = Abaddon::Get().GetDiscordClient(); + + const auto get_author_markup = [&](Snowflake author_id, Snowflake guild_id = Snowflake::Invalid) -> std::string { + if (guild_id.IsValid()) { + const auto role_id = discord.GetMemberHoistedRole(guild_id, author_id, true); + if (role_id.IsValid()) { + const auto role = discord.GetRole(role_id); + if (role.has_value()) { + const auto author = discord.GetUser(author_id); + return "Color) + "\">" + author->GetEscapedString() + ""; + } + } + } + + const auto author = discord.GetUser(author_id); + return author->GetEscapedBoldString(); + }; + + // if the message wasnt fetched from store it might have an un-fetched reference + std::optional> referenced_message = data.ReferencedMessage; + if (data.MessageReference.has_value() && data.MessageReference->MessageID.has_value() && !referenced_message.has_value()) { + auto refd = discord.GetMessage(*data.MessageReference->MessageID); + if (refd.has_value()) + referenced_message = std::make_shared(std::move(*refd)); + } + + if (data.Interaction.has_value()) { + const auto user = *discord.GetUser(data.Interaction->User.ID); + + if (data.GuildID.has_value()) { + lbl->set_markup(get_author_markup(user.ID, *data.GuildID) + + " used /" + + Glib::Markup::escape_text(data.Interaction->Name) + + ""); + } else { + lbl->set_markup(user.GetEscapedBoldString()); + } + } else if (referenced_message.has_value()) { + if (referenced_message.value() == nullptr) { + lbl->set_markup("deleted message"); + } else { + const auto &referenced = *referenced_message.value(); + Glib::ustring text; + if (referenced.Content.empty()) { + if (!referenced.Attachments.empty()) { + text = "attachment"; + } else if (!referenced.Embeds.empty()) { + text = "embed"; + } + } else { + auto buf = Gtk::TextBuffer::create(); + Gtk::TextBuffer::iterator start, end; + buf->get_bounds(start, end); + buf->set_text(referenced.Content); + CleanupEmojis(buf); + HandleUserMentions(buf); + HandleChannelMentions(buf); + text = Glib::Markup::escape_text(buf->get_text()); + } + // getting markup out of a textbuffer seems like something that to me should be really simple + // but actually is horribly annoying. replies won't have mention colors because you can't do this + // also no emojis because idk how to make a textview act like a label + // which of course would not be an issue if i could figure out how to get fonts to work on this god-forsaken framework + // oh well + // but ill manually get colors for the user who is being replied to + if (referenced.GuildID.has_value()) + lbl->set_markup(get_author_markup(referenced.Author.ID, *referenced.GuildID) + ": " + text); + else + lbl->set_markup(get_author_markup(referenced.Author.ID) + ": " + text); + } + } else { + lbl->set_markup("reply unavailable"); + } + + return box; +} + +Glib::ustring ChatMessageItemContainer::GetText(const Glib::RefPtr &buf) { + Gtk::TextBuffer::iterator a, b; + buf->get_bounds(a, b); + auto slice = buf->get_slice(a, b, true); + return slice; +} + +bool ChatMessageItemContainer::IsEmbedImageOnly(const EmbedData &data) { + if (!data.Thumbnail.has_value()) return false; + if (data.Author.has_value()) return false; + if (data.Description.has_value()) return false; + if (data.Fields.has_value()) return false; + if (data.Footer.has_value()) return false; + if (data.Image.has_value()) return false; + if (data.Timestamp.has_value()) return false; + return data.Thumbnail->ProxyURL.has_value() && data.Thumbnail->URL.has_value() && data.Thumbnail->Width.has_value() && data.Thumbnail->Height.has_value(); +} + +void ChatMessageItemContainer::HandleUserMentions(Glib::RefPtr buf) { + constexpr static const auto mentions_regex = R"(<@!?(\d+)>)"; + + static auto rgx = Glib::Regex::create(mentions_regex); + + Glib::ustring text = GetText(buf); + const auto &discord = Abaddon::Get().GetDiscordClient(); + + int startpos = 0; + Glib::MatchInfo match; + while (rgx->match(text, startpos, match)) { + int mstart, mend; + if (!match.fetch_pos(0, mstart, mend)) break; + const Glib::ustring user_id = match.fetch(1); + const auto user = discord.GetUser(user_id); + const auto channel = discord.GetChannel(ChannelID); + if (!user.has_value() || !channel.has_value()) { + startpos = mend; + continue; + } + + Glib::ustring replacement; + + if (channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM) + replacement = user->GetEscapedBoldString(); + else { + const auto role_id = user->GetHoistedRole(*channel->GuildID, true); + const auto role = discord.GetRole(role_id); + if (!role.has_value()) + replacement = user->GetEscapedBoldString(); + else + replacement = "Color) + "\">" + user->GetEscapedBoldString() + ""; + } + + // regex returns byte positions and theres no straightforward way in the c++ bindings to deal with that :( + const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); + const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); + const auto start_it = buf->get_iter_at_offset(chars_start); + const auto end_it = buf->get_iter_at_offset(chars_end); + + auto it = buf->erase(start_it, end_it); + buf->insert_markup(it, replacement); + + text = GetText(buf); + startpos = 0; + } +} + +void ChatMessageItemContainer::HandleStockEmojis(Gtk::TextView &tv) { + Abaddon::Get().GetEmojis().ReplaceEmojis(tv.get_buffer(), EmojiSize); +} + +void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) { + static auto rgx = Glib::Regex::create(R"()"); + + auto &img = Abaddon::Get().GetImageManager(); + + auto buf = tv.get_buffer(); + auto text = GetText(buf); + + Glib::MatchInfo match; + int startpos = 0; + while (rgx->match(text, startpos, match)) { + int mstart, mend; + if (!match.fetch_pos(0, mstart, mend)) break; + const bool is_animated = match.fetch(0)[1] == 'a'; + const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + + const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); + const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); + auto start_it = buf->get_iter_at_offset(chars_start); + auto end_it = buf->get_iter_at_offset(chars_end); + + startpos = mend; + if (is_animated && show_animations) { + const auto mark_start = buf->create_mark(start_it, false); + end_it.backward_char(); + const auto mark_end = buf->create_mark(end_it, false); + const auto cb = [this, &tv, buf, mark_start, mark_end](const Glib::RefPtr &pixbuf) { + auto start_it = mark_start->get_iter(); + auto end_it = mark_end->get_iter(); + end_it.forward_char(); + buf->delete_mark(mark_start); + buf->delete_mark(mark_end); + auto it = buf->erase(start_it, end_it); + const auto anchor = buf->create_child_anchor(it); + auto img = Gtk::manage(new Gtk::Image(pixbuf)); + img->show(); + tv.add_child_at_anchor(*img, anchor); + }; + img.LoadAnimationFromURL(EmojiData::URLFromID(match.fetch(2), "gif"), EmojiSize, EmojiSize, sigc::track_obj(cb, tv)); + } else { + // can't erase before pixbuf is ready or else marks that are in the same pos get mixed up + const auto mark_start = buf->create_mark(start_it, false); + end_it.backward_char(); + const auto mark_end = buf->create_mark(end_it, false); + const auto cb = [this, buf, mark_start, mark_end](const Glib::RefPtr &pixbuf) { + auto start_it = mark_start->get_iter(); + auto end_it = mark_end->get_iter(); + end_it.forward_char(); + buf->delete_mark(mark_start); + buf->delete_mark(mark_end); + auto it = buf->erase(start_it, end_it); + buf->insert_pixbuf(it, pixbuf->scale_simple(EmojiSize, EmojiSize, Gdk::INTERP_BILINEAR)); + }; + img.LoadFromURL(EmojiData::URLFromID(match.fetch(2)), sigc::track_obj(cb, tv)); + } + + text = GetText(buf); + } +} + +void ChatMessageItemContainer::HandleEmojis(Gtk::TextView &tv) { + static const bool stock_emojis = Abaddon::Get().GetSettings().GetShowStockEmojis(); + static const bool custom_emojis = Abaddon::Get().GetSettings().GetShowCustomEmojis(); + + if (stock_emojis) HandleStockEmojis(tv); + if (custom_emojis) HandleCustomEmojis(tv); +} + +void ChatMessageItemContainer::CleanupEmojis(Glib::RefPtr buf) { + static auto rgx = Glib::Regex::create(R"()"); + + auto text = GetText(buf); + + Glib::MatchInfo match; + int startpos = 0; + while (rgx->match(text, startpos, match)) { + int mstart, mend; + if (!match.fetch_pos(0, mstart, mend)) break; + + const auto new_term = ":" + match.fetch(1) + ":"; + + const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); + const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); + auto start_it = buf->get_iter_at_offset(chars_start); + auto end_it = buf->get_iter_at_offset(chars_end); + + startpos = mend; + const auto it = buf->erase(start_it, end_it); + const int alen = text.size(); + text = GetText(buf); + const int blen = text.size(); + startpos -= (alen - blen); + + buf->insert(it, new_term); + + text = GetText(buf); + } +} + +void ChatMessageItemContainer::HandleChannelMentions(Glib::RefPtr buf) { + static auto rgx = Glib::Regex::create(R"(<#(\d+)>)"); + + Glib::ustring text = GetText(buf); + + const auto &discord = Abaddon::Get().GetDiscordClient(); + + int startpos = 0; + Glib::MatchInfo match; + while (rgx->match(text, startpos, match)) { + int mstart, mend; + match.fetch_pos(0, mstart, mend); + std::string channel_id = match.fetch(1); + const auto chan = discord.GetChannel(channel_id); + if (!chan.has_value()) { + startpos = mend; + continue; + } + + auto tag = buf->create_tag(); + if (chan->Type == ChannelType::GUILD_TEXT) { + m_channel_tagmap[tag] = channel_id; + tag->property_weight() = Pango::WEIGHT_BOLD; + } + + const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); + const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); + const auto erase_from = buf->get_iter_at_offset(chars_start); + const auto erase_to = buf->get_iter_at_offset(chars_end); + auto it = buf->erase(erase_from, erase_to); + const std::string replacement = "#" + *chan->Name; + it = buf->insert_with_tag(it, "#" + *chan->Name, tag); + + // rescan the whole thing so i dont have to deal with fixing match positions + text = GetText(buf); + startpos = 0; + } +} + +void ChatMessageItemContainer::HandleChannelMentions(Gtk::TextView *tv) { + tv->signal_button_press_event().connect(sigc::mem_fun(*this, &ChatMessageItemContainer::OnClickChannel), false); + HandleChannelMentions(tv->get_buffer()); +} + +// a lot of repetition here so there should probably just be one slot for textview's button-press +bool ChatMessageItemContainer::OnClickChannel(GdkEventButton *ev) { + if (m_text_component == nullptr) return false; + if (ev->type != GDK_BUTTON_PRESS) return false; + if (ev->button != GDK_BUTTON_PRIMARY) return false; + + auto buf = m_text_component->get_buffer(); + Gtk::TextBuffer::iterator start, end; + buf->get_selection_bounds(start, end); // no open if selection + if (start.get_offset() != end.get_offset()) + return false; + + int x, y; + m_text_component->window_to_buffer_coords(Gtk::TEXT_WINDOW_WIDGET, ev->x, ev->y, x, y); + Gtk::TextBuffer::iterator iter; + m_text_component->get_iter_at_location(iter, x, y); + + const auto tags = iter.get_tags(); + for (auto tag : tags) { + const auto it = m_channel_tagmap.find(tag); + if (it != m_channel_tagmap.end()) { + m_signal_action_channel_click.emit(it->second); + + return true; + } + } + + return false; +} + +void ChatMessageItemContainer::on_link_menu_copy() { + Gtk::Clipboard::get()->set_text(m_selected_link); +} + +void ChatMessageItemContainer::HandleLinks(Gtk::TextView &tv) { + const auto rgx = Glib::Regex::create(R"(\bhttps?:\/\/[^\s]+\.[^\s]+\b)"); + + tv.signal_button_press_event().connect(sigc::mem_fun(*this, &ChatMessageItemContainer::OnLinkClick), false); + + auto buf = tv.get_buffer(); + Glib::ustring text = GetText(buf); + + // i'd like to let this be done thru css like .message-link { color: #bitch; } but idk how + static auto link_color = Abaddon::Get().GetSettings().GetLinkColor(); + + int startpos = 0; + Glib::MatchInfo match; + while (rgx->match(text, startpos, match)) { + int mstart, mend; + match.fetch_pos(0, mstart, mend); + std::string link = match.fetch(0); + auto tag = buf->create_tag(); + m_link_tagmap[tag] = link; + tag->property_foreground_rgba() = Gdk::RGBA(link_color); + tag->set_property("underline", 1); // stupid workaround for vcpkg bug (i think) + + const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); + const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); + const auto erase_from = buf->get_iter_at_offset(chars_start); + const auto erase_to = buf->get_iter_at_offset(chars_end); + auto it = buf->erase(erase_from, erase_to); + it = buf->insert_with_tag(it, link, tag); + + startpos = mend; + } +} + +bool ChatMessageItemContainer::OnLinkClick(GdkEventButton *ev) { + if (m_text_component == nullptr) return false; + if (ev->type != GDK_BUTTON_PRESS) return false; + if (ev->button != GDK_BUTTON_PRIMARY && ev->button != GDK_BUTTON_SECONDARY) return false; + + auto buf = m_text_component->get_buffer(); + Gtk::TextBuffer::iterator start, end; + buf->get_selection_bounds(start, end); // no open if selection + if (start.get_offset() != end.get_offset()) + return false; + + int x, y; + m_text_component->window_to_buffer_coords(Gtk::TEXT_WINDOW_WIDGET, ev->x, ev->y, x, y); + Gtk::TextBuffer::iterator iter; + m_text_component->get_iter_at_location(iter, x, y); + + const auto tags = iter.get_tags(); + for (auto tag : tags) { + const auto it = m_link_tagmap.find(tag); + if (it != m_link_tagmap.end()) { + if (ev->button == GDK_BUTTON_PRIMARY) { + LaunchBrowser(it->second); + return true; + } else if (ev->button == GDK_BUTTON_SECONDARY) { + m_selected_link = it->second; + m_link_menu.popup_at_pointer(reinterpret_cast(ev)); + return true; + } + } + } + + return false; +} + +ChatMessageItemContainer::type_signal_channel_click ChatMessageItemContainer::signal_action_channel_click() { + return m_signal_action_channel_click; +} + +ChatMessageItemContainer::type_signal_action_reaction_add ChatMessageItemContainer::signal_action_reaction_add() { + return m_signal_action_reaction_add; +} + +ChatMessageItemContainer::type_signal_action_reaction_remove ChatMessageItemContainer::signal_action_reaction_remove() { + return m_signal_action_reaction_remove; +} + +void ChatMessageItemContainer::AttachEventHandlers(Gtk::Widget &widget) { + const auto on_button_press_event = [this](GdkEventButton *e) -> bool { + if (e->type == GDK_BUTTON_PRESS && e->button == GDK_BUTTON_SECONDARY) { + event(reinterpret_cast(e)); // illegal ooooooh + return true; + } + + return false; + }; + widget.signal_button_press_event().connect(on_button_press_event, false); +} + +ChatMessageHeader::ChatMessageHeader(const Message &data) + : m_main_box(Gtk::ORIENTATION_HORIZONTAL) + , m_content_box(Gtk::ORIENTATION_VERTICAL) + , m_meta_box(Gtk::ORIENTATION_HORIZONTAL) + , m_avatar(Abaddon::Get().GetImageManager().GetPlaceholder(AvatarSize)) { + UserID = data.Author.ID; + ChannelID = data.ChannelID; + + const auto author = Abaddon::Get().GetDiscordClient().GetUser(UserID); + auto &img = Abaddon::Get().GetImageManager(); + + auto cb = [this](const Glib::RefPtr &pb) { + m_static_avatar = pb->scale_simple(AvatarSize, AvatarSize, Gdk::INTERP_BILINEAR); + m_avatar.property_pixbuf() = m_static_avatar; + }; + img.LoadFromURL(author->GetAvatarURL(data.GuildID), sigc::track_obj(cb, *this)); + + if (author->HasAnimatedAvatar()) { + auto cb = [this](const Glib::RefPtr &pb) { + m_anim_avatar = pb; + }; + img.LoadAnimationFromURL(author->GetAvatarURL("gif"), AvatarSize, AvatarSize, sigc::track_obj(cb, *this)); + } + + get_style_context()->add_class("message-container"); + m_author.get_style_context()->add_class("message-container-author"); + m_timestamp.get_style_context()->add_class("message-container-timestamp"); + m_avatar.get_style_context()->add_class("message-container-avatar"); + + m_avatar.set_valign(Gtk::ALIGN_START); + m_avatar.set_margin_right(10); + + m_author.set_markup(data.Author.GetEscapedBoldName()); + m_author.set_single_line_mode(true); + m_author.set_line_wrap(false); + m_author.set_ellipsize(Pango::ELLIPSIZE_END); + m_author.set_xalign(0.f); + m_author.set_can_focus(false); + + m_meta_ev.signal_button_press_event().connect(sigc::mem_fun(*this, &ChatMessageHeader::on_author_button_press)); + + if (author->IsBot || data.WebhookID.has_value()) { + m_extra = Gtk::manage(new Gtk::Label); + m_extra->get_style_context()->add_class("message-container-extra"); + m_extra->set_single_line_mode(true); + m_extra->set_margin_start(12); + m_extra->set_can_focus(false); + m_extra->set_use_markup(true); + } + if (author->IsBot) + m_extra->set_markup("BOT"); + else if (data.WebhookID.has_value()) + m_extra->set_markup("Webhook"); + + m_timestamp.set_text(data.ID.GetLocalTimestamp()); + m_timestamp.set_hexpand(true); + m_timestamp.set_halign(Gtk::ALIGN_END); + m_timestamp.set_ellipsize(Pango::ELLIPSIZE_END); + m_timestamp.set_opacity(0.5); + m_timestamp.set_single_line_mode(true); + m_timestamp.set_margin_start(12); + m_timestamp.set_can_focus(false); + + m_main_box.set_hexpand(true); + m_main_box.set_vexpand(true); + m_main_box.set_can_focus(true); + + m_meta_box.set_hexpand(true); + m_meta_box.set_can_focus(false); + + m_content_box.set_can_focus(false); + + const auto on_enter_cb = [this](const GdkEventCrossing *event) -> bool { + if (m_anim_avatar) + m_avatar.property_pixbuf_animation() = m_anim_avatar; + return false; + }; + const auto on_leave_cb = [this](const GdkEventCrossing *event) -> bool { + if (m_anim_avatar) + m_avatar.property_pixbuf() = m_static_avatar; + return false; + }; + + m_content_box_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); + m_meta_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); + m_avatar_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); + if (Abaddon::Get().GetSettings().GetShowAnimations()) { + m_content_box_ev.signal_enter_notify_event().connect(on_enter_cb); + m_content_box_ev.signal_leave_notify_event().connect(on_leave_cb); + m_meta_ev.signal_enter_notify_event().connect(on_enter_cb); + m_meta_ev.signal_leave_notify_event().connect(on_leave_cb); + m_avatar_ev.signal_enter_notify_event().connect(on_enter_cb); + m_avatar_ev.signal_leave_notify_event().connect(on_leave_cb); + } + + m_meta_box.add(m_author); + if (m_extra != nullptr) + m_meta_box.add(*m_extra); + + m_meta_box.add(m_timestamp); + m_meta_ev.add(m_meta_box); + m_content_box.add(m_meta_ev); + m_avatar_ev.add(m_avatar); + m_main_box.add(m_avatar_ev); + m_content_box_ev.add(m_content_box); + m_main_box.add(m_content_box_ev); + add(m_main_box); + + set_margin_bottom(8); + + show_all(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto role_update_cb = [this](...) { UpdateNameColor(); }; + discord.signal_role_update().connect(sigc::track_obj(role_update_cb, *this)); + auto guild_member_update_cb = [this](const auto &, const auto &) { UpdateNameColor(); }; + discord.signal_guild_member_update().connect(sigc::track_obj(guild_member_update_cb, *this)); + UpdateNameColor(); + AttachUserMenuHandler(m_meta_ev); + AttachUserMenuHandler(m_avatar_ev); +} + +void ChatMessageHeader::UpdateNameColor() { + const auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = discord.GetUser(UserID); + if (!user.has_value()) return; + const auto chan = discord.GetChannel(ChannelID); + bool is_guild = chan.has_value() && chan->GuildID.has_value(); + if (is_guild) { + const auto role_id = discord.GetMemberHoistedRole(*chan->GuildID, UserID, true); + const auto role = discord.GetRole(role_id); + + std::string md; + if (role.has_value()) + m_author.set_markup("" + user->GetEscapedName() + ""); + else + m_author.set_markup("" + user->GetEscapedName() + ""); + } else + m_author.set_markup("" + user->GetEscapedName() + ""); +} + +std::vector ChatMessageHeader::GetChildContent() { + return m_content_widgets; +} + +void ChatMessageHeader::AttachUserMenuHandler(Gtk::Widget &widget) { + widget.signal_button_press_event().connect([this](GdkEventButton *ev) -> bool { + if (ev->type == GDK_BUTTON_PRESS && ev->button == GDK_BUTTON_SECONDARY) { + auto info = Abaddon::Get().GetDiscordClient().GetChannel(ChannelID); + Snowflake guild_id; + if (info.has_value() && info->GuildID.has_value()) + guild_id = *info->GuildID; + Abaddon::Get().ShowUserMenu(reinterpret_cast(ev), UserID, guild_id); + return true; + } + + return false; + }); +} + +bool ChatMessageHeader::on_author_button_press(GdkEventButton *ev) { + if (ev->button == GDK_BUTTON_PRIMARY && (ev->state & GDK_SHIFT_MASK)) { + m_signal_action_insert_mention.emit(); + return true; + } + + return false; +} + +ChatMessageHeader::type_signal_action_insert_mention ChatMessageHeader::signal_action_insert_mention() { + return m_signal_action_insert_mention; +} + +ChatMessageHeader::type_signal_action_open_user_menu ChatMessageHeader::signal_action_open_user_menu() { + return m_signal_action_open_user_menu; +} + +void ChatMessageHeader::AddContent(Gtk::Widget *widget, bool prepend) { + m_content_widgets.push_back(widget); + const auto cb = [this, widget]() { + m_content_widgets.erase(std::remove(m_content_widgets.begin(), m_content_widgets.end(), widget), m_content_widgets.end()); + }; + widget->signal_unmap().connect(sigc::track_obj(cb, *this, *widget), false); + m_content_box.add(*widget); + if (prepend) + m_content_box.reorder_child(*widget, 1); + if (auto *x = dynamic_cast(widget)) { + if (x->ID > NewestID) + NewestID = x->ID; + } +} diff --git a/src/components/chatmessage.hpp b/src/components/chatmessage.hpp new file mode 100644 index 0000000..8b69117 --- /dev/null +++ b/src/components/chatmessage.hpp @@ -0,0 +1,125 @@ +#pragma once +#include +#include "discord/discord.hpp" + +class ChatMessageItemContainer : public Gtk::Box { +public: + Snowflake ID; + Snowflake ChannelID; + + std::string Nonce; + + ChatMessageItemContainer(); + static ChatMessageItemContainer *FromMessage(const Message &data); + + // attributes = edited, deleted + void UpdateAttributes(); + void UpdateContent(); + void UpdateReactions(); + void SetFailed(); + +protected: + void AddClickHandler(Gtk::Widget *widget, std::string); + Gtk::TextView *CreateTextComponent(const Message &data); // Message.Content + void UpdateTextComponent(Gtk::TextView *tv); + Gtk::Widget *CreateEmbedComponent(const EmbedData &data); // Message.Embeds[0] + Gtk::Widget *CreateImageComponent(const std::string &proxy_url, const std::string &url, int inw, int inh); + Gtk::Widget *CreateAttachmentComponent(const AttachmentData &data); // non-image attachments + Gtk::Widget *CreateStickerComponentDeprecated(const StickerData &data); + Gtk::Widget *CreateStickersComponent(const std::vector &data); + Gtk::Widget *CreateReactionsComponent(const Message &data); + Gtk::Widget *CreateReplyComponent(const Message &data); + + static Glib::ustring GetText(const Glib::RefPtr &buf); + + static bool IsEmbedImageOnly(const EmbedData &data); + + void HandleUserMentions(Glib::RefPtr buf); + void HandleStockEmojis(Gtk::TextView &tv); + void HandleCustomEmojis(Gtk::TextView &tv); + void HandleEmojis(Gtk::TextView &tv); + void CleanupEmojis(Glib::RefPtr buf); + + void HandleChannelMentions(Glib::RefPtr buf); + void HandleChannelMentions(Gtk::TextView *tv); + bool OnClickChannel(GdkEventButton *ev); + + // reused for images and links + Gtk::Menu m_link_menu; + Gtk::MenuItem *m_link_menu_copy; + + void on_link_menu_copy(); + Glib::ustring m_selected_link; + + void HandleLinks(Gtk::TextView &tv); + bool OnLinkClick(GdkEventButton *ev); + std::map, std::string> m_link_tagmap; + std::map, Snowflake> m_channel_tagmap; + + void AttachEventHandlers(Gtk::Widget &widget); + + Gtk::EventBox *_ev; + Gtk::Box m_main; + Gtk::Label *m_attrib_label = nullptr; + + Gtk::TextView *m_text_component = nullptr; + Gtk::Widget *m_embed_component = nullptr; + Gtk::Widget *m_reactions_component = nullptr; + +public: + typedef sigc::signal type_signal_channel_click; + typedef sigc::signal type_signal_action_reaction_add; + typedef sigc::signal type_signal_action_reaction_remove; + + type_signal_channel_click signal_action_channel_click(); + type_signal_action_reaction_add signal_action_reaction_add(); + type_signal_action_reaction_remove signal_action_reaction_remove(); + +private: + type_signal_channel_click m_signal_action_channel_click; + type_signal_action_reaction_add m_signal_action_reaction_add; + type_signal_action_reaction_remove m_signal_action_reaction_remove; +}; + +class ChatMessageHeader : public Gtk::ListBoxRow { +public: + Snowflake UserID; + Snowflake ChannelID; + Snowflake NewestID = 0; + + ChatMessageHeader(const Message &data); + void AddContent(Gtk::Widget *widget, bool prepend); + void UpdateNameColor(); + std::vector GetChildContent(); + +protected: + void AttachUserMenuHandler(Gtk::Widget &widget); + + bool on_author_button_press(GdkEventButton *ev); + + std::vector m_content_widgets; + + Gtk::Box m_main_box; + Gtk::Box m_content_box; + Gtk::EventBox m_content_box_ev; + Gtk::Box m_meta_box; + Gtk::EventBox m_meta_ev; + Gtk::Label m_author; + Gtk::Label m_timestamp; + Gtk::Label *m_extra = nullptr; + Gtk::Image m_avatar; + Gtk::EventBox m_avatar_ev; + + Glib::RefPtr m_static_avatar; + Glib::RefPtr m_anim_avatar; + + typedef sigc::signal type_signal_action_insert_mention; + typedef sigc::signal type_signal_action_open_user_menu; + + type_signal_action_insert_mention m_signal_action_insert_mention; + type_signal_action_open_user_menu m_signal_action_open_user_menu; + +public: + type_signal_action_insert_mention signal_action_insert_mention(); + type_signal_action_open_user_menu signal_action_open_user_menu(); +}; diff --git a/src/components/chatwindow.cpp b/src/components/chatwindow.cpp new file mode 100644 index 0000000..9b34dfd --- /dev/null +++ b/src/components/chatwindow.cpp @@ -0,0 +1,239 @@ +#include "chatwindow.hpp" +#include "chatmessage.hpp" +#include "abaddon.hpp" +#include "chatinputindicator.hpp" +#include "ratelimitindicator.hpp" +#include "chatinput.hpp" +#include "chatlist.hpp" + +ChatWindow::ChatWindow() { + Abaddon::Get().GetDiscordClient().signal_message_send_fail().connect(sigc::mem_fun(*this, &ChatWindow::OnMessageSendFail)); + + m_main = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + m_chat = Gtk::manage(new ChatList); + m_input = Gtk::manage(new ChatInput); + m_input_indicator = Gtk::manage(new ChatInputIndicator); + m_rate_limit_indicator = Gtk::manage(new RateLimitIndicator); + m_meta = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + + m_rate_limit_indicator->set_margin_end(5); + m_rate_limit_indicator->set_hexpand(true); + m_rate_limit_indicator->set_halign(Gtk::ALIGN_END); + m_rate_limit_indicator->set_valign(Gtk::ALIGN_END); + m_rate_limit_indicator->show(); + + m_input_indicator->set_halign(Gtk::ALIGN_START); + m_input_indicator->set_valign(Gtk::ALIGN_END); + m_input_indicator->show(); + + m_main->get_style_context()->add_class("messages"); + + m_main->set_hexpand(true); + m_main->set_vexpand(true); + + m_topic.get_style_context()->add_class("channel-topic"); + m_topic.add(m_topic_text); + m_topic_text.set_halign(Gtk::ALIGN_START); + m_topic_text.show(); + + m_input->signal_submit().connect(sigc::mem_fun(*this, &ChatWindow::OnInputSubmit)); + m_input->signal_escape().connect([this]() { + if (m_is_replying) + StopReplying(); + }); + m_input->signal_key_press_event().connect(sigc::mem_fun(*this, &ChatWindow::OnKeyPressEvent), false); + m_input->show(); + + m_completer.SetBuffer(m_input->GetBuffer()); + m_completer.SetGetChannelID([this]() -> auto { + return m_active_channel; + }); + + m_completer.SetGetRecentAuthors([this]() -> auto { + return m_chat->GetRecentAuthors(); + }); + + m_completer.show(); + + m_chat->signal_action_channel_click().connect([this](Snowflake id) { + m_signal_action_channel_click.emit(id); + }); + m_chat->signal_action_chat_load_history().connect([this](Snowflake id) { + m_signal_action_chat_load_history.emit(id); + }); + m_chat->signal_action_chat_submit().connect([this](const std::string &str, Snowflake channel_id, Snowflake referenced_id) { + m_signal_action_chat_submit.emit(str, channel_id, referenced_id); + }); + m_chat->signal_action_insert_mention().connect([this](Snowflake id) { + // lowkey gross + m_signal_action_insert_mention.emit(id); + }); + m_chat->signal_action_message_edit().connect([this](Snowflake channel_id, Snowflake message_id) { + m_signal_action_message_edit.emit(channel_id, message_id); + }); + m_chat->signal_action_reaction_add().connect([this](Snowflake id, const Glib::ustring ¶m) { + m_signal_action_reaction_add.emit(id, param); + }); + m_chat->signal_action_reaction_remove().connect([this](Snowflake id, const Glib::ustring ¶m) { + m_signal_action_reaction_remove.emit(id, param); + }); + m_chat->signal_action_reply_to().connect([this](Snowflake id) { + StartReplying(id); + }); + m_chat->show(); + + m_meta->set_hexpand(true); + m_meta->set_halign(Gtk::ALIGN_FILL); + m_meta->show(); + + m_meta->add(*m_input_indicator); + m_meta->add(*m_rate_limit_indicator); + //m_scroll->add(*m_list); + m_main->add(m_topic); + m_main->add(*m_chat); + m_main->add(m_completer); + m_main->add(*m_input); + m_main->add(*m_meta); + m_main->show(); +} + +Gtk::Widget *ChatWindow::GetRoot() const { + return m_main; +} + +void ChatWindow::Clear() { + m_chat->Clear(); +} + +void ChatWindow::SetMessages(const std::vector &msgs) { + m_chat->SetMessages(msgs.begin(), msgs.end()); +} + +void ChatWindow::SetActiveChannel(Snowflake id) { + m_active_channel = id; + m_chat->SetActiveChannel(id); + m_input_indicator->SetActiveChannel(id); + m_rate_limit_indicator->SetActiveChannel(id); + if (m_is_replying) + StopReplying(); +} + +void ChatWindow::AddNewMessage(const Message &data) { + m_chat->ProcessNewMessage(data, false); +} + +void ChatWindow::DeleteMessage(Snowflake id) { + m_chat->DeleteMessage(id); +} + +void ChatWindow::UpdateMessage(Snowflake id) { + m_chat->RefetchMessage(id); +} + +void ChatWindow::AddNewHistory(const std::vector &msgs) { + m_chat->PrependMessages(msgs.crbegin(), msgs.crend()); +} + +void ChatWindow::InsertChatInput(std::string text) { + m_input->InsertText(text); +} + +Snowflake ChatWindow::GetOldestListedMessage() { + return m_chat->GetOldestListedMessage(); +} + +void ChatWindow::UpdateReactions(Snowflake id) { + m_chat->UpdateMessageReactions(id); +} + +void ChatWindow::SetTopic(const std::string &text) { + m_topic_text.set_text(text); + m_topic.set_visible(text.length() > 0); +} + +Snowflake ChatWindow::GetActiveChannel() const { + return m_active_channel; +} + +bool ChatWindow::OnInputSubmit(const Glib::ustring &text) { + if (!m_rate_limit_indicator->CanSpeak()) + return false; + + if (text.size() == 0) + return false; + + if (m_active_channel.IsValid()) + m_signal_action_chat_submit.emit(text, m_active_channel, m_replying_to); // m_replying_to is checked for invalid in the handler + if (m_is_replying) + StopReplying(); + + return true; +} + +bool ChatWindow::OnKeyPressEvent(GdkEventKey *e) { + if (m_completer.ProcessKeyPress(e)) + return true; + + if (m_input->ProcessKeyPress(e)) + return true; + + return false; +} + +void ChatWindow::StartReplying(Snowflake message_id) { + const auto &discord = Abaddon::Get().GetDiscordClient(); + const auto message = *discord.GetMessage(message_id); + const auto author = discord.GetUser(message.Author.ID); + m_replying_to = message_id; + m_is_replying = true; + m_input->grab_focus(); + m_input->get_style_context()->add_class("replying"); + if (author.has_value()) + m_input_indicator->SetCustomMarkup("Replying to " + author->GetEscapedBoldString()); + else + m_input_indicator->SetCustomMarkup("Replying..."); +} + +void ChatWindow::StopReplying() { + m_is_replying = false; + m_replying_to = Snowflake::Invalid; + m_input->get_style_context()->remove_class("replying"); + m_input_indicator->ClearCustom(); +} + +void ChatWindow::OnScrollEdgeOvershot(Gtk::PositionType pos) { + if (pos == Gtk::POS_TOP) + m_signal_action_chat_load_history.emit(m_active_channel); +} + +void ChatWindow::OnMessageSendFail(const std::string &nonce, float retry_after) { + m_chat->SetFailedByNonce(nonce); +} + +ChatWindow::type_signal_action_message_edit ChatWindow::signal_action_message_edit() { + return m_signal_action_message_edit; +} + +ChatWindow::type_signal_action_chat_submit ChatWindow::signal_action_chat_submit() { + return m_signal_action_chat_submit; +} + +ChatWindow::type_signal_action_chat_load_history ChatWindow::signal_action_chat_load_history() { + return m_signal_action_chat_load_history; +} + +ChatWindow::type_signal_action_channel_click ChatWindow::signal_action_channel_click() { + return m_signal_action_channel_click; +} + +ChatWindow::type_signal_action_insert_mention ChatWindow::signal_action_insert_mention() { + return m_signal_action_insert_mention; +} + +ChatWindow::type_signal_action_reaction_add ChatWindow::signal_action_reaction_add() { + return m_signal_action_reaction_add; +} + +ChatWindow::type_signal_action_reaction_remove ChatWindow::signal_action_reaction_remove() { + return m_signal_action_reaction_remove; +} diff --git a/src/components/chatwindow.hpp b/src/components/chatwindow.hpp new file mode 100644 index 0000000..de55b0a --- /dev/null +++ b/src/components/chatwindow.hpp @@ -0,0 +1,90 @@ +#pragma once +#include +#include +#include +#include "discord/discord.hpp" +#include "completer.hpp" + +class ChatMessageHeader; +class ChatMessageItemContainer; +class ChatInput; +class ChatInputIndicator; +class RateLimitIndicator; +class ChatList; +class ChatWindow { +public: + ChatWindow(); + + Gtk::Widget *GetRoot() const; + Snowflake GetActiveChannel() const; + + void Clear(); + void SetMessages(const std::vector &msgs); // clear contents and replace with given set + void SetActiveChannel(Snowflake id); + void AddNewMessage(const Message &data); // append new message to bottom + void DeleteMessage(Snowflake id); // add [deleted] indicator + void UpdateMessage(Snowflake id); // add [edited] indicator + void AddNewHistory(const std::vector &msgs); // prepend messages + void InsertChatInput(std::string text); + Snowflake GetOldestListedMessage(); // oldest message that is currently in the ListBox + void UpdateReactions(Snowflake id); + void SetTopic(const std::string &text); + +protected: + bool m_is_replying = false; + Snowflake m_replying_to; + + void StartReplying(Snowflake message_id); + void StopReplying(); + + Snowflake m_active_channel; + + bool OnInputSubmit(const Glib::ustring &text); + + bool OnKeyPressEvent(GdkEventKey *e); + void OnScrollEdgeOvershot(Gtk::PositionType pos); + + void OnMessageSendFail(const std::string &nonce, float retry_after); + + Gtk::Box *m_main; + //Gtk::ListBox *m_list; + //Gtk::ScrolledWindow *m_scroll; + + Gtk::EventBox m_topic; // todo probably make everything else go on the stack + Gtk::Label m_topic_text; + + ChatList *m_chat; + + ChatInput *m_input; + + Completer m_completer; + ChatInputIndicator *m_input_indicator; + RateLimitIndicator *m_rate_limit_indicator; + Gtk::Box *m_meta; + +public: + typedef sigc::signal type_signal_action_message_edit; + typedef sigc::signal type_signal_action_chat_submit; + typedef sigc::signal type_signal_action_chat_load_history; + typedef sigc::signal type_signal_action_channel_click; + typedef sigc::signal type_signal_action_insert_mention; + typedef sigc::signal type_signal_action_reaction_add; + typedef sigc::signal type_signal_action_reaction_remove; + + type_signal_action_message_edit signal_action_message_edit(); + type_signal_action_chat_submit signal_action_chat_submit(); + type_signal_action_chat_load_history signal_action_chat_load_history(); + type_signal_action_channel_click signal_action_channel_click(); + type_signal_action_insert_mention signal_action_insert_mention(); + type_signal_action_reaction_add signal_action_reaction_add(); + type_signal_action_reaction_remove signal_action_reaction_remove(); + +private: + type_signal_action_message_edit m_signal_action_message_edit; + type_signal_action_chat_submit m_signal_action_chat_submit; + type_signal_action_chat_load_history m_signal_action_chat_load_history; + type_signal_action_channel_click m_signal_action_channel_click; + type_signal_action_insert_mention m_signal_action_insert_mention; + type_signal_action_reaction_add m_signal_action_reaction_add; + type_signal_action_reaction_remove m_signal_action_reaction_remove; +}; diff --git a/src/components/completer.cpp b/src/components/completer.cpp new file mode 100644 index 0000000..327ef95 --- /dev/null +++ b/src/components/completer.cpp @@ -0,0 +1,392 @@ +#include +#include "completer.hpp" +#include "abaddon.hpp" +#include "util.hpp" + +constexpr const int CompleterHeight = 150; +constexpr const int MaxCompleterEntries = 30; + +Completer::Completer() { + set_reveal_child(false); + set_transition_type(Gtk::REVEALER_TRANSITION_TYPE_NONE); // only SLIDE_UP and NONE work decently + + m_scroll.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + m_scroll.set_max_content_height(CompleterHeight); + m_scroll.set_size_request(-1, CompleterHeight); + m_scroll.set_placement(Gtk::CORNER_BOTTOM_LEFT); + + m_list.set_adjustment(m_scroll.get_vadjustment()); + m_list.set_focus_vadjustment(m_scroll.get_vadjustment()); + m_list.get_style_context()->add_class("completer"); + m_list.set_activate_on_single_click(true); + + m_list.set_focus_on_click(false); + set_can_focus(false); + + m_list.signal_row_activated().connect(sigc::mem_fun(*this, &Completer::OnRowActivate)); + + m_scroll.add(m_list); + add(m_scroll); + show_all(); +} + +Completer::Completer(const Glib::RefPtr &buf) + : Completer() { + SetBuffer(buf); +} + +void Completer::SetBuffer(const Glib::RefPtr &buf) { + m_buf = buf; + m_buf->signal_changed().connect(sigc::mem_fun(*this, &Completer::OnTextBufferChanged)); +} + +bool Completer::ProcessKeyPress(GdkEventKey *e) { + if (!IsShown()) return false; + if (e->type != GDK_KEY_PRESS) return false; + + switch (e->keyval) { + case GDK_KEY_Down: { + if (m_entries.size() == 0) return true; + const auto index = static_cast(m_list.get_selected_row()->get_index()); + if (index >= m_entries.size() - 1) return true; + m_list.select_row(*m_entries[index + 1]); + ScrollListBoxToSelected(m_list); + } + return true; + case GDK_KEY_Up: { + if (m_entries.size() == 0) return true; + const auto index = static_cast(m_list.get_selected_row()->get_index()); + if (index == 0) return true; + m_list.select_row(*m_entries[index - 1]); + ScrollListBoxToSelected(m_list); + } + return true; + case GDK_KEY_Return: { + if (m_entries.size() == 0) return true; + DoCompletion(m_list.get_selected_row()); + } + return true; + default: + break; + } + + return false; +} + +void Completer::SetGetRecentAuthors(get_recent_authors_cb cb) { + m_recent_authors_cb = cb; +} + +void Completer::SetGetChannelID(get_channel_id_cb cb) { + m_channel_id_cb = cb; +} + +bool Completer::IsShown() const { + return get_child_revealed(); +} + +CompleterEntry *Completer::CreateEntry(const Glib::ustring &completion) { + auto entry = Gtk::manage(new CompleterEntry(completion, m_entries.size())); + m_entries.push_back(entry); + entry->show_all(); + m_list.add(*entry); + return entry; +} + +void Completer::CompleteMentions(const Glib::ustring &term) { + if (!m_recent_authors_cb) + return; + + const auto &discord = Abaddon::Get().GetDiscordClient(); + + Snowflake channel_id; + if (m_channel_id_cb) + channel_id = m_channel_id_cb(); + auto author_ids = m_recent_authors_cb(); + if (channel_id.IsValid()) { + const auto chan = discord.GetChannel(channel_id); + if (chan->GuildID.has_value()) { + const auto members = discord.GetUsersInGuild(*chan->GuildID); + for (const auto x : members) + if (std::find(author_ids.begin(), author_ids.end(), x) == author_ids.end()) + author_ids.push_back(x); + } + } + const auto me = discord.GetUserData().ID; + int i = 0; + for (const auto id : author_ids) { + if (id == me) continue; + const auto author = discord.GetUser(id); + if (!author.has_value()) continue; + if (!StringContainsCaseless(author->Username, term)) continue; + if (i++ > 15) break; + + auto entry = CreateEntry(author->GetMention()); + + entry->SetText(author->Username + "#" + author->Discriminator); + + if (channel_id.IsValid()) { + const auto chan = discord.GetChannel(channel_id); + if (chan.has_value() && chan->GuildID.has_value()) { + const auto role_id = discord.GetMemberHoistedRole(*chan->GuildID, id, true); + if (role_id.IsValid()) { + const auto role = discord.GetRole(role_id); + if (role.has_value()) + entry->SetTextColor(role->Color); + } + } + } + + entry->SetImage(author->GetAvatarURL()); + } +} + +void Completer::CompleteEmojis(const Glib::ustring &term) { + if (!m_channel_id_cb) + return; + + const auto &discord = Abaddon::Get().GetDiscordClient(); + const auto channel_id = m_channel_id_cb(); + const auto channel = discord.GetChannel(channel_id); + + const auto make_entry = [&](const Glib::ustring &name, const Glib::ustring &completion, const Glib::ustring &url = "", bool animated = false) -> CompleterEntry * { + const auto entry = CreateEntry(completion); + entry->SetText(name); + if (url == "") return entry; + if (animated) + entry->SetAnimation(url); + else + entry->SetImage(url); + return entry; + }; + + const auto self_id = discord.GetUserData().ID; + const bool can_use_external = discord.GetSelfPremiumType() != EPremiumType::None && discord.HasChannelPermission(self_id, channel_id, Permission::USE_EXTERNAL_EMOJIS); + + int i = 0; + if (!can_use_external) { + if (channel->GuildID.has_value()) { + const auto guild = discord.GetGuild(*channel->GuildID); + + if (guild.has_value() && guild->Emojis.has_value()) + for (const auto &tmp : *guild->Emojis) { + const auto emoji = *discord.GetEmoji(tmp.ID); + if (emoji.IsAnimated.has_value() && *emoji.IsAnimated) continue; + if (emoji.IsAvailable.has_value() && !*emoji.IsAvailable) continue; + if (emoji.Roles.has_value() && emoji.Roles->size() > 0) continue; + if (term.size() > 0) + if (!StringContainsCaseless(emoji.Name, term)) continue; + + if (i++ > MaxCompleterEntries) break; + + make_entry(emoji.Name, "<:" + emoji.Name + ":" + std::to_string(emoji.ID) + ">", emoji.GetURL()); + } + } + } else { + for (const auto guild_id : discord.GetGuilds()) { + const auto guild = discord.GetGuild(guild_id); + if (!guild.has_value()) continue; + for (const auto &tmp : *guild->Emojis) { + const auto emoji = *discord.GetEmoji(tmp.ID); + const bool is_animated = emoji.IsAnimated.has_value() && *emoji.IsAnimated; + if (emoji.IsAvailable.has_value() && !*emoji.IsAvailable) continue; + if (emoji.Roles.has_value() && emoji.Roles->size() > 0) continue; + if (term.size() > 0) + if (!StringContainsCaseless(emoji.Name, term)) continue; + + if (i++ > MaxCompleterEntries) goto done; + + if (is_animated) + make_entry(emoji.Name, "", emoji.GetURL("gif"), true); + else + make_entry(emoji.Name, "<:" + emoji.Name + ":" + std::to_string(emoji.ID) + ">", emoji.GetURL()); + } + } + } +done: + + // if <15 guild emojis match then load up stock + if (i < 15) { + std::unordered_set added_patterns; + auto &emojis = Abaddon::Get().GetEmojis(); + const auto &shortcodes = emojis.GetShortCodes(); + for (const auto &[shortcode, pattern] : shortcodes) { + if (added_patterns.find(pattern) != added_patterns.end()) continue; + if (!StringContainsCaseless(shortcode, term)) continue; + if (i++ > 15) break; + const auto &pb = emojis.GetPixBuf(pattern); + if (!pb) continue; + added_patterns.insert(pattern); + const auto entry = make_entry(shortcode, pattern); + entry->SetImage(pb->scale_simple(CompleterImageSize, CompleterImageSize, Gdk::INTERP_BILINEAR)); + } + } +} + +void Completer::CompleteChannels(const Glib::ustring &term) { + if (!m_channel_id_cb) + return; + + const auto &discord = Abaddon::Get().GetDiscordClient(); + const auto channel_id = m_channel_id_cb(); + const auto channel = discord.GetChannel(channel_id); + if (!channel->GuildID.has_value()) return; + const auto channels = discord.GetChannelsInGuild(*channel->GuildID); + int i = 0; + for (const auto chan_id : channels) { + const auto chan = discord.GetChannel(chan_id); + if (chan->Type == ChannelType::GUILD_VOICE || chan->Type == ChannelType::GUILD_CATEGORY) continue; + if (!StringContainsCaseless(*chan->Name, term)) continue; + if (i++ > MaxCompleterEntries) break; + const auto entry = CreateEntry("<#" + std::to_string(chan_id) + ">"); + entry->SetText("#" + *chan->Name); + } +} + +void Completer::DoCompletion(Gtk::ListBoxRow *row) { + const int index = row->get_index(); + const auto completion = m_entries[index]->GetCompletion(); + const auto it = m_buf->erase(m_start, m_end); // entry is deleted here + m_buf->insert(it, completion + " "); +} + +void Completer::OnRowActivate(Gtk::ListBoxRow *row) { + DoCompletion(row); +} + +void Completer::OnTextBufferChanged() { + const auto term = GetTerm(); + + for (auto it = m_entries.begin(); it != m_entries.end();) { + delete *it; + it = m_entries.erase(it); + } + + switch (term[0]) { + case '@': + CompleteMentions(term.substr(1)); + break; + case ':': + CompleteEmojis(term.substr(1)); + break; + case '#': + CompleteChannels(term.substr(1)); + break; + default: + break; + } + if (m_entries.size() > 0) { + m_list.select_row(*m_entries[0]); + set_reveal_child(true); + } else { + set_reveal_child(false); + } +} + +bool MultiBackwardSearch(const Gtk::TextIter &iter, const Glib::ustring &chars, Gtk::TextSearchFlags flags, Gtk::TextBuffer::iterator &out) { + bool any = false; + for (const auto c : chars) { + Glib::ustring tmp(1, c); + Gtk::TextBuffer::iterator tstart, tend; + if (!iter.backward_search(tmp, flags, tstart, tend)) continue; + // if previous found, compare to see if closer to out iter + if (any) { + if (tstart.get_offset() > out.get_offset()) + out = tstart; + } else + out = tstart; + any = true; + } + return any; +} + +bool MultiForwardSearch(const Gtk::TextIter &iter, const Glib::ustring &chars, Gtk::TextSearchFlags flags, Gtk::TextBuffer::iterator &out) { + bool any = false; + for (const auto c : chars) { + Glib::ustring tmp(1, c); + Gtk::TextBuffer::iterator tstart, tend; + if (!iter.forward_search(tmp, flags, tstart, tend)) continue; + // if previous found, compare to see if closer to out iter + if (any) { + if (tstart.get_offset() < out.get_offset()) + out = tstart; + } else + out = tstart; + any = true; + } + return any; +} + +Glib::ustring Completer::GetTerm() { + const auto iter = m_buf->get_insert()->get_iter(); + Gtk::TextBuffer::iterator dummy; + if (!MultiBackwardSearch(iter, " \n", Gtk::TEXT_SEARCH_TEXT_ONLY, m_start)) + m_buf->get_bounds(m_start, dummy); + else + m_start.forward_char(); // 1 behind + if (!MultiForwardSearch(iter, " \n", Gtk::TEXT_SEARCH_TEXT_ONLY, m_end)) + m_buf->get_bounds(dummy, m_end); + return m_start.get_text(m_end); +} + +CompleterEntry::CompleterEntry(const Glib::ustring &completion, int index) + : m_completion(completion) + , m_index(index) + , m_box(Gtk::ORIENTATION_HORIZONTAL) { + set_halign(Gtk::ALIGN_START); + get_style_context()->add_class("completer-entry"); + set_can_focus(false); + set_focus_on_click(false); + m_box.show(); + add(m_box); +} + +void CompleterEntry::SetTextColor(int color) { + if (m_text == nullptr) return; + const auto cur = m_text->get_text(); + m_text->set_markup("" + Glib::Markup::escape_text(cur) + ""); +} + +void CompleterEntry::SetText(const Glib::ustring &text) { + if (m_text == nullptr) { + m_text = Gtk::manage(new Gtk::Label); + m_text->get_style_context()->add_class("completer-entry-label"); + m_text->show(); + m_box.pack_end(*m_text); + } + m_text->set_label(text); +} + +void CompleterEntry::SetImage(const Glib::RefPtr &pb) { + CheckImage(); + m_img->property_pixbuf() = pb; +} + +void CompleterEntry::SetImage(const std::string &url) { + CheckImage(); + m_img->SetAnimated(false); + m_img->SetURL(url); +} + +void CompleterEntry::SetAnimation(const std::string &url) { + CheckImage(); + m_img->SetAnimated(true); + m_img->SetURL(url); +} + +void CompleterEntry::CheckImage() { + if (m_img == nullptr) { + m_img = Gtk::manage(new LazyImage(CompleterImageSize, CompleterImageSize)); + m_img->get_style_context()->add_class("completer-entry-image"); + m_img->show(); + m_box.pack_start(*m_img); + } +} + +int CompleterEntry::GetIndex() const { + return m_index; +} + +Glib::ustring CompleterEntry::GetCompletion() const { + return m_completion; +} diff --git a/src/components/completer.hpp b/src/components/completer.hpp new file mode 100644 index 0000000..6bd8be9 --- /dev/null +++ b/src/components/completer.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include "lazyimage.hpp" +#include "discord/snowflake.hpp" + +constexpr static int CompleterImageSize = 24; + +class CompleterEntry : public Gtk::ListBoxRow { +public: + CompleterEntry(const Glib::ustring &completion, int index); + void SetTextColor(int color); // SetText will reset + void SetText(const Glib::ustring &text); + void SetImage(const Glib::RefPtr &pb); + void SetImage(const std::string &url); + void SetAnimation(const std::string &url); + + int GetIndex() const; + Glib::ustring GetCompletion() const; + +private: + void CheckImage(); + + Glib::ustring m_completion; + int m_index; + Gtk::Box m_box; + Gtk::Label *m_text = nullptr; + LazyImage *m_img = nullptr; +}; + +class Completer : public Gtk::Revealer { +public: + Completer(); + Completer(const Glib::RefPtr &buf); + + void SetBuffer(const Glib::RefPtr &buf); + bool ProcessKeyPress(GdkEventKey *e); + + using get_recent_authors_cb = std::function()>; + void SetGetRecentAuthors(get_recent_authors_cb cb); // maybe a better way idk + using get_channel_id_cb = std::function; + void SetGetChannelID(get_channel_id_cb cb); + + bool IsShown() const; + +private: + CompleterEntry *CreateEntry(const Glib::ustring &completion); + void CompleteMentions(const Glib::ustring &term); + void CompleteEmojis(const Glib::ustring &term); + void CompleteChannels(const Glib::ustring &term); + void DoCompletion(Gtk::ListBoxRow *row); + + std::vector m_entries; + + void OnRowActivate(Gtk::ListBoxRow *row); + void OnTextBufferChanged(); + Glib::ustring GetTerm(); + + Gtk::TextBuffer::iterator m_start; + Gtk::TextBuffer::iterator m_end; + + Gtk::ScrolledWindow m_scroll; + Gtk::ListBox m_list; + Glib::RefPtr m_buf; + + get_recent_authors_cb m_recent_authors_cb; + get_channel_id_cb m_channel_id_cb; +}; diff --git a/src/components/draglistbox.cpp b/src/components/draglistbox.cpp new file mode 100644 index 0000000..492abc3 --- /dev/null +++ b/src/components/draglistbox.cpp @@ -0,0 +1,141 @@ +#include "draglistbox.hpp" + +DragListBox::DragListBox() { + drag_dest_set(m_entries, Gtk::DEST_DEFAULT_MOTION | Gtk::DEST_DEFAULT_DROP, Gdk::ACTION_MOVE); +} + +void DragListBox::row_drag_begin(Gtk::Widget *widget, const Glib::RefPtr &context) { + m_drag_row = dynamic_cast(widget->get_ancestor(GTK_TYPE_LIST_BOX_ROW)); + + auto alloc = m_drag_row->get_allocation(); + auto surface = Cairo::ImageSurface::create(Cairo::FORMAT_ARGB32, alloc.get_width(), alloc.get_height()); + auto cr = Cairo::Context::create(surface); + + m_drag_row->get_style_context()->add_class("drag-icon"); + gtk_widget_draw(reinterpret_cast(m_drag_row->gobj()), cr->cobj()); + m_drag_row->get_style_context()->remove_class("drag-icon"); + + int x, y; + widget->translate_coordinates(*m_drag_row, 0, 0, x, y); + surface->set_device_offset(-x, -y); + context->set_icon(surface); +} + +bool DragListBox::on_drag_motion(const Glib::RefPtr &context, gint x, gint y, guint time) { + if (y > m_hover_top || y < m_hover_bottom) { + auto *row = get_row_at_y(y); + if (row == nullptr) return true; + const bool old_top = m_top; + const auto alloc = row->get_allocation(); + + const int hover_row_y = alloc.get_y(); + const int hover_row_height = alloc.get_height(); + if (row != m_drag_row) { + if (y < hover_row_y + hover_row_height / 2) { + m_hover_top = hover_row_y; + m_hover_bottom = m_hover_top + hover_row_height / 2; + row->get_style_context()->add_class("drag-hover-top"); + row->get_style_context()->remove_class("drag-hover-bottom"); + m_top = true; + } else { + m_hover_top = hover_row_y + hover_row_height / 2; + m_hover_bottom = hover_row_y + hover_row_height; + row->get_style_context()->add_class("drag-hover-bottom"); + row->get_style_context()->remove_class("drag-hover-top"); + m_top = false; + } + } + + if (m_hover_row != nullptr && m_hover_row != row) { + if (old_top) + m_hover_row->get_style_context()->remove_class("drag-hover-top"); + else + m_hover_row->get_style_context()->remove_class("drag-hover-bottom"); + } + + m_hover_row = row; + } + + check_scroll(y); + if (m_should_scroll && !m_scrolling) { + m_scrolling = true; + Glib::signal_timeout().connect(sigc::mem_fun(*this, &DragListBox::scroll), SCROLL_DELAY); + } + + return true; +} + +void DragListBox::on_drag_leave(const Glib::RefPtr &context, guint time) { + m_should_scroll = false; +} + +void DragListBox::check_scroll(gint y) { + if (!get_focus_vadjustment()) + return; + + const double vadjustment_min = get_focus_vadjustment()->get_value(); + const double vadjustment_max = get_focus_vadjustment()->get_page_size() + vadjustment_min; + const double show_min = std::max(0, y - SCROLL_DISTANCE); + const double show_max = std::min(get_focus_vadjustment()->get_upper(), static_cast(y) + SCROLL_DISTANCE); + if (vadjustment_min > show_min) { + m_should_scroll = true; + m_scroll_up = true; + } else if (vadjustment_max < show_max) { + m_should_scroll = true; + m_scroll_up = false; + } else { + m_should_scroll = false; + } +} + +bool DragListBox::scroll() { + if (m_should_scroll) { + if (m_scroll_up) { + get_focus_vadjustment()->set_value(get_focus_vadjustment()->get_value() - SCROLL_STEP_SIZE); + } else { + get_focus_vadjustment()->set_value(get_focus_vadjustment()->get_value() + SCROLL_STEP_SIZE); + } + } else { + m_scrolling = false; + } + return m_should_scroll; +} + +void DragListBox::on_drag_data_received(const Glib::RefPtr &context, int x, int y, const Gtk::SelectionData &selection_data, guint info, guint time) { + int index = 0; + if (m_hover_row != nullptr) { + if (m_top) { + index = m_hover_row->get_index() - 1; + m_hover_row->get_style_context()->remove_class("drag-hover-top"); + } else { + index = m_hover_row->get_index(); + m_hover_row->get_style_context()->remove_class("drag-hover-bottom"); + } + + Gtk::Widget *handle = *reinterpret_cast(selection_data.get_data()); + auto *row = dynamic_cast(handle->get_ancestor(GTK_TYPE_LIST_BOX_ROW)); + + if (row != nullptr && row != m_hover_row) { + if (row->get_index() > index) + index += 1; + if (m_signal_on_drop.emit(row, index)) return; + row->get_parent()->remove(*row); + insert(*row, index); + } + } + + m_drag_row = nullptr; +} + +void DragListBox::add_draggable(Gtk::ListBoxRow *widget) { + widget->drag_source_set(m_entries, Gdk::BUTTON1_MASK, Gdk::ACTION_MOVE); + widget->signal_drag_begin().connect(sigc::bind<0>(sigc::mem_fun(*this, &DragListBox::row_drag_begin), widget)); + widget->signal_drag_data_get().connect([this, widget](const Glib::RefPtr &context, Gtk::SelectionData &selection_data, guint info, guint time) { + selection_data.set("GTK_LIST_BOX_ROW", 32, reinterpret_cast(&widget), sizeof(&widget)); + }); + add(*widget); +} + +DragListBox::type_signal_on_drop DragListBox::signal_on_drop() { + return m_signal_on_drop; +} diff --git a/src/components/draglistbox.hpp b/src/components/draglistbox.hpp new file mode 100644 index 0000000..9f204be --- /dev/null +++ b/src/components/draglistbox.hpp @@ -0,0 +1,45 @@ +#pragma once +#include + +class DragListBox : public Gtk::ListBox { +public: + DragListBox(); + + void row_drag_begin(Gtk::Widget *widget, const Glib::RefPtr &context); + + bool on_drag_motion(const Glib::RefPtr &context, gint x, gint y, guint time) override; + + void on_drag_leave(const Glib::RefPtr &context, guint time) override; + + void check_scroll(gint y); + + bool scroll(); + + void on_drag_data_received(const Glib::RefPtr &context, int x, int y, const Gtk::SelectionData &selection_data, guint info, guint time) override; + + void add_draggable(Gtk::ListBoxRow *widget); + +private: + Gtk::ListBoxRow *m_hover_row = nullptr; + Gtk::ListBoxRow *m_drag_row = nullptr; + bool m_top = false; + int m_hover_top = 0; + int m_hover_bottom = 0; + bool m_should_scroll = false; + bool m_scrolling = false; + bool m_scroll_up = false; + + constexpr static int SCROLL_STEP_SIZE = 8; + constexpr static int SCROLL_DISTANCE = 30; + constexpr static int SCROLL_DELAY = 50; + + const std::vector m_entries = { + Gtk::TargetEntry("GTK_LIST_BOX_ROW", Gtk::TARGET_SAME_APP, 0), + }; + + using type_signal_on_drop = sigc::signal; + type_signal_on_drop m_signal_on_drop; + +public: + type_signal_on_drop signal_on_drop(); // return true to prevent drop +}; diff --git a/src/components/friendslist.cpp b/src/components/friendslist.cpp new file mode 100644 index 0000000..3896f02 --- /dev/null +++ b/src/components/friendslist.cpp @@ -0,0 +1,354 @@ +#include "friendslist.hpp" +#include "abaddon.hpp" +#include "lazyimage.hpp" + +using namespace std::string_literals; + +FriendsList::FriendsList() + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , m_filter_mode(FILTER_FRIENDS) { + get_style_context()->add_class("friends-list"); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + discord.signal_relationship_add().connect(sigc::mem_fun(*this, &FriendsList::OnRelationshipAdd)); + discord.signal_relationship_remove().connect(sigc::mem_fun(*this, &FriendsList::OnRelationshipRemove)); + + PopulateRelationships(); + signal_map().connect(sigc::mem_fun(*this, &FriendsList::PopulateRelationships)); + + constexpr static std::array strs = { + "Friends", + "Online", + "Pending", + "Blocked", + }; + for (const auto &x : strs) { + auto *btn = Gtk::manage(new Gtk::RadioButton(m_group, x)); + m_buttons.add(*btn); + btn->show(); + btn->signal_toggled().connect([this, btn, str = x] { + if (!btn->get_active()) return; + switch (str[0]) { // hehe + case 'F': + m_filter_mode = FILTER_FRIENDS; + break; + case 'O': + m_filter_mode = FILTER_ONLINE; + break; + case 'P': + m_filter_mode = FILTER_PENDING; + break; + case 'B': + m_filter_mode = FILTER_BLOCKED; + break; + } + m_list.invalidate_filter(); + }); + } + m_buttons.set_homogeneous(true); + m_buttons.set_halign(Gtk::ALIGN_CENTER); + + m_add.set_halign(Gtk::ALIGN_CENTER); + m_add.set_margin_top(5); + m_add.set_margin_bottom(5); + + m_scroll.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); + + m_list.set_sort_func(sigc::mem_fun(*this, &FriendsList::ListSortFunc)); + m_list.set_filter_func(sigc::mem_fun(*this, &FriendsList::ListFilterFunc)); + m_list.set_selection_mode(Gtk::SELECTION_NONE); + m_list.set_hexpand(true); + m_list.set_vexpand(true); + m_scroll.add(m_list); + add(m_add); + add(m_buttons); + add(m_scroll); + + m_add.show(); + m_scroll.show(); + m_buttons.show(); + m_list.show(); +} + +FriendsListFriendRow *FriendsList::MakeRow(const UserData &user, RelationshipType type) { + auto *row = Gtk::manage(new FriendsListFriendRow(type, user)); + row->signal_action_remove().connect(sigc::bind(sigc::mem_fun(*this, &FriendsList::OnActionRemove), user.ID)); + row->signal_action_accept().connect(sigc::bind(sigc::mem_fun(*this, &FriendsList::OnActionAccept), user.ID)); + return row; +} + +void FriendsList::OnRelationshipAdd(const RelationshipAddData &data) { + for (auto *row_ : m_list.get_children()) { + auto *row = dynamic_cast(row_); + if (row == nullptr || row->ID != data.ID) continue; + delete row; + break; + } + + auto *row = MakeRow(data.User, data.Type); + m_list.add(*row); + row->show(); +} + +void FriendsList::OnRelationshipRemove(Snowflake id, RelationshipType type) { + for (auto *row_ : m_list.get_children()) { + auto *row = dynamic_cast(row_); + if (row == nullptr || row->ID != id) continue; + delete row; + return; + } +} + +void FriendsList::OnActionAccept(Snowflake id) { + const auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg(*dynamic_cast(get_toplevel()), "Failed to accept", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().PutRelationship(id, sigc::track_obj(cb, *this)); +} + +void FriendsList::OnActionRemove(Snowflake id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = discord.GetUser(id); + if (auto *window = dynamic_cast(get_toplevel())) { + Glib::ustring str; + switch (*discord.GetRelationship(id)) { + case RelationshipType::Blocked: + str = "Are you sure you want to unblock " + user->Username + "#" + user->Discriminator + "?"; + break; + case RelationshipType::Friend: + str = "Are you sure you want to remove " + user->Username + "#" + user->Discriminator + "?"; + break; + case RelationshipType::PendingIncoming: + str = "Are you sure you want to ignore " + user->Username + "#" + user->Discriminator + "?"; + break; + case RelationshipType::PendingOutgoing: + str = "Are you sure you want to cancel your request to " + user->Username + "#" + user->Discriminator + "?"; + break; + default: + break; + } + if (Abaddon::Get().ShowConfirm(str, window)) { + const auto cb = [this, window](DiscordError code) { + if (code == DiscordError::NONE) return; + Gtk::MessageDialog dlg(*window, "Failed to remove user", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + }; + discord.RemoveRelationship(id, sigc::track_obj(cb, *this)); + } + } +} + +void FriendsList::PopulateRelationships() { + for (auto child : m_list.get_children()) + delete child; + + auto &discord = Abaddon::Get().GetDiscordClient(); + for (const auto &[id, type] : discord.GetRelationships()) { + const auto user = discord.GetUser(id); + if (!user.has_value()) continue; + auto *row = MakeRow(*user, type); + m_list.add(*row); + row->show(); + } +} + +int FriendsList::ListSortFunc(Gtk::ListBoxRow *a_, Gtk::ListBoxRow *b_) { + auto *a = dynamic_cast(a_); + auto *b = dynamic_cast(b_); + if (a == nullptr || b == nullptr) return 0; + return a->Name.compare(b->Name); +} + +bool FriendsList::ListFilterFunc(Gtk::ListBoxRow *row_) { + auto *row = dynamic_cast(row_); + if (row == nullptr) return false; + switch (m_filter_mode) { + case FILTER_FRIENDS: + return row->Type == RelationshipType::Friend; + case FILTER_ONLINE: + return row->Type == RelationshipType::Friend && row->Status != PresenceStatus::Offline; + case FILTER_PENDING: + return row->Type == RelationshipType::PendingIncoming || row->Type == RelationshipType::PendingOutgoing; + case FILTER_BLOCKED: + return row->Type == RelationshipType::Blocked; + default: + return false; + } +} + +FriendsListAddComponent::FriendsListAddComponent() + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , m_label("Add a Friend", Gtk::ALIGN_START) + , m_status("", Gtk::ALIGN_START) + , m_add("Add") + , m_box(Gtk::ORIENTATION_HORIZONTAL) { + m_box.add(m_entry); + m_box.add(m_add); + m_box.add(m_status); + + m_add.signal_clicked().connect(sigc::mem_fun(*this, &FriendsListAddComponent::Submit)); + + m_label.set_halign(Gtk::ALIGN_CENTER); + + m_entry.set_placeholder_text("Enter a Username#1234"); + m_entry.signal_key_press_event().connect(sigc::mem_fun(*this, &FriendsListAddComponent::OnKeyPress), false); + + add(m_label); + add(m_box); + + show_all_children(); +} + +void FriendsListAddComponent::Submit() { + if (m_requesting) return; + + auto text = m_entry.get_text(); + m_label.set_text("Invalid input"); // cheeky !! + m_entry.set_text(""); + const auto hashpos = text.find("#"); + if (hashpos == Glib::ustring::npos) return; + const auto username = text.substr(0, hashpos); + const auto discriminator = text.substr(hashpos + 1); + if (username.size() == 0 || discriminator.size() != 4) return; + if (discriminator.find_first_not_of("0123456789") != Glib::ustring::npos) return; + + m_requesting = true; + m_label.set_text("Hang on..."); + + const auto cb = [this](DiscordError code) { + m_requesting = false; + if (code == DiscordError::NONE) { + m_label.set_text("Success!"); + } else { + m_label.set_text("Failed: "s + GetDiscordErrorDisplayString(code)); + } + }; + Abaddon::Get().GetDiscordClient().SendFriendRequest(username, std::stoul(discriminator), sigc::track_obj(cb, *this)); +} + +bool FriendsListAddComponent::OnKeyPress(GdkEventKey *e) { + if (e->keyval == GDK_KEY_Return) { + Submit(); + return true; + } + return false; +} + +FriendsListFriendRow::FriendsListFriendRow(RelationshipType type, const UserData &data) + : ID(data.ID) + , Type(type) + , Name(data.Username + "#" + data.Discriminator) + , Status(Abaddon::Get().GetDiscordClient().GetUserStatus(data.ID)) + , m_accept("Accept") { + auto *ev = Gtk::manage(new Gtk::EventBox); + auto *box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + auto *img = Gtk::manage(new LazyImage(32, 32, true)); + auto *namebox = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + auto *namelbl = Gtk::manage(new Gtk::Label("", Gtk::ALIGN_START)); + m_status_lbl = Gtk::manage(new Gtk::Label("", Gtk::ALIGN_START)); + auto *lblbox = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + + auto &discord = Abaddon::Get().GetDiscordClient(); + discord.signal_presence_update().connect(sigc::mem_fun(*this, &FriendsListFriendRow::OnPresenceUpdate)); + + static bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + if (data.HasAnimatedAvatar() && show_animations) { + img->SetAnimated(true); + img->SetURL(data.GetAvatarURL("gif", "32")); + } else { + img->SetURL(data.GetAvatarURL("png", "32")); + } + + namelbl->set_markup(data.GetEscapedBoldName()); + + UpdatePresenceLabel(); + + AddWidgetMenuHandler(ev, m_menu, [this] { + m_accept.set_visible(Type == RelationshipType::PendingIncoming); + + switch (Type) { + case RelationshipType::Blocked: + case RelationshipType::Friend: + m_remove.set_label("Remove"); + break; + case RelationshipType::PendingIncoming: + m_remove.set_label("Ignore"); + break; + case RelationshipType::PendingOutgoing: + m_remove.set_label("Cancel"); + break; + default: + break; + } + }); + + m_remove.signal_activate().connect([this] { + m_signal_remove.emit(); + }); + + m_accept.signal_activate().connect([this] { + m_signal_accept.emit(); + }); + + m_menu.append(m_accept); + m_menu.append(m_remove); + m_menu.show_all(); + + lblbox->set_valign(Gtk::ALIGN_CENTER); + + img->set_margin_end(5); + + namebox->add(*namelbl); + lblbox->add(*namebox); + lblbox->add(*m_status_lbl); + + box->add(*img); + box->add(*lblbox); + + ev->add(*box); + add(*ev); + show_all_children(); +} + +void FriendsListFriendRow::UpdatePresenceLabel() { + switch (Type) { + case RelationshipType::PendingIncoming: + m_status_lbl->set_text("Incoming Friend Request"); + break; + case RelationshipType::PendingOutgoing: + m_status_lbl->set_text("Outgoing Friend Request"); + break; + default: + m_status_lbl->set_text(GetPresenceDisplayString(Status)); + break; + } +} + +void FriendsListFriendRow::OnPresenceUpdate(const UserData &user, PresenceStatus status) { + if (user.ID != ID) return; + Status = status; + UpdatePresenceLabel(); + changed(); +} + +FriendsListFriendRow::type_signal_remove FriendsListFriendRow::signal_action_remove() { + return m_signal_remove; +} + +FriendsListFriendRow::type_signal_accept FriendsListFriendRow::signal_action_accept() { + return m_signal_accept; +} + +FriendsListWindow::FriendsListWindow() { + add(m_friends); + set_default_size(500, 500); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + m_friends.show(); +} diff --git a/src/components/friendslist.hpp b/src/components/friendslist.hpp new file mode 100644 index 0000000..460ad32 --- /dev/null +++ b/src/components/friendslist.hpp @@ -0,0 +1,92 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class FriendsListAddComponent : public Gtk::Box { +public: + FriendsListAddComponent(); + +private: + void Submit(); + bool OnKeyPress(GdkEventKey *e); + + Gtk::Label m_label; + Gtk::Label m_status; + Gtk::Entry m_entry; + Gtk::Button m_add; + Gtk::Box m_box; + + bool m_requesting = false; +}; + +class FriendsListFriendRow; +class FriendsList : public Gtk::Box { +public: + FriendsList(); + +private: + FriendsListFriendRow *MakeRow(const UserData &user, RelationshipType type); + + void OnRelationshipAdd(const RelationshipAddData &data); + void OnRelationshipRemove(Snowflake id, RelationshipType type); + + void OnActionAccept(Snowflake id); + void OnActionRemove(Snowflake id); + + void PopulateRelationships(); + + enum FilterMode { + FILTER_FRIENDS, + FILTER_ONLINE, + FILTER_PENDING, + FILTER_BLOCKED, + }; + + FilterMode m_filter_mode; + + int ListSortFunc(Gtk::ListBoxRow *a, Gtk::ListBoxRow *b); + bool ListFilterFunc(Gtk::ListBoxRow *row); + + FriendsListAddComponent m_add; + Gtk::RadioButtonGroup m_group; + Gtk::ButtonBox m_buttons; + Gtk::ScrolledWindow m_scroll; + Gtk::ListBox m_list; +}; + +class FriendsListFriendRow : public Gtk::ListBoxRow { +public: + FriendsListFriendRow(RelationshipType type, const UserData &str); + + Snowflake ID; + RelationshipType Type; + Glib::ustring Name; + PresenceStatus Status; + +private: + void UpdatePresenceLabel(); + void OnPresenceUpdate(const UserData &user, PresenceStatus status); + + Gtk::Label *m_status_lbl; + + Gtk::Menu m_menu; + Gtk::MenuItem m_remove; // or cancel or ignore + Gtk::MenuItem m_accept; // incoming + + using type_signal_remove = sigc::signal; + using type_signal_accept = sigc::signal; + type_signal_remove m_signal_remove; + type_signal_accept m_signal_accept; + +public: + type_signal_remove signal_action_remove(); + type_signal_accept signal_action_accept(); +}; + +class FriendsListWindow : public Gtk::Window { +public: + FriendsListWindow(); + +private: + FriendsList m_friends; +}; diff --git a/src/components/lazyimage.cpp b/src/components/lazyimage.cpp new file mode 100644 index 0000000..49bbdeb --- /dev/null +++ b/src/components/lazyimage.cpp @@ -0,0 +1,48 @@ +#include "lazyimage.hpp" +#include "abaddon.hpp" + +LazyImage::LazyImage(int w, int h, bool use_placeholder) + : m_width(w) + , m_height(h) { + if (use_placeholder) + property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(w)->scale_simple(w, h, Gdk::INTERP_BILINEAR); + signal_draw().connect(sigc::mem_fun(*this, &LazyImage::OnDraw)); +} + +LazyImage::LazyImage(const std::string &url, int w, int h, bool use_placeholder) + : m_url(url) + , m_width(w) + , m_height(h) { + if (use_placeholder) + property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(w)->scale_simple(w, h, Gdk::INTERP_BILINEAR); + signal_draw().connect(sigc::mem_fun(*this, &LazyImage::OnDraw)); +} + +void LazyImage::SetAnimated(bool is_animated) { + m_animated = is_animated; +} + +void LazyImage::SetURL(const std::string &url) { + m_url = url; +} + +bool LazyImage::OnDraw(const Cairo::RefPtr &context) { + if (!m_needs_request || m_url == "") return false; + m_needs_request = false; + + if (m_animated) { + auto cb = [this](const Glib::RefPtr &pb) { + property_pixbuf_animation() = pb; + }; + + Abaddon::Get().GetImageManager().LoadAnimationFromURL(m_url, m_width, m_height, sigc::track_obj(cb, *this)); + } else { + auto cb = [this](const Glib::RefPtr &pb) { + property_pixbuf() = pb->scale_simple(m_width, m_height, Gdk::INTERP_BILINEAR); + }; + + Abaddon::Get().GetImageManager().LoadFromURL(m_url, sigc::track_obj(cb, *this)); + } + + return false; +} diff --git a/src/components/lazyimage.hpp b/src/components/lazyimage.hpp new file mode 100644 index 0000000..fae69df --- /dev/null +++ b/src/components/lazyimage.hpp @@ -0,0 +1,21 @@ +#pragma once +#include + +// loads an image only when the widget is drawn for the first time +class LazyImage : public Gtk::Image { +public: + LazyImage(int w, int h, bool use_placeholder = true); + LazyImage(const std::string &url, int w, int h, bool use_placeholder = true); + + void SetAnimated(bool is_animated); + void SetURL(const std::string &url); + +private: + bool OnDraw(const Cairo::RefPtr &context); + + bool m_animated = false; + bool m_needs_request = true; + std::string m_url; + int m_width; + int m_height; +}; diff --git a/src/components/memberlist.cpp b/src/components/memberlist.cpp new file mode 100644 index 0000000..0c4d9bc --- /dev/null +++ b/src/components/memberlist.cpp @@ -0,0 +1,228 @@ +#include "memberlist.hpp" +#include "abaddon.hpp" +#include "util.hpp" +#include "lazyimage.hpp" +#include "statusindicator.hpp" + +constexpr static const int MaxMemberListRows = 200; + +MemberListUserRow::MemberListUserRow(const std::optional &guild, const UserData &data) { + ID = data.ID; + m_ev = Gtk::manage(new Gtk::EventBox); + m_box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_HORIZONTAL)); + m_label = Gtk::manage(new Gtk::Label); + m_avatar = Gtk::manage(new LazyImage(16, 16)); + m_status_indicator = Gtk::manage(new StatusIndicator(ID)); + + static bool crown = Abaddon::Get().GetSettings().GetShowOwnerCrown(); + if (crown && guild.has_value() && guild->OwnerID == data.ID) { + try { + const static auto crown_path = Abaddon::GetResPath("/crown.png"); + auto pixbuf = Gdk::Pixbuf::create_from_file(crown_path, 12, 12); + m_crown = Gtk::manage(new Gtk::Image(pixbuf)); + m_crown->set_valign(Gtk::ALIGN_CENTER); + m_crown->set_margin_end(8); + } catch (...) {} + } + + m_status_indicator->set_margin_start(3); + + if (guild.has_value()) + m_avatar->SetURL(data.GetAvatarURL(guild->ID, "png")); + else + m_avatar->SetURL(data.GetAvatarURL("png")); + + get_style_context()->add_class("members-row"); + get_style_context()->add_class("members-row-member"); + m_label->get_style_context()->add_class("members-row-label"); + m_avatar->get_style_context()->add_class("members-row-avatar"); + + m_label->set_single_line_mode(true); + m_label->set_ellipsize(Pango::ELLIPSIZE_END); + + static bool show_discriminator = Abaddon::Get().GetSettings().GetShowMemberListDiscriminators(); + std::string display = data.Username; + if (show_discriminator) + display += "#" + data.Discriminator; + if (guild.has_value()) { + if (const auto col_id = data.GetHoistedRole(guild->ID, true); col_id.IsValid()) { + auto color = Abaddon::Get().GetDiscordClient().GetRole(col_id)->Color; + m_label->set_use_markup(true); + m_label->set_markup("" + Glib::Markup::escape_text(display) + ""); + } else { + m_label->set_text(display); + } + } else { + m_label->set_text(display); + } + + m_label->set_halign(Gtk::ALIGN_START); + m_box->add(*m_avatar); + m_box->add(*m_status_indicator); + m_box->add(*m_label); + if (m_crown != nullptr) + m_box->add(*m_crown); + m_ev->add(*m_box); + add(*m_ev); + show_all(); +} + +MemberList::MemberList() { + m_main = Gtk::manage(new Gtk::ScrolledWindow); + m_listbox = Gtk::manage(new Gtk::ListBox); + + m_listbox->get_style_context()->add_class("members"); + + m_listbox->set_selection_mode(Gtk::SELECTION_NONE); + + m_main->set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + m_main->add(*m_listbox); + m_main->show_all(); +} + +Gtk::Widget *MemberList::GetRoot() const { + return m_main; +} + +void MemberList::Clear() { + SetActiveChannel(Snowflake::Invalid); + UpdateMemberList(); +} + +void MemberList::SetActiveChannel(Snowflake id) { + m_chan_id = id; + m_guild_id = Snowflake::Invalid; + if (m_chan_id.IsValid()) { + const auto chan = Abaddon::Get().GetDiscordClient().GetChannel(id); + if (chan.has_value() && chan->GuildID.has_value()) m_guild_id = *chan->GuildID; + } +} + +void MemberList::UpdateMemberList() { + m_id_to_row.clear(); + + auto children = m_listbox->get_children(); + auto it = children.begin(); + while (it != children.end()) { + delete *it; + it++; + } + + if (!Abaddon::Get().GetDiscordClient().IsStarted()) return; + if (!m_chan_id.IsValid()) return; + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto chan = discord.GetChannel(m_chan_id); + if (!chan.has_value()) return; + if (chan->Type == ChannelType::DM || chan->Type == ChannelType::GROUP_DM) { + int num_rows = 0; + for (const auto &user : chan->GetDMRecipients()) { + if (num_rows++ > MaxMemberListRows) break; + auto *row = Gtk::manage(new MemberListUserRow(std::nullopt, user)); + m_id_to_row[user.ID] = row; + AttachUserMenuHandler(row, user.ID); + m_listbox->add(*row); + } + + return; + } + + std::set ids; + if (chan->IsThread()) { + const auto x = discord.GetUsersInThread(m_chan_id); + ids = { x.begin(), x.end() }; + } else + ids = discord.GetUsersInGuild(m_guild_id); + + // process all the shit first so its in proper order + std::map pos_to_role; + std::map> pos_to_users; + std::unordered_map user_to_color; + std::vector roleless_users; + + for (const auto &id : ids) { + auto user = discord.GetUser(id); + if (!user.has_value() || user->IsDeleted()) + continue; + + auto pos_role_id = discord.GetMemberHoistedRole(m_guild_id, id); // role for positioning + auto col_role_id = discord.GetMemberHoistedRole(m_guild_id, id, true); // role for color + auto pos_role = discord.GetRole(pos_role_id); + auto col_role = discord.GetRole(col_role_id); + + if (!pos_role.has_value()) { + roleless_users.push_back(id); + continue; + }; + + pos_to_role[pos_role->Position] = *pos_role; + pos_to_users[pos_role->Position].push_back(std::move(*user)); + if (col_role.has_value()) + user_to_color[id] = col_role->Color; + } + + int num_rows = 0; + const auto guild = *discord.GetGuild(m_guild_id); + auto add_user = [this, &user_to_color, &num_rows, guild](const UserData &data) -> bool { + if (num_rows++ > MaxMemberListRows) return false; + auto *row = Gtk::manage(new MemberListUserRow(guild, data)); + m_id_to_row[data.ID] = row; + AttachUserMenuHandler(row, data.ID); + m_listbox->add(*row); + return true; + }; + + auto add_role = [this](std::string name) { + auto *role_row = Gtk::manage(new Gtk::ListBoxRow); + auto *role_lbl = Gtk::manage(new Gtk::Label); + + role_row->get_style_context()->add_class("members-row"); + role_row->get_style_context()->add_class("members-row-role"); + role_lbl->get_style_context()->add_class("members-row-label"); + + role_lbl->set_single_line_mode(true); + role_lbl->set_ellipsize(Pango::ELLIPSIZE_END); + role_lbl->set_use_markup(true); + role_lbl->set_markup("" + Glib::Markup::escape_text(name) + ""); + role_lbl->set_halign(Gtk::ALIGN_START); + role_row->add(*role_lbl); + role_row->show_all(); + m_listbox->add(*role_row); + }; + + for (auto it = pos_to_role.crbegin(); it != pos_to_role.crend(); it++) { + auto pos = it->first; + const auto &role = it->second; + + add_role(role.Name); + + if (pos_to_users.find(pos) == pos_to_users.end()) continue; + + auto &users = pos_to_users.at(pos); + AlphabeticalSort(users.begin(), users.end(), [](const auto &e) { return e.Username; }); + + for (const auto &data : users) + if (!add_user(data)) return; + } + + if (chan->Type == ChannelType::DM || chan->Type == ChannelType::GROUP_DM) + add_role("Users"); + else + add_role("@everyone"); + for (const auto &id : roleless_users) { + const auto user = discord.GetUser(id); + if (user.has_value()) + if (!add_user(*user)) return; + } +} + +void MemberList::AttachUserMenuHandler(Gtk::ListBoxRow *row, Snowflake id) { + row->signal_button_press_event().connect([this, row, id](GdkEventButton *e) -> bool { + if (e->type == GDK_BUTTON_PRESS && e->button == GDK_BUTTON_SECONDARY) { + Abaddon::Get().ShowUserMenu(reinterpret_cast(e), id, m_guild_id); + return true; + } + + return false; + }); +} diff --git a/src/components/memberlist.hpp b/src/components/memberlist.hpp new file mode 100644 index 0000000..60a25bc --- /dev/null +++ b/src/components/memberlist.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include +#include +#include "discord/discord.hpp" + +class LazyImage; +class StatusIndicator; +class MemberListUserRow : public Gtk::ListBoxRow { +public: + MemberListUserRow(const std::optional &guild, const UserData &data); + + Snowflake ID; + +private: + Gtk::EventBox *m_ev; + Gtk::Box *m_box; + LazyImage *m_avatar; + StatusIndicator *m_status_indicator; + Gtk::Label *m_label; + Gtk::Image *m_crown = nullptr; +}; + +class MemberList { +public: + MemberList(); + Gtk::Widget *GetRoot() const; + + void UpdateMemberList(); + void Clear(); + void SetActiveChannel(Snowflake id); + +private: + void AttachUserMenuHandler(Gtk::ListBoxRow *row, Snowflake id); + + Gtk::ScrolledWindow *m_main; + Gtk::ListBox *m_listbox; + + Snowflake m_guild_id; + Snowflake m_chan_id; + + std::unordered_map m_id_to_row; +}; diff --git a/src/components/ratelimitindicator.cpp b/src/components/ratelimitindicator.cpp new file mode 100644 index 0000000..ac4ef4b --- /dev/null +++ b/src/components/ratelimitindicator.cpp @@ -0,0 +1,137 @@ +#include "ratelimitindicator.hpp" +#include "abaddon.hpp" +#include + +RateLimitIndicator::RateLimitIndicator() + : Gtk::Box(Gtk::ORIENTATION_HORIZONTAL) { + m_label.set_text(""); + m_label.set_ellipsize(Pango::ELLIPSIZE_START); + m_label.set_valign(Gtk::ALIGN_END); + get_style_context()->add_class("ratelimit-indicator"); + + m_img.set_margin_start(7); + + add(m_label); + add(m_img); + m_label.show(); + + const static auto clock_path = Abaddon::GetResPath("/clock.png"); + if (std::filesystem::exists(clock_path)) { + try { + const auto pixbuf = Gdk::Pixbuf::create_from_file(clock_path); + int w, h; + GetImageDimensions(pixbuf->get_width(), pixbuf->get_height(), w, h, 20, 10); + m_img.property_pixbuf() = pixbuf->scale_simple(w, h, Gdk::INTERP_BILINEAR); + } catch (...) {} + } + + Abaddon::Get().GetDiscordClient().signal_message_create().connect(sigc::mem_fun(*this, &RateLimitIndicator::OnMessageCreate)); + Abaddon::Get().GetDiscordClient().signal_message_send_fail().connect(sigc::mem_fun(*this, &RateLimitIndicator::OnMessageSendFail)); + Abaddon::Get().GetDiscordClient().signal_channel_update().connect(sigc::mem_fun(*this, &RateLimitIndicator::OnChannelUpdate)); +} + +void RateLimitIndicator::SetActiveChannel(Snowflake id) { + m_active_channel = id; + const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(m_active_channel); + if (channel.has_value() && channel->RateLimitPerUser.has_value()) + m_rate_limit = *channel->RateLimitPerUser; + else + m_rate_limit = 0; + + UpdateIndicator(); +} + +bool RateLimitIndicator::CanSpeak() const { + const auto rate_limit = GetRateLimit(); + if (rate_limit == 0) return true; + + const auto it = m_times.find(m_active_channel); + if (it == m_times.end()) + return true; + + const auto now = std::chrono::steady_clock::now(); + const auto sec_diff = std::chrono::duration_cast(it->second - now).count(); + return sec_diff <= 0; +} + +int RateLimitIndicator::GetTimeLeft() const { + if (CanSpeak()) return 0; + + auto it = m_times.find(m_active_channel); + if (it == m_times.end()) return 0; + + const auto now = std::chrono::steady_clock::now(); + const auto sec_diff = std::chrono::duration_cast(it->second - now).count(); + + if (sec_diff <= 0) + return 0; + else + return sec_diff; +} + +int RateLimitIndicator::GetRateLimit() const { + return m_rate_limit; +} + +bool RateLimitIndicator::UpdateIndicator() { + if (const auto rate_limit = GetRateLimit(); rate_limit != 0) { + m_img.show(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + if (discord.HasAnyChannelPermission(discord.GetUserData().ID, m_active_channel, Permission::MANAGE_MESSAGES | Permission::MANAGE_CHANNELS)) { + m_label.set_text("You may bypass slowmode."); + set_has_tooltip(false); + } else { + const auto time_left = GetTimeLeft(); + if (time_left > 0) + m_label.set_text(std::to_string(time_left) + "s"); + else + m_label.set_text(""); + set_tooltip_text("Slowmode is enabled. Members can send one message every " + std::to_string(rate_limit) + " seconds."); + } + } else { + m_img.hide(); + + m_label.set_text(""); + set_has_tooltip(false); + } + + if (m_connection) + m_connection.disconnect(); + m_connection = Glib::signal_timeout().connect_seconds(sigc::mem_fun(*this, &RateLimitIndicator::UpdateIndicator), 1); + + return false; +} + +void RateLimitIndicator::OnMessageCreate(const Message &message) { + auto &discord = Abaddon::Get().GetDiscordClient(); + if (message.Author.ID != discord.GetUserData().ID) return; + if (!message.GuildID.has_value()) return; + const bool can_bypass = discord.HasAnyChannelPermission(discord.GetUserData().ID, m_active_channel, Permission::MANAGE_MESSAGES | Permission::MANAGE_CHANNELS); + const auto rate_limit = GetRateLimit(); + if (rate_limit > 0 && !can_bypass) { + m_times[message.ChannelID] = std::chrono::steady_clock::now() + std::chrono::duration(rate_limit + 1); + UpdateIndicator(); + } +} + +void RateLimitIndicator::OnMessageSendFail(const std::string &nonce, float retry_after) { + if (retry_after != 0) { // failed to rate limit + const auto msg = Abaddon::Get().GetDiscordClient().GetMessage(nonce); + const auto channel_id = msg->ChannelID; + m_times[channel_id] = std::chrono::steady_clock::now() + std::chrono::duration(std::lroundf(retry_after + 0.5f) + 1); // + 0.5 will ceil it + UpdateIndicator(); + } +} + +void RateLimitIndicator::OnChannelUpdate(Snowflake channel_id) { + if (channel_id != m_active_channel) return; + const auto chan = Abaddon::Get().GetDiscordClient().GetChannel(m_active_channel); + if (!chan.has_value()) return; + const auto r = chan->RateLimitPerUser; + if (r.has_value()) + m_rate_limit = *r; + else + m_rate_limit = 0; + UpdateIndicator(); +} diff --git a/src/components/ratelimitindicator.hpp b/src/components/ratelimitindicator.hpp new file mode 100644 index 0000000..b4dbb69 --- /dev/null +++ b/src/components/ratelimitindicator.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include +#include "discord/message.hpp" + +class RateLimitIndicator : public Gtk::Box { +public: + RateLimitIndicator(); + void SetActiveChannel(Snowflake id); + + // even tho this probably isnt the right place for this im gonna do it anyway to reduce coad + bool CanSpeak() const; + +private: + int GetTimeLeft() const; + int GetRateLimit() const; + bool UpdateIndicator(); + void OnMessageCreate(const Message &message); + void OnMessageSendFail(const std::string &nonce, float rate_limit); + void OnChannelUpdate(Snowflake channel_id); + + Gtk::Image m_img; + Gtk::Label m_label; + + sigc::connection m_connection; + + int m_rate_limit; + Snowflake m_active_channel; + std::unordered_map> m_times; // time point of when next message can be sent +}; diff --git a/src/components/statusindicator.cpp b/src/components/statusindicator.cpp new file mode 100644 index 0000000..42eb170 --- /dev/null +++ b/src/components/statusindicator.cpp @@ -0,0 +1,130 @@ +#include "statusindicator.hpp" +#include "abaddon.hpp" + +static const constexpr int Diameter = 8; +static const auto OnlineColor = Gdk::RGBA("#43B581"); +static const auto IdleColor = Gdk::RGBA("#FAA61A"); +static const auto DNDColor = Gdk::RGBA("#982929"); +static const auto OfflineColor = Gdk::RGBA("#808080"); + +StatusIndicator::StatusIndicator(Snowflake user_id) + : Glib::ObjectBase("statusindicator") + , Gtk::Widget() + , m_id(user_id) + , m_status(static_cast(-1)) { + set_has_window(true); + set_name("status-indicator"); + + get_style_context()->add_class("status-indicator"); + + Abaddon::Get().GetDiscordClient().signal_guild_member_list_update().connect(sigc::hide(sigc::mem_fun(*this, &StatusIndicator::CheckStatus))); + auto cb = [this](const UserData &user, PresenceStatus status) { + if (user.ID == m_id) CheckStatus(); + }; + Abaddon::Get().GetDiscordClient().signal_presence_update().connect(sigc::track_obj(cb, *this)); + + CheckStatus(); +} + +StatusIndicator::~StatusIndicator() { +} + +void StatusIndicator::CheckStatus() { + const auto status = Abaddon::Get().GetDiscordClient().GetUserStatus(m_id); + const auto last_status = m_status; + get_style_context()->remove_class("online"); + get_style_context()->remove_class("dnd"); + get_style_context()->remove_class("idle"); + get_style_context()->remove_class("offline"); + get_style_context()->add_class(GetPresenceString(status)); + m_status = status; + + if (last_status != m_status) + queue_draw(); +} + +Gtk::SizeRequestMode StatusIndicator::get_request_mode_vfunc() const { + return Gtk::Widget::get_request_mode_vfunc(); +} + +void StatusIndicator::get_preferred_width_vfunc(int &minimum_width, int &natural_width) const { + minimum_width = 0; + natural_width = Diameter; +} + +void StatusIndicator::get_preferred_height_for_width_vfunc(int width, int &minimum_height, int &natural_height) const { + minimum_height = 0; + natural_height = Diameter; +} + +void StatusIndicator::get_preferred_height_vfunc(int &minimum_height, int &natural_height) const { + minimum_height = 0; + natural_height = Diameter; +} + +void StatusIndicator::get_preferred_width_for_height_vfunc(int height, int &minimum_width, int &natural_width) const { + minimum_width = 0; + natural_width = Diameter; +} + +void StatusIndicator::on_size_allocate(Gtk::Allocation &allocation) { + set_allocation(allocation); + + if (m_window) + m_window->move_resize(allocation.get_x(), allocation.get_y(), allocation.get_width(), allocation.get_height()); +} + +void StatusIndicator::on_map() { + Gtk::Widget::on_map(); +} + +void StatusIndicator::on_unmap() { + Gtk::Widget::on_unmap(); +} + +void StatusIndicator::on_realize() { + set_realized(true); + + if (!m_window) { + GdkWindowAttr attributes; + std::memset(&attributes, 0, sizeof(attributes)); + + auto allocation = get_allocation(); + + attributes.x = allocation.get_x(); + attributes.y = allocation.get_y(); + attributes.width = allocation.get_width(); + attributes.height = allocation.get_height(); + + attributes.event_mask = get_events() | Gdk::EXPOSURE_MASK; + attributes.window_type = GDK_WINDOW_CHILD; + attributes.wclass = GDK_INPUT_OUTPUT; + + m_window = Gdk::Window::create(get_parent_window(), &attributes, GDK_WA_X | GDK_WA_Y); + set_window(m_window); + + m_window->set_user_data(gobj()); + } +} + +void StatusIndicator::on_unrealize() { + m_window.reset(); + + Gtk::Widget::on_unrealize(); +} + +bool StatusIndicator::on_draw(const Cairo::RefPtr &cr) { + const auto allocation = get_allocation(); + const auto width = allocation.get_width(); + const auto height = allocation.get_height(); + + const auto color = get_style_context()->get_color(Gtk::STATE_FLAG_NORMAL); + + cr->set_source_rgb(color.get_red(), color.get_green(), color.get_blue()); + cr->arc(width / 2, height / 2, width / 3, 0.0, 2 * (4 * std::atan(1))); + cr->close_path(); + cr->fill_preserve(); + cr->stroke(); + + return true; +} diff --git a/src/components/statusindicator.hpp b/src/components/statusindicator.hpp new file mode 100644 index 0000000..b2cf0bd --- /dev/null +++ b/src/components/statusindicator.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include "discord/snowflake.hpp" +#include "discord/activity.hpp" + +class StatusIndicator : public Gtk::Widget { +public: + StatusIndicator(Snowflake user_id); + virtual ~StatusIndicator(); + +protected: + Gtk::SizeRequestMode get_request_mode_vfunc() const override; + void get_preferred_width_vfunc(int &minimum_width, int &natural_width) const override; + void get_preferred_height_for_width_vfunc(int width, int &minimum_height, int &natural_height) const override; + void get_preferred_height_vfunc(int &minimum_height, int &natural_height) const override; + void get_preferred_width_for_height_vfunc(int height, int &minimum_width, int &natural_width) const override; + void on_size_allocate(Gtk::Allocation &allocation) override; + void on_map() override; + void on_unmap() override; + void on_realize() override; + void on_unrealize() override; + bool on_draw(const Cairo::RefPtr &cr) override; + + Glib::RefPtr m_window; + + void CheckStatus(); + + Snowflake m_id; + PresenceStatus m_status; +}; diff --git a/src/config.h.in b/src/config.h.in new file mode 100644 index 0000000..ab6583d --- /dev/null +++ b/src/config.h.in @@ -0,0 +1 @@ +#define ABADDON_DEFAULT_RESOURCE_DIR "@ABADDON_RESOURCE_DIR@" diff --git a/src/constants.hpp b/src/constants.hpp new file mode 100644 index 0000000..6c6276f --- /dev/null +++ b/src/constants.hpp @@ -0,0 +1,4 @@ +#include + +constexpr static uint64_t SnowflakeSplitDifference = 600; +constexpr static int MaxMessagesForChatCull = 50; // this has to be 50 (for now) cuz that magic number is used in a couple other places and i dont feel like replacing them diff --git a/src/dialogs/confirm.cpp b/src/dialogs/confirm.cpp new file mode 100644 index 0000000..39d8971 --- /dev/null +++ b/src/dialogs/confirm.cpp @@ -0,0 +1,36 @@ +#include "confirm.hpp" + +ConfirmDialog::ConfirmDialog(Gtk::Window &parent) + : Gtk::Dialog("Confirm", parent, true) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_ok("OK") + , m_cancel("Cancel") + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 50); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_label.set_text("Are you sure?"); + + m_ok.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_OK); + }); + + m_cancel.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_CANCEL); + }); + + m_bbox.pack_start(m_ok, Gtk::PACK_SHRINK); + m_bbox.pack_start(m_cancel, Gtk::PACK_SHRINK); + m_bbox.set_layout(Gtk::BUTTONBOX_END); + + m_layout.add(m_label); + m_layout.add(m_bbox); + get_content_area()->add(m_layout); + + show_all_children(); +} + +void ConfirmDialog::SetConfirmText(const Glib::ustring &text) { + m_label.set_text(text); +} diff --git a/src/dialogs/confirm.hpp b/src/dialogs/confirm.hpp new file mode 100644 index 0000000..df1e185 --- /dev/null +++ b/src/dialogs/confirm.hpp @@ -0,0 +1,15 @@ +#pragma once +#include + +class ConfirmDialog : public Gtk::Dialog { +public: + ConfirmDialog(Gtk::Window &parent); + void SetConfirmText(const Glib::ustring &text); + +protected: + Gtk::Label m_label; + Gtk::Box m_layout; + Gtk::Button m_ok; + Gtk::Button m_cancel; + Gtk::ButtonBox m_bbox; +}; diff --git a/src/dialogs/editmessage.cpp b/src/dialogs/editmessage.cpp new file mode 100644 index 0000000..b4308a0 --- /dev/null +++ b/src/dialogs/editmessage.cpp @@ -0,0 +1,45 @@ +#include "editmessage.hpp" + +EditMessageDialog::EditMessageDialog(Gtk::Window &parent) + : Gtk::Dialog("Edit Message", parent, true) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_ok("OK") + , m_cancel("Cancel") + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 50); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_ok.signal_clicked().connect([&]() { + m_content = m_text.get_buffer()->get_text(); + response(Gtk::RESPONSE_OK); + }); + + m_cancel.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_CANCEL); + }); + + m_bbox.pack_start(m_ok, Gtk::PACK_SHRINK); + m_bbox.pack_start(m_cancel, Gtk::PACK_SHRINK); + m_bbox.set_layout(Gtk::BUTTONBOX_END); + + m_text.set_hexpand(true); + + m_scroll.set_hexpand(true); + m_scroll.set_vexpand(true); + m_scroll.add(m_text); + + m_layout.add(m_scroll); + m_layout.add(m_bbox); + get_content_area()->add(m_layout); + + show_all_children(); +} + +Glib::ustring EditMessageDialog::GetContent() { + return m_content; +} + +void EditMessageDialog::SetContent(const Glib::ustring &str) { + m_text.get_buffer()->set_text(str); +} diff --git a/src/dialogs/editmessage.hpp b/src/dialogs/editmessage.hpp new file mode 100644 index 0000000..bf6307d --- /dev/null +++ b/src/dialogs/editmessage.hpp @@ -0,0 +1,21 @@ +#pragma once +#include +#include + +class EditMessageDialog : public Gtk::Dialog { +public: + EditMessageDialog(Gtk::Window &parent); + Glib::ustring GetContent(); + void SetContent(const Glib::ustring &str); + +protected: + Gtk::Box m_layout; + Gtk::Button m_ok; + Gtk::Button m_cancel; + Gtk::ButtonBox m_bbox; + Gtk::ScrolledWindow m_scroll; + Gtk::TextView m_text; + +private: + Glib::ustring m_content; +}; diff --git a/src/dialogs/friendpicker.cpp b/src/dialogs/friendpicker.cpp new file mode 100644 index 0000000..fc099aa --- /dev/null +++ b/src/dialogs/friendpicker.cpp @@ -0,0 +1,93 @@ +#include "friendpicker.hpp" +#include "abaddon.hpp" + +FriendPickerDialog::FriendPickerDialog(Gtk::Window &parent) + : Gtk::Dialog("Pick a friend", parent, true) + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 300); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_ok_button = add_button("OK", Gtk::RESPONSE_OK); + m_cancel_button = add_button("Cancel", Gtk::RESPONSE_CANCEL); + + m_ok_button->set_sensitive(false); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto relationships = discord.GetRelationships(RelationshipType::Friend); + for (auto id : relationships) { + auto *item = Gtk::manage(new FriendPickerDialogItem(id)); + item->show(); + m_list.add(*item); + } + + m_list.signal_row_activated().connect(sigc::mem_fun(*this, &FriendPickerDialog::OnRowActivated)); + m_list.signal_selected_rows_changed().connect(sigc::mem_fun(*this, &FriendPickerDialog::OnSelectionChange)); + m_list.set_activate_on_single_click(false); + m_list.set_selection_mode(Gtk::SELECTION_SINGLE); + + m_main.set_propagate_natural_height(true); + + m_main.add(m_list); + + get_content_area()->add(m_main); + + show_all_children(); +} + +Snowflake FriendPickerDialog::GetUserID() const { + return m_chosen_id; +} + +void FriendPickerDialog::OnRowActivated(Gtk::ListBoxRow *row) { + auto *x = dynamic_cast(row); + if (x != nullptr) { + m_chosen_id = x->ID; + response(Gtk::RESPONSE_OK); + } +} + +void FriendPickerDialog::OnSelectionChange() { + auto selection = m_list.get_selected_row(); + m_ok_button->set_sensitive(false); + if (selection != nullptr) { + auto *row = dynamic_cast(selection); + if (!row) return; + m_chosen_id = row->ID; + m_ok_button->set_sensitive(true); + } +} + +FriendPickerDialogItem::FriendPickerDialogItem(Snowflake user_id) + : ID(user_id) + , m_layout(Gtk::ORIENTATION_HORIZONTAL) { + auto user = *Abaddon::Get().GetDiscordClient().GetUser(user_id); + + m_name.set_markup(user.GetEscapedBoldString()); + m_name.set_single_line_mode(true); + + m_avatar.property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(32); + if (user.HasAnimatedAvatar() && Abaddon::Get().GetSettings().GetShowAnimations()) { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf_animation() = pb; + }; + Abaddon::Get().GetImageManager().LoadAnimationFromURL(user.GetAvatarURL("gif", "32"), 32, 32, sigc::track_obj(cb, *this)); + } else { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf() = pb->scale_simple(32, 32, Gdk::INTERP_BILINEAR); + }; + Abaddon::Get().GetImageManager().LoadFromURL(user.GetAvatarURL("png", "32"), sigc::track_obj(cb, *this)); + } + + m_avatar.set_margin_end(5); + m_avatar.set_halign(Gtk::ALIGN_START); + m_avatar.set_valign(Gtk::ALIGN_CENTER); + m_name.set_halign(Gtk::ALIGN_START); + m_name.set_valign(Gtk::ALIGN_CENTER); + + m_layout.add(m_avatar); + m_layout.add(m_name); + add(m_layout); + + show_all_children(); +} diff --git a/src/dialogs/friendpicker.hpp b/src/dialogs/friendpicker.hpp new file mode 100644 index 0000000..81d02a3 --- /dev/null +++ b/src/dialogs/friendpicker.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include "discord/snowflake.hpp" + +class FriendPickerDialog : public Gtk::Dialog { +public: + FriendPickerDialog(Gtk::Window &parent); + + Snowflake GetUserID() const; + +protected: + void OnRowActivated(Gtk::ListBoxRow *row); + void OnSelectionChange(); + + Snowflake m_chosen_id; + + Gtk::ScrolledWindow m_main; + Gtk::ListBox m_list; + Gtk::ButtonBox m_bbox; + + Gtk::Button *m_ok_button; + Gtk::Button *m_cancel_button; +}; + +class FriendPickerDialogItem : public Gtk::ListBoxRow { +public: + FriendPickerDialogItem(Snowflake user_id); + + Snowflake ID; + +private: + Gtk::Box m_layout; + Gtk::Image m_avatar; + Gtk::Label m_name; +}; diff --git a/src/dialogs/joinguild.cpp b/src/dialogs/joinguild.cpp new file mode 100644 index 0000000..14fab53 --- /dev/null +++ b/src/dialogs/joinguild.cpp @@ -0,0 +1,97 @@ +#include "joinguild.hpp" +#include "abaddon.hpp" +#include +#include + +JoinGuildDialog::JoinGuildDialog(Gtk::Window &parent) + : Gtk::Dialog("Join Server", parent, true) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_ok("OK") + , m_cancel("Cancel") + , m_info("Enter code") { + set_default_size(300, 50); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + Glib::signal_idle().connect(sigc::mem_fun(*this, &JoinGuildDialog::on_idle_slot)); + + m_entry.signal_changed().connect(sigc::mem_fun(*this, &JoinGuildDialog::on_entry_changed)); + + m_ok.set_sensitive(false); + + m_ok.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_OK); + }); + + m_cancel.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_CANCEL); + }); + + m_entry.set_hexpand(true); + m_layout.add(m_entry); + m_lower.set_hexpand(true); + m_lower.pack_start(m_info); + m_info.set_halign(Gtk::ALIGN_START); + m_lower.pack_start(m_ok, Gtk::PACK_SHRINK); + m_lower.pack_start(m_cancel, Gtk::PACK_SHRINK); + m_ok.set_halign(Gtk::ALIGN_END); + m_cancel.set_halign(Gtk::ALIGN_END); + m_layout.add(m_lower); + get_content_area()->add(m_layout); + + show_all_children(); +} + +void JoinGuildDialog::on_entry_changed() { + std::string s = m_entry.get_text(); + std::regex invite_regex(R"((https?:\/\/)?discord\.(gg(\/invite)?\/|com\/invite\/)([A-Za-z0-9\-]+))", std::regex_constants::ECMAScript); + std::smatch match; + bool full_url = std::regex_search(s, match, invite_regex); + if (full_url || IsCode(s)) { + m_code = full_url ? match[4].str() : s; + m_needs_request = true; + m_ok.set_sensitive(false); + } else { + m_ok.set_sensitive(false); + } +} + +void JoinGuildDialog::CheckCode() { + auto cb = [this](const std::optional &invite) { + if (invite.has_value()) { + m_ok.set_sensitive(true); + if (invite->Guild.has_value()) { + if (invite->MemberCount.has_value()) + m_info.set_text(invite->Guild->Name + " (" + std::to_string(*invite->MemberCount) + " members)"); + else + m_info.set_text(invite->Guild->Name); + } else { + m_info.set_text("Group DM (" + std::to_string(*invite->MemberCount) + " members)"); + } + } else { + m_ok.set_sensitive(false); + m_info.set_text("Invalid invite"); + } + }; + Abaddon::Get().GetDiscordClient().FetchInvite(m_code, sigc::track_obj(cb, *this)); +} + +bool JoinGuildDialog::IsCode(std::string str) { + return str.length() >= 2 && std::all_of(str.begin(), str.end(), [](char c) -> bool { return std::isalnum(c) || c == '-'; }); +} + +std::string JoinGuildDialog::GetCode() { + return m_code; +} + +static const constexpr int RateLimitMS = 1500; +bool JoinGuildDialog::on_idle_slot() { + const auto now = std::chrono::steady_clock::now(); + if (m_needs_request && ((now - m_last_req_time) > std::chrono::milliseconds(RateLimitMS))) { + m_needs_request = false; + m_last_req_time = now; + CheckCode(); + } + + return true; +} diff --git a/src/dialogs/joinguild.hpp b/src/dialogs/joinguild.hpp new file mode 100644 index 0000000..109d010 --- /dev/null +++ b/src/dialogs/joinguild.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +class JoinGuildDialog : public Gtk::Dialog { +public: + JoinGuildDialog(Gtk::Window &parent); + std::string GetCode(); + +protected: + void on_entry_changed(); + bool IsCode(std::string str); + + Gtk::Box m_layout; + Gtk::Button m_ok; + Gtk::Button m_cancel; + Gtk::Box m_lower; + Gtk::Label m_info; + Gtk::Entry m_entry; + + void CheckCode(); + + // needs a rate limit cuz if u hit it u get ip banned from /invites for a long time :( + bool m_needs_request = false; + std::chrono::time_point m_last_req_time; + bool on_idle_slot(); + +private: + std::string m_code; +}; diff --git a/src/dialogs/setstatus.cpp b/src/dialogs/setstatus.cpp new file mode 100644 index 0000000..7a3a038 --- /dev/null +++ b/src/dialogs/setstatus.cpp @@ -0,0 +1,72 @@ +#include "setstatus.hpp" + +SetStatusDialog::SetStatusDialog(Gtk::Window &parent) + : Gtk::Dialog("Set Status", parent, true) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_bottom(Gtk::ORIENTATION_HORIZONTAL) + , m_ok("OK") + , m_cancel("Cancel") + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 50); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_text.set_placeholder_text("Status text"); + + m_status_combo.append("online", "Online"); + m_status_combo.append("dnd", "Do Not Disturb"); + m_status_combo.append("idle", "Away"); + m_status_combo.append("invisible", "Invisible"); + m_status_combo.set_active_text("Online"); + + m_type_combo.append("0", "Playing"); + m_type_combo.append("1", "Streaming"); + m_type_combo.append("2", "Listening to"); + m_type_combo.append("3", "Watching"); + m_type_combo.append("4", "Custom"); + m_type_combo.append("5", "Competing in"); + m_type_combo.set_active_text("Custom"); + + m_ok.signal_clicked().connect([this]() { + response(Gtk::RESPONSE_OK); + }); + + m_cancel.signal_clicked().connect([this]() { + response(Gtk::RESPONSE_CANCEL); + }); + + m_bbox.pack_start(m_ok, Gtk::PACK_SHRINK); + m_bbox.pack_start(m_cancel, Gtk::PACK_SHRINK); + m_bbox.set_layout(Gtk::BUTTONBOX_END); + + m_bottom.add(m_status_combo); + m_bottom.add(m_type_combo); + m_bottom.add(m_bbox); + m_layout.add(m_text); + m_layout.add(m_bottom); + get_content_area()->add(m_layout); + + show_all_children(); +} + +ActivityType SetStatusDialog::GetActivityType() const { + const auto x = m_type_combo.get_active_id(); + return static_cast(std::stoul(x)); +} + +PresenceStatus SetStatusDialog::GetStatusType() const { + const auto &x = m_status_combo.get_active_id(); + if (x == "online") + return PresenceStatus::Online; + else if (x == "idle") + return PresenceStatus::Idle; + else if (x == "dnd") + return PresenceStatus::DND; + else if (x == "invisible") + return PresenceStatus::Offline; + return PresenceStatus::Online; +} + +std::string SetStatusDialog::GetActivityName() const { + return m_text.get_text(); +} diff --git a/src/dialogs/setstatus.hpp b/src/dialogs/setstatus.hpp new file mode 100644 index 0000000..b06c182 --- /dev/null +++ b/src/dialogs/setstatus.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class SetStatusDialog : public Gtk::Dialog { +public: + SetStatusDialog(Gtk::Window &parent); + ActivityType GetActivityType() const; + PresenceStatus GetStatusType() const; + std::string GetActivityName() const; + +protected: + Gtk::Box m_layout; + Gtk::Box m_bottom; + Gtk::Entry m_text; + Gtk::ComboBoxText m_status_combo; + Gtk::ComboBoxText m_type_combo; + + Gtk::Button m_ok; + Gtk::Button m_cancel; + Gtk::ButtonBox m_bbox; +}; diff --git a/src/dialogs/token.cpp b/src/dialogs/token.cpp new file mode 100644 index 0000000..f984990 --- /dev/null +++ b/src/dialogs/token.cpp @@ -0,0 +1,43 @@ +#include "token.hpp" + +std::string trim(const std::string& str) { + const auto first = str.find_first_not_of(' '); + if (first == std::string::npos) return str; + const auto last = str.find_last_not_of(' '); + return str.substr(first, last - first + 1); +} + +TokenDialog::TokenDialog(Gtk::Window &parent) + : Gtk::Dialog("Set Token", parent, true) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_ok("OK") + , m_cancel("Cancel") + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 50); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_ok.signal_clicked().connect([&]() { + m_token = trim(m_entry.get_text()); + response(Gtk::RESPONSE_OK); + }); + + m_cancel.signal_clicked().connect([&]() { + response(Gtk::RESPONSE_CANCEL); + }); + + m_bbox.pack_start(m_ok, Gtk::PACK_SHRINK); + m_bbox.pack_start(m_cancel, Gtk::PACK_SHRINK); + m_bbox.set_layout(Gtk::BUTTONBOX_END); + + m_entry.set_hexpand(true); + m_layout.add(m_entry); + m_layout.add(m_bbox); + get_content_area()->add(m_layout); + + show_all_children(); +} + +std::string TokenDialog::GetToken() { + return m_token; +} diff --git a/src/dialogs/token.hpp b/src/dialogs/token.hpp new file mode 100644 index 0000000..7778bfb --- /dev/null +++ b/src/dialogs/token.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +class TokenDialog : public Gtk::Dialog { +public: + TokenDialog(Gtk::Window &parent); + std::string GetToken(); + +protected: + Gtk::Box m_layout; + Gtk::Button m_ok; + Gtk::Button m_cancel; + Gtk::ButtonBox m_bbox; + Gtk::Entry m_entry; + +private: + std::string m_token; +}; diff --git a/src/dialogs/verificationgate.cpp b/src/dialogs/verificationgate.cpp new file mode 100644 index 0000000..698ddff --- /dev/null +++ b/src/dialogs/verificationgate.cpp @@ -0,0 +1,51 @@ +#include "verificationgate.hpp" +#include "abaddon.hpp" + +VerificationGateDialog::VerificationGateDialog(Gtk::Window &parent, Snowflake guild_id) + : Gtk::Dialog("Verification Required", parent, true) + , m_bbox(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(300, 300); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + + m_ok_button = add_button("Accept", Gtk::RESPONSE_OK); + + m_scroll_rules.set_vexpand(true); + m_scroll_rules.set_hexpand(true); + + m_description.set_line_wrap(true); + m_description.set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + m_description.set_halign(Gtk::ALIGN_CENTER); + m_description.set_margin_bottom(5); + + m_scroll_rules.add(m_rules); + get_content_area()->add(m_description); + get_content_area()->add(m_scroll_rules); + show_all_children(); + + Abaddon::Get().GetDiscordClient().GetVerificationGateInfo(guild_id, sigc::mem_fun(*this, &VerificationGateDialog::OnVerificationGateFetch)); +} + +const VerificationGateInfoObject &VerificationGateDialog::GetVerificationGate() const { + return m_gate_info; +} + +void VerificationGateDialog::OnVerificationGateFetch(const std::optional &info) { + m_gate_info = *info; + if (m_gate_info.Description.has_value()) + m_description.set_markup("" + Glib::Markup::escape_text(*m_gate_info.Description) + ""); + else + m_description.hide(); + for (const auto &field : *info->VerificationFields) { + if (field.Type == "TERMS") { + for (const auto &rule : field.Values) { + auto *lbl = Gtk::manage(new Gtk::Label(rule)); + lbl->set_halign(Gtk::ALIGN_START); + lbl->set_ellipsize(Pango::ELLIPSIZE_END); + lbl->show(); + m_rules.add(*lbl); + } + break; + } + } +} diff --git a/src/dialogs/verificationgate.hpp b/src/dialogs/verificationgate.hpp new file mode 100644 index 0000000..0a0dc08 --- /dev/null +++ b/src/dialogs/verificationgate.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include "discord/objects.hpp" + +class VerificationGateDialog : public Gtk::Dialog { +public: + VerificationGateDialog(Gtk::Window &parent, Snowflake guild_id); + const VerificationGateInfoObject &GetVerificationGate() const; + +protected: + void OnVerificationGateFetch(const std::optional &info); + + VerificationGateInfoObject m_gate_info; + + Gtk::Label m_description; + Gtk::ScrolledWindow m_scroll_rules; + Gtk::ListBox m_rules; + Gtk::ButtonBox m_bbox; + + Gtk::Button *m_ok_button; +}; diff --git a/src/discord/activity.cpp b/src/discord/activity.cpp new file mode 100644 index 0000000..95dda5d --- /dev/null +++ b/src/discord/activity.cpp @@ -0,0 +1,116 @@ +#include "activity.hpp" + +void from_json(const nlohmann::json &j, ActivityTimestamps &m) { + JS_O("start", m.Start); + JS_O("end", m.End); +} + +void to_json(nlohmann::json &j, const ActivityTimestamps &m) { + JS_IF("start", m.Start); + JS_IF("end", m.End); +} + +void from_json(const nlohmann::json &j, ActivityEmoji &m) { + JS_D("name", m.Name); + JS_O("id", m.ID); + JS_O("animated", m.IsAnimated); +} + +void to_json(nlohmann::json &j, const ActivityEmoji &m) { + j["name"] = m.Name; + if (m.ID.has_value()) + j["id"] = *m.ID; + if (m.IsAnimated.has_value()) + j["animated"] = *m.IsAnimated; +} + +void from_json(const nlohmann::json &j, ActivityParty &m) { + JS_O("id", m.ID); + JS_O("size", m.Size); +} + +void to_json(nlohmann::json &j, const ActivityParty &m) { + JS_IF("id", m.ID); + JS_IF("size", m.Size); +} + +void from_json(const nlohmann::json &j, ActivityAssets &m) { + JS_O("large_image", m.LargeImage); + JS_O("large_text", m.LargeText); + JS_O("small_image", m.SmallImage); + JS_O("small_text", m.SmallText); +} + +void to_json(nlohmann::json &j, const ActivityAssets &m) { + JS_IF("large_image", m.LargeImage); + JS_IF("large_text", m.LargeText); + JS_IF("small_image", m.SmallImage); + JS_IF("small_text", m.SmallText); +} + +void from_json(const nlohmann::json &j, ActivitySecrets &m) { + JS_O("join", m.Join); + JS_O("spectate", m.Spectate); + JS_O("match", m.Match); +} + +void to_json(nlohmann::json &j, const ActivitySecrets &m) { + JS_IF("join", m.Join); + JS_IF("spectate", m.Spectate); + JS_IF("match", m.Match); +} + +void from_json(const nlohmann::json &j, ActivityData &m) { + JS_D("name", m.Name); + JS_D("type", m.Type); + JS_ON("url", m.URL); + JS_D("created_at", m.CreatedAt); + JS_O("timestamps", m.Timestamps); + JS_O("application_id", m.ApplicationID); + JS_ON("details", m.Details); + JS_ON("state", m.State); + JS_ON("emoji", m.Emoji); + JS_ON("party", m.Party); + JS_O("assets", m.Assets); + JS_O("secrets", m.Secrets); + JS_O("instance", m.IsInstance); + JS_O("flags", m.Flags); +} + +void to_json(nlohmann::json &j, const ActivityData &m) { + if (m.Type == ActivityType::Custom) { + j["name"] = "Custom Status"; + j["state"] = m.Name; + } else { + j["name"] = m.Name; + JS_IF("state", m.State); + } + + j["type"] = m.Type; + JS_IF("url", m.URL); + JS_IF("created_at", m.CreatedAt); + JS_IF("timestamps", m.Timestamps); + JS_IF("application_id", m.ApplicationID); + JS_IF("details", m.Details); + JS_IF("emoji", m.Emoji); + JS_IF("party", m.Party); + JS_IF("assets", m.Assets); + JS_IF("secrets", m.Secrets); + JS_IF("instance", m.IsInstance); + JS_IF("flags", m.Flags); +} + +void from_json(const nlohmann::json &j, PresenceData &m) { + JS_N("activities", m.Activities); + JS_D("status", m.Status); +} + +void to_json(nlohmann::json &j, const PresenceData &m) { + j["activities"] = m.Activities; + j["status"] = m.Status; + JS_IF("afk", m.IsAFK); + if (m.Since.has_value()) + j["since"] = *m.Since; + else + j["since"] = 0; +} diff --git a/src/discord/activity.hpp b/src/discord/activity.hpp new file mode 100644 index 0000000..6b8e944 --- /dev/null +++ b/src/discord/activity.hpp @@ -0,0 +1,137 @@ +#pragma once +#include +#include +#include "util.hpp" +#include "json.hpp" +#include "snowflake.hpp" + +enum class PresenceStatus : uint8_t { + Online, + Offline, + Idle, + DND, +}; + +constexpr inline const char *GetPresenceString(PresenceStatus s) { + switch (s) { + case PresenceStatus::Online: + return "online"; + case PresenceStatus::Offline: + return "offline"; + case PresenceStatus::Idle: + return "idle"; + case PresenceStatus::DND: + return "dnd"; + } + return ""; +} + +constexpr inline const char* GetPresenceDisplayString(PresenceStatus s) { + switch (s) { + case PresenceStatus::Online: + return "Online"; + case PresenceStatus::Offline: + return "Offline"; + case PresenceStatus::Idle: + return "Away"; + case PresenceStatus::DND: + return "Do Not Disturb"; + } + return ""; +} + +enum class ActivityType : int { + Game = 0, + Streaming = 1, + Listening = 2, + Watching = 3, // not documented + Custom = 4, + Competing = 5, +}; + +enum class ActivityFlags { + INSTANCE = (1 << 0), + JOIN = (1 << 1), + SPECTATE = (1 << 2), + JOIN_REQUEST = (1 << 3), + SYNC = (1 << 4), + PLAY = (1 << 5), +}; +template<> +struct Bitwise { + static const bool enable = true; +}; + +struct ActivityTimestamps { + std::optional Start; + std::optional End; + + friend void from_json(const nlohmann::json &j, ActivityTimestamps &m); + friend void to_json(nlohmann::json &j, const ActivityTimestamps &m); +}; + +struct ActivityEmoji { + std::string Name; + std::optional ID; + std::optional IsAnimated; + + friend void from_json(const nlohmann::json &j, ActivityEmoji &m); + friend void to_json(nlohmann::json &j, const ActivityEmoji &m); +}; + +struct ActivityParty { + std::optional ID; + std::optional> Size; + + friend void from_json(const nlohmann::json &j, ActivityParty &m); + friend void to_json(nlohmann::json &j, const ActivityParty &m); +}; + +struct ActivityAssets { + std::optional LargeImage; + std::optional LargeText; + std::optional SmallImage; + std::optional SmallText; + + friend void from_json(const nlohmann::json &j, ActivityAssets &m); + friend void to_json(nlohmann::json &j, const ActivityAssets &m); +}; + +struct ActivitySecrets { + std::optional Join; + std::optional Spectate; + std::optional Match; + + friend void from_json(const nlohmann::json &j, ActivitySecrets &m); + friend void to_json(nlohmann::json &j, const ActivitySecrets &m); +}; + +struct ActivityData { + std::string Name; // + ActivityType Type; // + std::optional URL; // null + std::optional CreatedAt; // + std::optional Timestamps; // + std::optional ApplicationID; // + std::optional Details; // null + std::optional State; // null + std::optional Emoji; // null + std::optional Party; // + std::optional Assets; // + std::optional Secrets; // + std::optional IsInstance; // + std::optional Flags; // + + friend void from_json(const nlohmann::json &j, ActivityData &m); + friend void to_json(nlohmann::json &j, const ActivityData &m); +}; + +struct PresenceData { + std::vector Activities; // null (but never sent as such) + std::string Status; + std::optional IsAFK; + std::optional Since; + + friend void from_json(const nlohmann::json &j, PresenceData &m); + friend void to_json(nlohmann::json &j, const PresenceData &m); +}; diff --git a/src/discord/auditlog.cpp b/src/discord/auditlog.cpp new file mode 100644 index 0000000..bfada39 --- /dev/null +++ b/src/discord/auditlog.cpp @@ -0,0 +1,34 @@ +#include "auditlog.hpp" + +void from_json(const nlohmann::json &j, AuditLogChange &m) { + JS_D("key", m.Key); + JS_O("old_value", m.OldValue); + JS_O("new_value", m.NewValue); +} + +void from_json(const nlohmann::json &j, AuditLogOptions &m) { + JS_O("delete_member_days", m.DeleteMemberDays); + JS_O("members_removed", m.MembersRemoved); + JS_O("channel_id", m.ChannelID); + JS_O("message_id", m.MessageID); + JS_O("count", m.Count); + JS_O("id", m.ID); + JS_O("type", m.Type); + JS_O("role_name", m.RoleName); +} + +void from_json(const nlohmann::json &j, AuditLogEntry &m) { + JS_N("target_id", m.TargetID); + JS_O("changes", m.Changes); + JS_N("user_id", m.UserID); + JS_D("id", m.ID); + JS_D("action_type", m.Type); + JS_O("options", m.Options); + JS_O("reason", m.Reason); +} + +void from_json(const nlohmann::json &j, AuditLogData &m) { + JS_D("audit_log_entries", m.Entries); + JS_D("users", m.Users); + JS_D("webhooks", m.Webhooks); +} diff --git a/src/discord/auditlog.hpp b/src/discord/auditlog.hpp new file mode 100644 index 0000000..3a902d1 --- /dev/null +++ b/src/discord/auditlog.hpp @@ -0,0 +1,120 @@ +#pragma once +#include "snowflake.hpp" +#include "user.hpp" +#include "json.hpp" +#include "webhook.hpp" + +enum class AuditLogActionType { + GUILD_UPDATE = 1, + CHANNEL_CREATE = 10, + CHANNEL_UPDATE = 11, + CHANNEL_DELETE = 12, + CHANNEL_OVERWRITE_CREATE = 13, + CHANNEL_OVERWRITE_UPDATE = 14, + CHANNEL_OVERWRITE_DELETE = 15, + MEMBER_KICK = 20, + MEMBER_PRUNE = 21, + MEMBER_BAN_ADD = 22, + MEMBER_BAN_REMOVE = 23, + MEMBER_UPDATE = 24, + MEMBER_ROLE_UPDATE = 25, + MEMBER_MOVE = 26, + MEMBER_DISCONNECT = 27, + BOT_ADD = 28, + ROLE_CREATE = 30, + ROLE_UPDATE = 31, + ROLE_DELETE = 32, + INVITE_CREATE = 40, + INVITE_UPDATE = 41, + INVITE_DELETE = 42, + WEBHOOK_CREATE = 50, + WEBHOOK_UPDATE = 51, + WEBHOOK_DELETE = 52, + EMOJI_CREATE = 60, + EMOJI_UPDATE = 61, + EMOJI_DELETE = 62, + MESSAGE_DELETE = 72, + MESSAGE_BULK_DELETE = 73, + MESSAGE_PIN = 74, + MESSAGE_UNPIN = 75, + INTEGRATION_CREATE = 80, + INTEGRATION_UPDATE = 81, + INTEGRATION_DELETE = 82, + STAGE_INSTANCE_CREATE = 83, + STAGE_INSTANCE_UPDATE = 84, + STAGE_INSTANCE_DELETE = 85, + STICKER_CREATE = 90, + STICKER_UPDATE = 91, + STICKER_DELETE = 92, + THREAD_CREATE = 110, + THREAD_UPDATE = 111, + THREAD_DELETE = 112, +}; + +struct AuditLogChange { + std::string Key; + std::optional OldValue; + std::optional NewValue; + + friend void from_json(const nlohmann::json &j, AuditLogChange &m); +}; + +struct AuditLogOptions { + std::optional DeleteMemberDays; // MEMBER_PRUNE + std::optional MembersRemoved; // MEMBER_PRUNE + std::optional ChannelID; // MEMBER_MOVE, MESSAGE_PIN, MESSAGE_UNPIN, MESSAGE_DELETE + std::optional MessageID; // MESSAGE_PIN, MESSAGE_UNPIN, + std::optional Count; // MESSAGE_DELETE, MESSAGE_BULK_DELETE, MEMBER_DISCONNECT, MEMBER_MOVE + std::optional ID; // CHANNEL_OVERWRITE_CREATE, CHANNEL_OVERWRITE_UPDATE, CHANNEL_OVERWRITE_DELETE + std::optional Type; // CHANNEL_OVERWRITE_CREATE, CHANNEL_OVERWRITE_UPDATE, CHANNEL_OVERWRITE_DELETE + std::optional RoleName; // CHANNEL_OVERWRITE_CREATE, CHANNEL_OVERWRITE_UPDATE, CHANNEL_OVERWRITE_DELETE + + friend void from_json(const nlohmann::json &j, AuditLogOptions &m); +}; + +struct AuditLogEntry { + Snowflake ID; + std::string TargetID; // null + std::optional UserID; + AuditLogActionType Type; + std::optional Reason; + std::optional> Changes; + std::optional Options; + + friend void from_json(const nlohmann::json &j, AuditLogEntry &m); + + template + std::optional GetOldFromKey(const std::string &key) const; + + template + std::optional GetNewFromKey(const std::string &key) const; +}; + +struct AuditLogData { + std::vector Entries; + std::vector Users; + std::vector Webhooks; + // std::vector Integrations; + + friend void from_json(const nlohmann::json &j, AuditLogData &m); +}; + +template +inline std::optional AuditLogEntry::GetOldFromKey(const std::string &key) const { + if (!Changes.has_value()) return std::nullopt; + for (const auto &change : *Changes) + if (change.Key == key && change.OldValue.has_value()) + return change.OldValue->get(); + + return std::nullopt; +} + +template +inline std::optional AuditLogEntry::GetNewFromKey(const std::string &key) const { + if (!Changes.has_value()) return std::nullopt; + for (const auto &change : *Changes) + if (change.Key == key && change.NewValue.has_value()) + return change.NewValue->get(); + + return std::nullopt; +} diff --git a/src/discord/ban.cpp b/src/discord/ban.cpp new file mode 100644 index 0000000..a354c15 --- /dev/null +++ b/src/discord/ban.cpp @@ -0,0 +1,6 @@ +#include "ban.hpp" + +void from_json(const nlohmann::json &j, BanData &m) { + JS_N("reason", m.Reason); + JS_D("user", m.User); +} diff --git a/src/discord/ban.hpp b/src/discord/ban.hpp new file mode 100644 index 0000000..d417ce3 --- /dev/null +++ b/src/discord/ban.hpp @@ -0,0 +1,10 @@ +#pragma once +#include +#include "user.hpp" + +struct BanData { + std::string Reason; // null + UserData User; // access id + + friend void from_json(const nlohmann::json &j, BanData &m); +}; diff --git a/src/discord/channel.cpp b/src/discord/channel.cpp new file mode 100644 index 0000000..80b1760 --- /dev/null +++ b/src/discord/channel.cpp @@ -0,0 +1,97 @@ +#include "abaddon.hpp" +#include "channel.hpp" + +void from_json(const nlohmann::json &j, ThreadMetadataData &m) { + JS_D("archived", m.IsArchived); + JS_D("auto_archive_duration", m.AutoArchiveDuration); + JS_D("archive_timestamp", m.ArchiveTimestamp); + JS_O("locked", m.IsLocked); +} + +void from_json(const nlohmann::json &j, ThreadMemberObject &m) { + JS_O("id", m.ThreadID); + JS_O("user_id", m.UserID); + JS_D("join_timestamp", m.JoinTimestamp); + JS_D("flags", m.Flags); +} + +void from_json(const nlohmann::json &j, ChannelData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_O("guild_id", m.GuildID); + JS_O("position", m.Position); + JS_O("permission_overwrites", m.PermissionOverwrites); + JS_ON("name", m.Name); + JS_ON("topic", m.Topic); + JS_O("nsfw", m.IsNSFW); + JS_ON("last_message_id", m.LastMessageID); + JS_O("bitrate", m.Bitrate); + JS_O("user_limit", m.UserLimit); + JS_O("rate_limit_per_user", m.RateLimitPerUser); + JS_O("recipients", m.Recipients); + JS_O("recipient_ids", m.RecipientIDs); + JS_ON("icon", m.Icon); + JS_O("owner_id", m.OwnerID); + JS_O("application_id", m.ApplicationID); + JS_ON("parent_id", m.ParentID); + JS_ON("last_pin_timestamp", m.LastPinTimestamp); + JS_O("thread_metadata", m.ThreadMetadata); + JS_O("member", m.ThreadMember); +} + +void ChannelData::update_from_json(const nlohmann::json &j) { + JS_RD("type", Type); + JS_RD("guild_id", GuildID); + JS_RV("position", Position, -1); + JS_RD("permission_overwrites", PermissionOverwrites); + JS_RD("name", Name); + JS_RD("topic", Topic); + JS_RD("nsfw", IsNSFW); + JS_RD("last_message_id", LastMessageID); + JS_RD("bitrate", Bitrate); + JS_RD("user_limit", UserLimit); + JS_RD("rate_limit_per_user", RateLimitPerUser); + JS_RD("recipients", Recipients); + JS_RD("icon", Icon); + JS_RD("owner_id", OwnerID); + JS_RD("application_id", ApplicationID); + JS_RD("parent_id", ParentID); + JS_RD("last_pin_timestamp", LastPinTimestamp); +} + +bool ChannelData::NSFW() const { + return IsNSFW.has_value() && *IsNSFW; +} + +bool ChannelData::IsThread() const noexcept { + return Type == ChannelType::GUILD_PUBLIC_THREAD || + Type == ChannelType::GUILD_PRIVATE_THREAD || + Type == ChannelType::GUILD_NEWS_THREAD; +} + +bool ChannelData::IsJoinedThread() const { + return Abaddon::Get().GetDiscordClient().IsThreadJoined(ID); +} + +std::optional ChannelData::GetOverwrite(Snowflake id) const { + return Abaddon::Get().GetDiscordClient().GetPermissionOverwrite(ID, id); +} + +std::vector ChannelData::GetDMRecipients() const { + const auto &discord = Abaddon::Get().GetDiscordClient(); + if (Recipients.has_value()) + return *Recipients; + + if (RecipientIDs.has_value()) { + std::vector ret; + for (const auto &id : *RecipientIDs) { + auto user = discord.GetUser(id); + if (user.has_value()) + ret.push_back(std::move(*user)); + } + + return ret; + } + + return std::vector(); +} diff --git a/src/discord/channel.hpp b/src/discord/channel.hpp new file mode 100644 index 0000000..942d555 --- /dev/null +++ b/src/discord/channel.hpp @@ -0,0 +1,92 @@ +#pragma once +#include "snowflake.hpp" +#include "json.hpp" +#include "user.hpp" +#include "permissions.hpp" +#include +#include + +enum class ChannelType : int { + GUILD_TEXT = 0, + DM = 1, + GUILD_VOICE = 2, + GROUP_DM = 3, + GUILD_CATEGORY = 4, + GUILD_NEWS = 5, + GUILD_STORE = 6, + /* 7 and 8 were used for LFG */ + /* 9 was used for threads */ + GUILD_NEWS_THREAD = 10, + GUILD_PUBLIC_THREAD = 11, + GUILD_PRIVATE_THREAD = 12, + GUILD_STAGE_VOICE = 13, +}; + +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 { + bool IsArchived; + int AutoArchiveDuration; + std::string ArchiveTimestamp; + std::optional IsLocked; + + friend void from_json(const nlohmann::json &j, ThreadMetadataData &m); +}; + +struct ThreadMemberObject { + std::optional ThreadID; + std::optional UserID; + std::string JoinTimestamp; + int Flags; + + friend void from_json(const nlohmann::json &j, ThreadMemberObject &m); +}; + +struct ChannelData { + Snowflake ID; + ChannelType Type; + std::optional GuildID; + std::optional Position; + std::optional> PermissionOverwrites; // shouldnt be accessed + std::optional Name; // null for dm's + std::optional Topic; // null + std::optional IsNSFW; + std::optional LastMessageID; // null + std::optional Bitrate; + std::optional UserLimit; + std::optional RateLimitPerUser; + std::optional> Recipients; // only access id + std::optional> RecipientIDs; + std::optional Icon; // null + std::optional OwnerID; + std::optional ApplicationID; + std::optional ParentID; // null + std::optional LastPinTimestamp; // null + std::optional ThreadMetadata; + std::optional ThreadMember; + + friend void from_json(const nlohmann::json &j, ChannelData &m); + void update_from_json(const nlohmann::json &j); + + bool NSFW() const; + bool IsThread() const noexcept; + bool IsJoinedThread() const; + std::optional GetOverwrite(Snowflake id) const; + std::vector GetDMRecipients() const; +}; diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp new file mode 100644 index 0000000..bed959f --- /dev/null +++ b/src/discord/discord.cpp @@ -0,0 +1,2310 @@ +#include "discord.hpp" +#include +#include +#include "util.hpp" +#include "abaddon.hpp" + +DiscordClient::DiscordClient(bool mem_store) + : m_decompress_buf(InflateChunkSize) + , m_store(mem_store) { + m_msg_dispatch.connect(sigc::mem_fun(*this, &DiscordClient::MessageDispatch)); + auto dispatch_cb = [this]() { + m_generic_mutex.lock(); + auto func = m_generic_queue.front(); + m_generic_queue.pop(); + m_generic_mutex.unlock(); + func(); + }; + m_generic_dispatch.connect(dispatch_cb); + + m_websocket.signal_message().connect(sigc::mem_fun(*this, &DiscordClient::HandleGatewayMessageRaw)); + m_websocket.signal_open().connect(sigc::mem_fun(*this, &DiscordClient::HandleSocketOpen)); + m_websocket.signal_close().connect(sigc::mem_fun(*this, &DiscordClient::HandleSocketClose)); + + LoadEventMap(); +} + +void DiscordClient::Start() { + if (m_client_started) return; + + m_http.SetBase(GetAPIURL()); + + std::memset(&m_zstream, 0, sizeof(m_zstream)); + inflateInit2(&m_zstream, MAX_WBITS + 32); + + m_last_sequence = -1; + m_heartbeat_acked = true; + m_client_connected = true; + m_client_started = true; + m_websocket.StartConnection(GetGatewayURL()); +} + +void DiscordClient::Stop() { + if (m_client_started) { + inflateEnd(&m_zstream); + m_compressed_buf.clear(); + + m_heartbeat_waiter.kill(); + if (m_heartbeat_thread.joinable()) m_heartbeat_thread.join(); + m_client_connected = false; + m_reconnecting = false; + + m_store.ClearAll(); + m_guild_to_users.clear(); + + m_websocket.Stop(); + } + + m_client_started = false; +} + +bool DiscordClient::IsStarted() const { + return m_client_started; +} + +bool DiscordClient::IsStoreValid() const { + return m_store.IsValid(); +} + +const UserSettings &DiscordClient::GetUserSettings() const { + return m_user_settings; +} + +std::unordered_set DiscordClient::GetGuilds() const { + return m_store.GetGuilds(); +} + +const UserData &DiscordClient::GetUserData() const { + return m_user_data; +} + +std::vector DiscordClient::GetUserSortedGuilds() const { + // sort order is unfolder'd guilds sorted by id descending, then guilds in folders in array order + // todo: make sure folder'd guilds are sorted properly + std::vector folder_order; + auto guilds = GetGuilds(); + for (const auto &entry : m_user_settings.GuildFolders) { // can contain guilds not a part of + for (const auto &id : entry.GuildIDs) { + if (std::find(guilds.begin(), guilds.end(), id) != guilds.end()) + folder_order.push_back(id); + } + } + + std::vector ret; + for (const auto &gid : guilds) { + if (std::find(folder_order.begin(), folder_order.end(), gid) == folder_order.end()) { + ret.push_back(gid); + } + } + + std::sort(ret.rbegin(), ret.rend()); + + for (const auto &gid : folder_order) + ret.push_back(gid); + + return ret; +} + +std::vector DiscordClient::GetMessagesForChannel(Snowflake id, size_t limit) const { + return m_store.GetLastMessages(id, limit); +} + +std::vector DiscordClient::GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit) const { + return m_store.GetMessagesBefore(channel_id, message_id, limit); +} + +void DiscordClient::FetchInvite(std::string code, sigc::slot)> callback) { + m_http.MakeGET("/invites/" + code + "?with_counts=true", [this, callback](http::response_type r) { + if (!CheckCode(r)) { + if (r.status_code == 404) + callback(std::nullopt); + return; + }; + + callback(nlohmann::json::parse(r.text).get()); + }); +} + +void DiscordClient::FetchMessagesInChannel(Snowflake id, sigc::slot &)> cb) { + std::string path = "/channels/" + std::to_string(id) + "/messages?limit=50"; + m_http.MakeGET(path, [this, id, cb](const http::response_type &r) { + if (!CheckCode(r)) { + // fake a thread delete event if the requested channel is a thread and we get a 404 + + if (r.status_code == http::NotFound) { + const auto channel = m_store.GetChannel(id); + if (channel.has_value() && channel->IsThread()) { + ThreadDeleteData data; + data.GuildID = *channel->GuildID; + data.ID = id; + data.ParentID = *channel->ParentID; + data.Type = channel->Type; + m_signal_thread_delete.emit(data); + } + } + + return; + } + + std::vector msgs; + + nlohmann::json::parse(r.text).get_to(msgs); + + m_store.BeginTransaction(); + for (auto &msg : msgs) { + StoreMessageData(msg); + if (msg.GuildID.has_value()) + AddUserToGuild(msg.Author.ID, *msg.GuildID); + } + m_store.EndTransaction(); + + cb(msgs); + }); +} + +void DiscordClient::FetchMessagesInChannelBefore(Snowflake channel_id, Snowflake before_id, sigc::slot &)> cb) { + std::string path = "/channels/" + std::to_string(channel_id) + "/messages?limit=50&before=" + std::to_string(before_id); + m_http.MakeGET(path, [this, channel_id, cb](http::response_type r) { + if (!CheckCode(r)) return; + + std::vector msgs; + + nlohmann::json::parse(r.text).get_to(msgs); + + m_store.BeginTransaction(); + for (auto &msg : msgs) { + StoreMessageData(msg); + if (msg.GuildID.has_value()) + AddUserToGuild(msg.Author.ID, *msg.GuildID); + } + m_store.EndTransaction(); + + std::sort(msgs.begin(), msgs.end(), [](const Message &a, const Message &b) { return a.ID < b.ID; }); + cb(msgs); + }); +} + +std::optional DiscordClient::GetMessage(Snowflake id) const { + return m_store.GetMessage(id); +} + +std::optional DiscordClient::GetChannel(Snowflake id) const { + return m_store.GetChannel(id); +} + +std::optional DiscordClient::GetUser(Snowflake id) const { + return m_store.GetUser(id); +} + +std::optional DiscordClient::GetRole(Snowflake id) const { + return m_store.GetRole(id); +} + +std::optional DiscordClient::GetGuild(Snowflake id) const { + return m_store.GetGuild(id); +} + +std::optional DiscordClient::GetMember(Snowflake user_id, Snowflake guild_id) const { + return m_store.GetGuildMember(guild_id, user_id); +} + +std::optional DiscordClient::GetBan(Snowflake guild_id, Snowflake user_id) const { + return m_store.GetBan(guild_id, user_id); +} + +std::optional DiscordClient::GetPermissionOverwrite(Snowflake channel_id, Snowflake id) const { + return m_store.GetPermissionOverwrite(channel_id, id); +} + +std::optional DiscordClient::GetEmoji(Snowflake id) const { + return m_store.GetEmoji(id); +} + +Snowflake DiscordClient::GetMemberHoistedRole(Snowflake guild_id, Snowflake user_id, bool with_color) const { + const auto data = GetMember(user_id, guild_id); + if (!data.has_value()) return Snowflake::Invalid; + + std::optional top_role; + for (const auto &id : data->Roles) { + const auto role = GetRole(id); + if (role.has_value()) { + if ((with_color && role->Color != 0x000000) || (!with_color && role->IsHoisted)) + if (!top_role.has_value() || top_role->Position < role->Position) + top_role = role; + } + } + + return top_role.has_value() ? top_role->ID : Snowflake::Invalid; +} + +std::optional DiscordClient::GetMemberHighestRole(Snowflake guild_id, Snowflake user_id) const { + const auto data = GetMember(user_id, guild_id); + if (!data.has_value()) return std::nullopt; + + if (data->Roles.size() == 0) return std::nullopt; + if (data->Roles.size() == 1) return GetRole(data->Roles[0]); + + std::vector roles; + for (const auto id : data->Roles) + roles.push_back(*GetRole(id)); + + return *std::max_element(roles.begin(), roles.end(), [this](const auto &a, const auto &b) -> bool { + return a.Position < b.Position; + }); +} + +std::set DiscordClient::GetUsersInGuild(Snowflake id) const { + auto it = m_guild_to_users.find(id); + if (it != m_guild_to_users.end()) + return it->second; + + return {}; +} + +std::set DiscordClient::GetChannelsInGuild(Snowflake id) const { + auto it = m_guild_to_channels.find(id); + if (it != m_guild_to_channels.end()) + return it->second; + return {}; +} + +std::vector DiscordClient::GetUsersInThread(Snowflake id) const { + if (auto it = m_thread_members.find(id); it != m_thread_members.end()) + return it->second; + return {}; +} + +// there is an endpoint for this but it should be synced before this is called anyways +std::vector DiscordClient::GetActiveThreads(Snowflake channel_id) const { + return m_store.GetActiveThreads(channel_id); +} + +void DiscordClient::GetArchivedPublicThreads(Snowflake channel_id, sigc::slot callback) { + m_http.MakeGET("/channels/" + std::to_string(channel_id) + "/threads/archived/public", [this, callback](const http::response_type &r) { + if (CheckCode(r)) { + const auto data = nlohmann::json::parse(r.text).get(); + for (const auto &thread : data.Threads) + m_store.SetChannel(thread.ID, thread); + callback(DiscordError::NONE, data); + } else { + callback(GetCodeFromResponse(r), {}); + } + }); +} + +bool DiscordClient::IsThreadJoined(Snowflake thread_id) const { + return std::find(m_joined_threads.begin(), m_joined_threads.end(), thread_id) != m_joined_threads.end(); +} + +bool DiscordClient::HasGuildPermission(Snowflake user_id, Snowflake guild_id, Permission perm) const { + const auto base = ComputePermissions(user_id, guild_id); + return (base & perm) == perm; +} + +bool DiscordClient::HasAnyChannelPermission(Snowflake user_id, Snowflake channel_id, Permission perm) const { + const auto channel = m_store.GetChannel(channel_id); + if (!channel.has_value() || !channel->GuildID.has_value()) return false; + const auto base = ComputePermissions(user_id, *channel->GuildID); + const auto overwrites = ComputeOverwrites(base, user_id, channel_id); + return (overwrites & perm) != Permission::NONE; +} + +bool DiscordClient::HasChannelPermission(Snowflake user_id, Snowflake channel_id, Permission perm) const { + const auto channel = m_store.GetChannel(channel_id); + if (!channel.has_value()) return false; + const auto base = ComputePermissions(user_id, *channel->GuildID); + const auto overwrites = ComputeOverwrites(base, user_id, channel_id); + return (overwrites & perm) == perm; +} + +Permission DiscordClient::ComputePermissions(Snowflake member_id, Snowflake guild_id) const { + const auto member = GetMember(member_id, guild_id); + const auto guild = GetGuild(guild_id); + if (!member.has_value() || !guild.has_value()) + return Permission::NONE; + + if (guild->OwnerID == member_id) + return Permission::ALL; + + const auto everyone = GetRole(guild_id); + if (!everyone.has_value()) + return Permission::NONE; + + Permission perms = everyone->Permissions; + for (const auto role_id : member->Roles) { + const auto role = GetRole(role_id); + if (role.has_value()) + perms |= role->Permissions; + } + + if ((perms & Permission::ADMINISTRATOR) == Permission::ADMINISTRATOR) + return Permission::ALL; + + return perms; +} + +Permission DiscordClient::ComputeOverwrites(Permission base, Snowflake member_id, Snowflake channel_id) const { + if ((base & Permission::ADMINISTRATOR) == Permission::ADMINISTRATOR) + return Permission::ALL; + + const auto channel = GetChannel(channel_id); + const auto member = GetMember(member_id, *channel->GuildID); + if (!member.has_value() || !channel.has_value()) + return Permission::NONE; + + Permission perms = base; + const auto overwrite_everyone = GetPermissionOverwrite(channel_id, *channel->GuildID); + if (overwrite_everyone.has_value()) { + perms &= ~overwrite_everyone->Deny; + perms |= overwrite_everyone->Allow; + } + + Permission allow = Permission::NONE; + Permission deny = Permission::NONE; + for (const auto role_id : member->Roles) { + const auto overwrite = GetPermissionOverwrite(channel_id, role_id); + if (overwrite.has_value()) { + allow |= overwrite->Allow; + deny |= overwrite->Deny; + } + } + + perms &= ~deny; + perms |= allow; + + const auto member_overwrite = GetPermissionOverwrite(channel_id, member_id); + if (member_overwrite.has_value()) { + perms &= ~member_overwrite->Deny; + perms |= member_overwrite->Allow; + } + + return perms; +} + +bool DiscordClient::CanManageMember(Snowflake guild_id, Snowflake actor, Snowflake target) const { + const auto guild = GetGuild(guild_id); + if (guild.has_value() && guild->OwnerID == target) return false; + const auto actor_highest = GetMemberHighestRole(guild_id, actor); + const auto target_highest = GetMemberHighestRole(guild_id, target); + if (!actor_highest.has_value()) return false; + if (!target_highest.has_value()) return true; + return actor_highest->Position > target_highest->Position; +} + +void DiscordClient::ChatMessageCallback(std::string nonce, const http::response_type &response) { + if (!CheckCode(response)) { + if (response.status_code == http::TooManyRequests) { + try { // not sure if this body is guaranteed + RateLimitedResponse r = nlohmann::json::parse(response.text); + m_signal_message_send_fail.emit(nonce, r.RetryAfter); + } catch (...) { + m_signal_message_send_fail.emit(nonce, 0); + } + } else { + m_signal_message_send_fail.emit(nonce, 0); + } + } +} + +void DiscordClient::SendChatMessage(const std::string &content, Snowflake channel) { + // @([^@#]{1,32})#(\\d{4}) + const auto nonce = std::to_string(Snowflake::FromNow()); + CreateMessageObject obj; + obj.Content = content; + obj.Nonce = nonce; + m_http.MakePOST("/channels/" + std::to_string(channel) + "/messages", nlohmann::json(obj).dump(), sigc::bind<0>(sigc::mem_fun(*this, &DiscordClient::ChatMessageCallback), nonce)); + // dummy data so the content can be shown while waiting for MESSAGE_CREATE + Message tmp; + tmp.Content = content; + tmp.ID = nonce; + tmp.ChannelID = channel; + tmp.Author = GetUserData(); + tmp.IsTTS = false; + tmp.DoesMentionEveryone = false; + tmp.Type = MessageType::DEFAULT; + tmp.IsPinned = false; + tmp.Timestamp = "2000-01-01T00:00:00.000000+00:00"; + tmp.Nonce = obj.Nonce; + tmp.IsPending = true; + m_store.SetMessage(tmp.ID, tmp); + m_signal_message_sent.emit(tmp); +} + +void DiscordClient::SendChatMessage(const std::string &content, Snowflake channel, Snowflake referenced_message) { + const auto nonce = std::to_string(Snowflake::FromNow()); + CreateMessageObject obj; + obj.Content = content; + obj.Nonce = nonce; + obj.MessageReference.emplace().MessageID = referenced_message; + m_http.MakePOST("/channels/" + std::to_string(channel) + "/messages", nlohmann::json(obj).dump(), sigc::bind<0>(sigc::mem_fun(*this, &DiscordClient::ChatMessageCallback), nonce)); + Message tmp; + tmp.Content = content; + tmp.ID = nonce; + tmp.ChannelID = channel; + tmp.Author = GetUserData(); + tmp.IsTTS = false; + tmp.DoesMentionEveryone = false; + tmp.Type = MessageType::DEFAULT; + tmp.IsPinned = false; + tmp.Timestamp = "2000-01-01T00:00:00.000000+00:00"; + tmp.Nonce = obj.Nonce; + tmp.IsPending = true; + m_store.SetMessage(tmp.ID, tmp); + m_signal_message_sent.emit(tmp); +} + +void DiscordClient::DeleteMessage(Snowflake channel_id, Snowflake id) { + std::string path = "/channels/" + std::to_string(channel_id) + "/messages/" + std::to_string(id); + m_http.MakeDELETE(path, [](auto) {}); +} + +void DiscordClient::EditMessage(Snowflake channel_id, Snowflake id, std::string content) { + std::string path = "/channels/" + std::to_string(channel_id) + "/messages/" + std::to_string(id); + MessageEditObject obj; + obj.Content = content; + nlohmann::json j = obj; + m_http.MakePATCH(path, j.dump(), [](auto) {}); +} + +void DiscordClient::SendLazyLoad(Snowflake id) { + LazyLoadRequestMessage msg; + msg.Channels.emplace(); + msg.Channels.value()[id] = { + std::make_pair(0, 99), + std::make_pair(100, 199) + }; + msg.GuildID = *GetChannel(id)->GuildID; + msg.ShouldGetActivities = true; + msg.ShouldGetTyping = true; + msg.ShouldGetThreads = true; + + m_websocket.Send(msg); + + m_channels_lazy_loaded.insert(id); +} + +void DiscordClient::SendThreadLazyLoad(Snowflake id) { + auto thread = GetChannel(id); + if (thread.has_value()) + if (m_channels_lazy_loaded.find(*thread->ParentID) == m_channels_lazy_loaded.end()) + SendLazyLoad(*thread->ParentID); + + LazyLoadRequestMessage msg; + msg.GuildID = *GetChannel(id)->GuildID; + msg.ThreadIDs.emplace().push_back(id); + + m_websocket.Send(msg); +} + +void DiscordClient::JoinGuild(std::string code) { + m_http.MakePOST("/invites/" + code, "{}", [](auto) {}); +} + +void DiscordClient::LeaveGuild(Snowflake id) { + m_http.MakeDELETE("/users/@me/guilds/" + std::to_string(id), [](auto) {}); +} + +void DiscordClient::KickUser(Snowflake user_id, Snowflake guild_id) { + m_http.MakeDELETE("/guilds/" + std::to_string(guild_id) + "/members/" + std::to_string(user_id), [](auto) {}); +} + +void DiscordClient::BanUser(Snowflake user_id, Snowflake guild_id) { + m_http.MakePUT("/guilds/" + std::to_string(guild_id) + "/bans/" + std::to_string(user_id), "{}", [](auto) {}); +} + +void DiscordClient::UpdateStatus(PresenceStatus status, bool is_afk) { + UpdateStatusMessage msg; + msg.Status = status; + msg.IsAFK = is_afk; + + m_websocket.Send(nlohmann::json(msg)); + // fake message cuz we dont receive messages for ourself + m_user_to_status[m_user_data.ID] = status; + m_signal_presence_update.emit(GetUserData(), status); +} + +void DiscordClient::UpdateStatus(PresenceStatus status, bool is_afk, const ActivityData &obj) { + UpdateStatusMessage msg; + msg.Status = status; + msg.IsAFK = is_afk; + msg.Activities.push_back(obj); + + m_websocket.Send(nlohmann::json(msg)); + m_user_to_status[m_user_data.ID] = status; + m_signal_presence_update.emit(GetUserData(), status); +} + +void DiscordClient::CreateDM(Snowflake user_id) { + CreateDM(user_id, [](...) {}); +} + +void DiscordClient::CreateDM(Snowflake user_id, sigc::slot callback) { + CreateDMObject obj; + obj.Recipients.push_back(user_id); + m_http.MakePOST("/users/@me/channels", nlohmann::json(obj).dump(), [this, callback](const http::response &response) { + if (!CheckCode(response)) { + callback(DiscordError::NONE, Snowflake::Invalid); + return; + } + auto channel = nlohmann::json::parse(response.text).get(); + callback(GetCodeFromResponse(response), channel.ID); + }); +} + +void DiscordClient::CloseDM(Snowflake channel_id) { + m_http.MakeDELETE("/channels/" + std::to_string(channel_id), [this](const http::response &response) { + CheckCode(response); + }); +} + +std::optional DiscordClient::FindDM(Snowflake user_id) { + const auto &channels = m_store.GetChannels(); + for (const auto &id : channels) { + const auto channel = m_store.GetChannel(id); + const auto recipients = channel->GetDMRecipients(); + if (recipients.size() == 1 && recipients[0].ID == user_id) + return id; + } + + return std::nullopt; +} + +void DiscordClient::AddReaction(Snowflake id, Glib::ustring param) { + if (!param.is_ascii()) // means unicode param + param = Glib::uri_escape_string(param, "", false); + else { + const auto &tmp = m_store.GetEmoji(param); + if (tmp.has_value()) + param = tmp->Name + ":" + std::to_string(tmp->ID); + else + return; + } + Snowflake channel_id = m_store.GetMessage(id)->ChannelID; + m_http.MakePUT("/channels/" + std::to_string(channel_id) + "/messages/" + std::to_string(id) + "/reactions/" + param + "/@me", "", [](auto) {}); +} + +void DiscordClient::RemoveReaction(Snowflake id, Glib::ustring param) { + if (!param.is_ascii()) // means unicode param + param = Glib::uri_escape_string(param, "", false); + else { + const auto &tmp = m_store.GetEmoji(param); + if (tmp.has_value()) + param = tmp->Name + ":" + std::to_string(tmp->ID); + else + return; + } + Snowflake channel_id = m_store.GetMessage(id)->ChannelID; + m_http.MakeDELETE("/channels/" + std::to_string(channel_id) + "/messages/" + std::to_string(id) + "/reactions/" + param + "/@me", [](auto) {}); +} + +void DiscordClient::SetGuildName(Snowflake id, const Glib::ustring &name) { + SetGuildName(id, name, [](auto) {}); +} + +void DiscordClient::SetGuildName(Snowflake id, const Glib::ustring &name, sigc::slot callback) { + ModifyGuildObject obj; + obj.Name = name; + m_http.MakePATCH("/guilds/" + std::to_string(id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &r) { + if (CheckCode(r)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(r)); + }); +} + +void DiscordClient::SetGuildIcon(Snowflake id, const std::string &data) { + SetGuildIcon(id, data, [](auto) {}); +} + +void DiscordClient::SetGuildIcon(Snowflake id, const std::string &data, sigc::slot callback) { + ModifyGuildObject obj; + obj.IconData = data; + m_http.MakePATCH("/guilds/" + std::to_string(id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &r) { + if (CheckCode(r)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(r)); + }); +} + +void DiscordClient::UnbanUser(Snowflake guild_id, Snowflake user_id) { + UnbanUser(guild_id, user_id, [](const auto) {}); +} + +void DiscordClient::UnbanUser(Snowflake guild_id, Snowflake user_id, sigc::slot callback) { + m_http.MakeDELETE("/guilds/" + std::to_string(guild_id) + "/bans/" + std::to_string(user_id), [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::DeleteInvite(const std::string &code) { + DeleteInvite(code, [](const auto) {}); +} + +void DiscordClient::DeleteInvite(const std::string &code, sigc::slot callback) { + m_http.MakeDELETE("/invites/" + code, [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::AddGroupDMRecipient(Snowflake channel_id, Snowflake user_id) { + m_http.MakePUT("/channels/" + std::to_string(channel_id) + "/recipients/" + std::to_string(user_id), "", [this](const http::response_type &response) { + CheckCode(response); + }); +} + +void DiscordClient::RemoveGroupDMRecipient(Snowflake channel_id, Snowflake user_id) { + m_http.MakeDELETE("/channels/" + std::to_string(channel_id) + "/recipients/" + std::to_string(user_id), [this](const http::response_type &response) { + CheckCode(response); + }); +} + +void DiscordClient::ModifyRolePermissions(Snowflake guild_id, Snowflake role_id, Permission permissions, sigc::slot callback) { + ModifyGuildRoleObject obj; + obj.Permissions = permissions; + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/roles/" + std::to_string(role_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::ModifyRoleName(Snowflake guild_id, Snowflake role_id, const Glib::ustring &name, sigc::slot callback) { + ModifyGuildRoleObject obj; + obj.Name = name; + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/roles/" + std::to_string(role_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::ModifyRoleColor(Snowflake guild_id, Snowflake role_id, uint32_t color, sigc::slot callback) { + ModifyGuildRoleObject obj; + obj.Color = color; + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/roles/" + std::to_string(role_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::ModifyRoleColor(Snowflake guild_id, Snowflake role_id, Gdk::RGBA color, sigc::slot callback) { + uint32_t int_color = 0; + int_color |= static_cast(color.get_blue() * 255.0) << 0; + int_color |= static_cast(color.get_green() * 255.0) << 8; + int_color |= static_cast(color.get_red() * 255.0) << 16; + ModifyRoleColor(guild_id, role_id, int_color, callback); +} + +void DiscordClient::ModifyRolePosition(Snowflake guild_id, Snowflake role_id, int position, sigc::slot callback) { + const auto roles = GetGuild(guild_id)->FetchRoles(); + if (static_cast(position) > roles.size()) return; + // gay and makes you send every role in between new and old position + constexpr auto IDX_MAX = ~size_t { 0 }; + size_t index_from = IDX_MAX, index_to = IDX_MAX; + for (size_t i = 0; i < roles.size(); i++) { + const auto &role = roles[i]; + if (role.ID == role_id) + index_from = i; + else if (role.Position == position) + index_to = i; + if (index_from != IDX_MAX && index_to != IDX_MAX) break; + } + + if (index_from == IDX_MAX || index_to == IDX_MAX) return; + + int dir; + size_t range_from, range_to; + if (index_to > index_from) { + dir = 1; + range_from = index_from + 1; + range_to = index_to + 1; + } else { + dir = -1; + range_from = index_to; + range_to = index_from; + } + + ModifyGuildRolePositionsObject obj; + + obj.Positions.push_back({ roles[index_from].ID, position }); + for (size_t i = range_from; i < range_to; i++) + obj.Positions.push_back({ roles[i].ID, roles[i].Position + dir }); + + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/roles", nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::ModifyEmojiName(Snowflake guild_id, Snowflake emoji_id, const Glib::ustring &name, sigc::slot callback) { + ModifyGuildEmojiObject obj; + obj.Name = name; + + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/emojis/" + std::to_string(emoji_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::DeleteEmoji(Snowflake guild_id, Snowflake emoji_id, sigc::slot callback) { + m_http.MakeDELETE("/guilds/" + std::to_string(guild_id) + "/emojis/" + std::to_string(emoji_id), [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +std::optional DiscordClient::GetGuildApplication(Snowflake guild_id) const { + const auto it = m_guild_join_requests.find(guild_id); + if (it == m_guild_join_requests.end()) return std::nullopt; + return it->second; +} + +void DiscordClient::RemoveRelationship(Snowflake id, sigc::slot callback) { + m_http.MakeDELETE("/users/@me/relationships/" + std::to_string(id), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::SendFriendRequest(const Glib::ustring &username, int discriminator, sigc::slot callback) { + FriendRequestObject obj; + obj.Username = username; + obj.Discriminator = discriminator; + m_http.MakePOST("/users/@me/relationships", nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::PutRelationship(Snowflake id, sigc::slot callback) { + m_http.MakePUT("/users/@me/relationships/" + std::to_string(id), "{}", [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::Pin(Snowflake channel_id, Snowflake message_id, sigc::slot callback) { + m_http.MakePUT("/channels/" + std::to_string(channel_id) + "/pins/" + std::to_string(message_id), "", [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::Unpin(Snowflake channel_id, Snowflake message_id, sigc::slot callback) { + m_http.MakeDELETE("/channels/" + std::to_string(channel_id) + "/pins/" + std::to_string(message_id), [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +// i dont know if the location parameter is necessary at all but discord's thread implementation is extremely strange +// so its here just in case +void DiscordClient::LeaveThread(Snowflake channel_id, const std::string &location, sigc::slot callback) { + m_http.MakeDELETE("/channels/" + std::to_string(channel_id) + "/thread-members/@me?location=" + location, [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::ArchiveThread(Snowflake channel_id, sigc::slot callback) { + ModifyChannelObject obj; + obj.Archived = true; + obj.Locked = true; + m_http.MakePATCH("/channels/" + std::to_string(channel_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::UnArchiveThread(Snowflake channel_id, sigc::slot callback) { + ModifyChannelObject obj; + obj.Archived = false; + obj.Locked = false; + m_http.MakePATCH("/channels/" + std::to_string(channel_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::FetchPinned(Snowflake id, sigc::slot, DiscordError code)> callback) { + // return from db if we know the pins have already been requested + if (m_channels_pinned_requested.find(id) != m_channels_pinned_requested.end()) { + callback(m_store.GetPinnedMessages(id), DiscordError::NONE); + return; + } + m_channels_pinned_requested.insert(id); + + m_http.MakeGET("/channels/" + std::to_string(id) + "/pins", [this, callback](const http::response_type &response) { + if (!CheckCode(response)) { + callback({}, GetCodeFromResponse(response)); + return; + } + + auto data = nlohmann::json::parse(response.text).get>(); + std::sort(data.begin(), data.end(), [](const Message &a, const Message &b) { return a.ID < b.ID; }); + for (auto &msg : data) + StoreMessageData(msg); + callback(std::move(data), DiscordError::NONE); + }); +} + +bool DiscordClient::CanModifyRole(Snowflake guild_id, Snowflake role_id, Snowflake user_id) const { + const auto guild = *GetGuild(guild_id); + if (guild.OwnerID == user_id) return true; + const auto role = *GetRole(role_id); + const auto has_modify = HasGuildPermission(user_id, guild_id, Permission::MANAGE_CHANNELS); + const auto highest = GetMemberHighestRole(guild_id, user_id); + return has_modify && highest.has_value() && highest->Position > role.Position; +} + +bool DiscordClient::CanModifyRole(Snowflake guild_id, Snowflake role_id) const { + return CanModifyRole(guild_id, role_id, GetUserData().ID); +} + +std::vector DiscordClient::GetBansInGuild(Snowflake guild_id) { + return m_store.GetBans(guild_id); +} + +void DiscordClient::FetchGuildBan(Snowflake guild_id, Snowflake user_id, sigc::slot callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/bans/" + std::to_string(user_id), [this, callback, guild_id](const http::response_type &response) { + if (!CheckCode(response)) return; + auto ban = nlohmann::json::parse(response.text).get(); + m_store.SetBan(guild_id, ban.User.ID, ban); + m_store.SetUser(ban.User.ID, ban.User); + callback(ban); + }); +} + +void DiscordClient::FetchGuildBans(Snowflake guild_id, sigc::slot)> callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/bans", [this, callback, guild_id](const http::response_type &response) { + if (!CheckCode(response)) return; + auto bans = nlohmann::json::parse(response.text).get>(); + m_store.BeginTransaction(); + for (const auto &ban : bans) { + m_store.SetBan(guild_id, ban.User.ID, ban); + m_store.SetUser(ban.User.ID, ban.User); + } + m_store.EndTransaction(); + callback(bans); + }); +} + +void DiscordClient::FetchGuildInvites(Snowflake guild_id, sigc::slot)> callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/invites", [this, callback, guild_id](const http::response_type &response) { + // store? + if (!CheckCode(response)) return; + auto invites = nlohmann::json::parse(response.text).get>(); + + m_store.BeginTransaction(); + for (const auto &invite : invites) + if (invite.Inviter.has_value()) + m_store.SetUser(invite.Inviter->ID, *invite.Inviter); + m_store.EndTransaction(); + + callback(invites); + }); +} + +void DiscordClient::FetchAuditLog(Snowflake guild_id, sigc::slot callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/audit-logs", [this, callback](const http::response &response) { + if (!CheckCode(response)) return; + auto data = nlohmann::json::parse(response.text).get(); + + m_store.BeginTransaction(); + for (const auto &user : data.Users) + m_store.SetUser(user.ID, user); + m_store.EndTransaction(); + + callback(data); + }); +} + +void DiscordClient::FetchGuildEmojis(Snowflake guild_id, sigc::slot)> callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/emojis", [this, callback](const http::response_type &response) { + if (!CheckCode(response)) return; + auto emojis = nlohmann::json::parse(response.text).get>(); + m_store.BeginTransaction(); + for (const auto &emoji : emojis) + m_store.SetEmoji(emoji.ID, emoji); + m_store.EndTransaction(); + callback(std::move(emojis)); + }); +} + +void DiscordClient::FetchUserProfile(Snowflake user_id, sigc::slot callback) { + m_http.MakeGET("/users/" + std::to_string(user_id) + "/profile", [this, callback](const http::response_type &response) { + if (!CheckCode(response)) return; + callback(nlohmann::json::parse(response.text).get()); + }); +} + +void DiscordClient::FetchUserNote(Snowflake user_id, sigc::slot callback) { + m_http.MakeGET("/users/@me/notes/" + std::to_string(user_id), [this, callback](const http::response_type &response) { + if (response.status_code == 404) return; + if (!CheckCode(response)) return; + const auto note = nlohmann::json::parse(response.text).get().Note; + if (note.has_value()) + callback(*note); + }); +} + +void DiscordClient::SetUserNote(Snowflake user_id, std::string note) { + SetUserNote(user_id, note, [](auto) {}); +} + +void DiscordClient::SetUserNote(Snowflake user_id, std::string note, sigc::slot callback) { + UserSetNoteObject obj; + obj.Note = note; + m_http.MakePUT("/users/@me/notes/" + std::to_string(user_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response, 204)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::FetchUserRelationships(Snowflake user_id, sigc::slot)> callback) { + m_http.MakeGET("/users/" + std::to_string(user_id) + "/relationships", [this, callback](const http::response_type &response) { + if (!CheckCode(response)) return; + RelationshipsData data = nlohmann::json::parse(response.text); + for (const auto &user : data.Users) + m_store.SetUser(user.ID, user); + callback(data.Users); + }); +} + +bool DiscordClient::IsVerificationRequired(Snowflake guild_id) { + const auto member = GetMember(GetUserData().ID, guild_id); + if (member.has_value() && member->IsPending.has_value()) + return *member->IsPending; + return false; +} + +void DiscordClient::GetVerificationGateInfo(Snowflake guild_id, sigc::slot)> callback) { + m_http.MakeGET("/guilds/" + std::to_string(guild_id) + "/member-verification", [this, callback](const http::response_type &response) { + if (!CheckCode(response)) return; + if (response.status_code == 204) callback(std::nullopt); + callback(nlohmann::json::parse(response.text).get()); + }); +} + +void DiscordClient::AcceptVerificationGate(Snowflake guild_id, VerificationGateInfoObject info, sigc::slot callback) { + if (info.VerificationFields.has_value()) + for (auto &field : *info.VerificationFields) + field.Response = true; + m_http.MakePUT("/guilds/" + std::to_string(guild_id) + "/requests/@me", nlohmann::json(info).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); +} + +void DiscordClient::UpdateToken(std::string token) { + if (!IsStarted()) { + m_token = token; + m_http.SetAuth(token); + } +} + +void DiscordClient::SetUserAgent(std::string agent) { + m_http.SetUserAgent(agent); + m_websocket.SetUserAgent(agent); +} + +PresenceStatus DiscordClient::GetUserStatus(Snowflake id) const { + auto it = m_user_to_status.find(id); + if (it != m_user_to_status.end()) + return it->second; + + return PresenceStatus::Offline; +} + +std::map DiscordClient::GetRelationships() const { + return m_user_relationships; +} + +std::set DiscordClient::GetRelationships(RelationshipType type) const { + std::set ret; + for (const auto &[id, rtype] : m_user_relationships) + if (rtype == type) + ret.insert(id); + return ret; +} + +std::optional DiscordClient::GetRelationship(Snowflake id) const { + if (auto it = m_user_relationships.find(id); it != m_user_relationships.end()) + return it->second; + return std::nullopt; +} + +void DiscordClient::HandleGatewayMessageRaw(std::string str) { + // handles multiple zlib compressed messages, calling HandleGatewayMessage when a full message is received + std::vector buf(str.begin(), str.end()); + int len = static_cast(buf.size()); + bool has_suffix = buf[len - 4] == 0x00 && buf[len - 3] == 0x00 && buf[len - 2] == 0xFF && buf[len - 1] == 0xFF; + + m_compressed_buf.insert(m_compressed_buf.end(), buf.begin(), buf.end()); + + if (!has_suffix) return; + + m_zstream.next_in = m_compressed_buf.data(); + m_zstream.avail_in = static_cast(m_compressed_buf.size()); + m_zstream.total_in = m_zstream.total_out = 0; + + // loop in case of really big messages (e.g. READY) + while (true) { + m_zstream.next_out = m_decompress_buf.data() + m_zstream.total_out; + m_zstream.avail_out = static_cast(m_decompress_buf.size() - m_zstream.total_out); + + int err = inflate(&m_zstream, Z_SYNC_FLUSH); + if ((err == Z_OK || err == Z_BUF_ERROR) && m_zstream.avail_in > 0) { + m_decompress_buf.resize(m_decompress_buf.size() + InflateChunkSize); + } else { + if (err != Z_OK) { + fprintf(stderr, "Error decompressing input buffer %d (%d/%d)\n", err, m_zstream.avail_in, m_zstream.avail_out); + } else { + m_msg_mutex.lock(); + m_msg_queue.push(std::string(m_decompress_buf.begin(), m_decompress_buf.begin() + m_zstream.total_out)); + m_msg_dispatch.emit(); + m_msg_mutex.unlock(); + if (m_decompress_buf.size() > InflateChunkSize) + m_decompress_buf.resize(InflateChunkSize); + } + break; + } + } + + m_compressed_buf.clear(); +} + +void DiscordClient::MessageDispatch() { + m_msg_mutex.lock(); + auto msg = m_msg_queue.front(); + m_msg_queue.pop(); + m_msg_mutex.unlock(); + HandleGatewayMessage(msg); +} + +void DiscordClient::HandleGatewayMessage(std::string str) { + GatewayMessage m; + try { + m = nlohmann::json::parse(str); + } catch (std::exception &e) { + printf("Error decoding JSON. Discarding message: %s\n", e.what()); + return; + } + + if (m.Sequence != -1) + m_last_sequence = m.Sequence; + + try { + switch (m.Opcode) { + case GatewayOp::Hello: { + HandleGatewayHello(m); + } break; + case GatewayOp::HeartbeatAck: { + m_heartbeat_acked = true; + } break; + case GatewayOp::Reconnect: { + HandleGatewayReconnect(m); + } break; + case GatewayOp::InvalidSession: { + HandleGatewayInvalidSession(m); + } break; + case GatewayOp::Event: { + auto iter = m_event_map.find(m.Type); + if (iter == m_event_map.end()) { + printf("Unknown event %s\n", m.Type.c_str()); + break; + } + switch (iter->second) { + case GatewayEvent::READY: { + HandleGatewayReady(m); + } break; + case GatewayEvent::MESSAGE_CREATE: { + HandleGatewayMessageCreate(m); + } break; + case GatewayEvent::MESSAGE_DELETE: { + HandleGatewayMessageDelete(m); + } break; + case GatewayEvent::MESSAGE_UPDATE: { + HandleGatewayMessageUpdate(m); + } break; + case GatewayEvent::GUILD_MEMBER_LIST_UPDATE: { + HandleGatewayGuildMemberListUpdate(m); + } break; + case GatewayEvent::GUILD_CREATE: { + HandleGatewayGuildCreate(m); + } break; + case GatewayEvent::GUILD_DELETE: { + HandleGatewayGuildDelete(m); + } break; + case GatewayEvent::MESSAGE_DELETE_BULK: { + HandleGatewayMessageDeleteBulk(m); + } break; + case GatewayEvent::GUILD_MEMBER_UPDATE: { + HandleGatewayGuildMemberUpdate(m); + } break; + case GatewayEvent::PRESENCE_UPDATE: { + HandleGatewayPresenceUpdate(m); + } break; + case GatewayEvent::CHANNEL_DELETE: { + HandleGatewayChannelDelete(m); + } break; + case GatewayEvent::CHANNEL_UPDATE: { + HandleGatewayChannelUpdate(m); + } break; + case GatewayEvent::CHANNEL_CREATE: { + HandleGatewayChannelCreate(m); + } break; + case GatewayEvent::GUILD_UPDATE: { + HandleGatewayGuildUpdate(m); + } break; + case GatewayEvent::GUILD_ROLE_UPDATE: { + HandleGatewayGuildRoleUpdate(m); + } break; + case GatewayEvent::GUILD_ROLE_CREATE: { + HandleGatewayGuildRoleCreate(m); + } break; + case GatewayEvent::GUILD_ROLE_DELETE: { + HandleGatewayGuildRoleDelete(m); + } break; + case GatewayEvent::MESSAGE_REACTION_ADD: { + HandleGatewayMessageReactionAdd(m); + } break; + case GatewayEvent::MESSAGE_REACTION_REMOVE: { + HandleGatewayMessageReactionRemove(m); + } break; + case GatewayEvent::CHANNEL_RECIPIENT_ADD: { + HandleGatewayChannelRecipientAdd(m); + } break; + case GatewayEvent::CHANNEL_RECIPIENT_REMOVE: { + HandleGatewayChannelRecipientRemove(m); + } break; + case GatewayEvent::TYPING_START: { + HandleGatewayTypingStart(m); + } break; + case GatewayEvent::GUILD_BAN_REMOVE: { + HandleGatewayGuildBanRemove(m); + } break; + case GatewayEvent::GUILD_BAN_ADD: { + HandleGatewayGuildBanAdd(m); + } break; + case GatewayEvent::INVITE_CREATE: { + HandleGatewayInviteCreate(m); + } break; + case GatewayEvent::INVITE_DELETE: { + HandleGatewayInviteDelete(m); + } break; + case GatewayEvent::USER_NOTE_UPDATE: { + HandleGatewayUserNoteUpdate(m); + } break; + case GatewayEvent::READY_SUPPLEMENTAL: { + HandleGatewayReadySupplemental(m); + } break; + case GatewayEvent::GUILD_EMOJIS_UPDATE: { + HandleGatewayGuildEmojisUpdate(m); + } break; + case GatewayEvent::GUILD_JOIN_REQUEST_CREATE: { + HandleGatewayGuildJoinRequestCreate(m); + } break; + case GatewayEvent::GUILD_JOIN_REQUEST_UPDATE: { + HandleGatewayGuildJoinRequestUpdate(m); + } break; + case GatewayEvent::GUILD_JOIN_REQUEST_DELETE: { + HandleGatewayGuildJoinRequestDelete(m); + } break; + case GatewayEvent::RELATIONSHIP_REMOVE: { + HandleGatewayRelationshipRemove(m); + } break; + case GatewayEvent::RELATIONSHIP_ADD: { + HandleGatewayRelationshipAdd(m); + } break; + case GatewayEvent::THREAD_CREATE: { + HandleGatewayThreadCreate(m); + } break; + case GatewayEvent::THREAD_DELETE: { + HandleGatewayThreadDelete(m); + } break; + case GatewayEvent::THREAD_LIST_SYNC: { + HandleGatewayThreadListSync(m); + } break; + case GatewayEvent::THREAD_MEMBERS_UPDATE: { + HandleGatewayThreadMembersUpdate(m); + } break; + case GatewayEvent::THREAD_MEMBER_UPDATE: { + HandleGatewayThreadMemberUpdate(m); + } break; + case GatewayEvent::THREAD_UPDATE: { + HandleGatewayThreadUpdate(m); + } break; + case GatewayEvent::THREAD_MEMBER_LIST_UPDATE: { + HandleGatewayThreadMemberListUpdate(m); + } break; + } + } break; + default: + printf("Unknown opcode %d\n", static_cast(m.Opcode)); + break; + } + } catch (std::exception &e) { + fprintf(stderr, "error handling message (opcode %d): %s\n", static_cast(m.Opcode), e.what()); + } +} + +void DiscordClient::HandleGatewayHello(const GatewayMessage &msg) { + m_client_connected = true; + HelloMessageData d = msg.Data; + m_heartbeat_msec = d.HeartbeatInterval; + m_heartbeat_waiter.revive(); + m_heartbeat_thread = std::thread(std::bind(&DiscordClient::HeartbeatThread, this)); + m_signal_connected.emit(); // socket is connected before this but emitting here should b fine + m_reconnecting = false; // maybe should go elsewhere? + if (m_wants_resume) { + m_wants_resume = false; + SendResume(); + } else + SendIdentify(); +} + +// perhaps this should be set by the main class +std::string DiscordClient::GetAPIURL() { + static const auto url = Abaddon::Get().GetSettings().GetAPIBaseURL(); + return url; +} + +std::string DiscordClient::GetGatewayURL() { + static const auto url = Abaddon::Get().GetSettings().GetGatewayURL(); + return url; +} + +DiscordError DiscordClient::GetCodeFromResponse(const http::response_type &response) { + try { + const auto data = nlohmann::json::parse(response.text); + return data.at("code").get(); + } catch (...) {} + return DiscordError::GENERIC; +} + +void DiscordClient::ProcessNewGuild(GuildData &guild) { + if (guild.IsUnavailable) { + printf("guild (%" PRIu64 ") unavailable\n", static_cast(guild.ID)); + return; + } + + m_store.BeginTransaction(); + + m_store.SetGuild(guild.ID, guild); + if (guild.Channels.has_value()) { + for (auto &c : *guild.Channels) { + c.GuildID = guild.ID; + m_store.SetChannel(c.ID, c); + m_guild_to_channels[guild.ID].insert(c.ID); + for (auto &p : *c.PermissionOverwrites) { + m_store.SetPermissionOverwrite(c.ID, p.ID, p); + } + } + } + + if (guild.Threads.has_value()) { + for (auto &c : *guild.Threads) { + m_joined_threads.insert(c.ID); + c.GuildID = guild.ID; + m_store.SetChannel(c.ID, c); + } + } + + for (auto &r : *guild.Roles) + m_store.SetRole(guild.ID, r); + + for (auto &e : *guild.Emojis) + m_store.SetEmoji(e.ID, e); + + m_store.EndTransaction(); +} + +void DiscordClient::HandleGatewayReady(const GatewayMessage &msg) { + m_ready_received = true; + ReadyEventData data = msg.Data; + for (auto &g : data.Guilds) + ProcessNewGuild(g); + + m_store.BeginTransaction(); + + for (const auto &dm : data.PrivateChannels) { + m_store.SetChannel(dm.ID, dm); + if (dm.Recipients.has_value()) + for (const auto &recipient : *dm.Recipients) + m_store.SetUser(recipient.ID, recipient); + } + + if (data.Users.has_value()) + for (const auto &user : *data.Users) + m_store.SetUser(user.ID, user); + + if (data.MergedMembers.has_value()) { + for (size_t i = 0; i < data.MergedMembers->size(); i++) { + const auto guild_id = data.Guilds[i].ID; + for (const auto &member : data.MergedMembers.value()[i]) { + m_store.SetGuildMember(guild_id, *member.UserID, member); + } + } + } + + if (data.Relationships.has_value()) + for (const auto &relationship : *data.Relationships) + m_user_relationships[relationship.ID] = relationship.Type; + + if (data.GuildJoinRequests.has_value()) + for (const auto &request : *data.GuildJoinRequests) + m_guild_join_requests[request.GuildID] = request; + + m_store.EndTransaction(); + + m_session_id = data.SessionID; + m_user_data = data.SelfUser; + m_user_settings = data.Settings; + m_signal_gateway_ready.emit(); +} + +void DiscordClient::HandleGatewayMessageCreate(const GatewayMessage &msg) { + Message data = msg.Data; + StoreMessageData(data); + if (data.GuildID.has_value()) + AddUserToGuild(data.Author.ID, *data.GuildID); + m_signal_message_create.emit(data); +} + +void DiscordClient::HandleGatewayMessageDelete(const GatewayMessage &msg) { + MessageDeleteData data = msg.Data; + auto cur = m_store.GetMessage(data.ID); + if (!cur.has_value()) + return; + + cur->SetDeleted(); + m_store.SetMessage(data.ID, *cur); + m_signal_message_delete.emit(data.ID, data.ChannelID); +} + +void DiscordClient::HandleGatewayMessageDeleteBulk(const GatewayMessage &msg) { + MessageDeleteBulkData data = msg.Data; + m_store.BeginTransaction(); + for (const auto &id : data.IDs) { + auto cur = m_store.GetMessage(id); + if (!cur.has_value()) + continue; + + cur->SetDeleted(); + m_store.SetMessage(id, *cur); + m_signal_message_delete.emit(id, data.ChannelID); + } + m_store.EndTransaction(); +} + +void DiscordClient::HandleGatewayGuildMemberUpdate(const GatewayMessage &msg) { + GuildMemberUpdateMessage data = msg.Data; + auto cur = m_store.GetGuildMember(data.GuildID, data.User.ID); + if (cur.has_value()) { + cur->update_from_json(msg.Data); + m_store.SetGuildMember(data.GuildID, data.User.ID, *cur); + } + m_signal_guild_member_update.emit(data.GuildID, data.User.ID); +} + +void DiscordClient::HandleGatewayPresenceUpdate(const GatewayMessage &msg) { + PresenceUpdateMessage data = msg.Data; + const auto user_id = data.User.at("id").get(); + + auto cur = m_store.GetUser(user_id); + if (cur.has_value()) { + cur->update_from_json(data.User); + m_store.SetUser(cur->ID, *cur); + } else + return; + + PresenceStatus e; + if (data.StatusMessage == "online") + e = PresenceStatus::Online; + else if (data.StatusMessage == "offline") + e = PresenceStatus::Offline; + else if (data.StatusMessage == "idle") + e = PresenceStatus::Idle; + else if (data.StatusMessage == "dnd") + e = PresenceStatus::DND; + + m_user_to_status[user_id] = e; + + m_signal_presence_update.emit(*cur, e); +} + +void DiscordClient::HandleGatewayChannelDelete(const GatewayMessage &msg) { + const auto id = msg.Data.at("id").get(); + const auto channel = GetChannel(id); + auto it = m_guild_to_channels.find(*channel->GuildID); + if (it != m_guild_to_channels.end()) + it->second.erase(id); + m_store.ClearChannel(id); + m_signal_channel_delete.emit(id); +} + +void DiscordClient::HandleGatewayChannelUpdate(const GatewayMessage &msg) { + const auto id = msg.Data.at("id").get(); + auto cur = m_store.GetChannel(id); + if (cur.has_value()) { + cur->update_from_json(msg.Data); + m_store.SetChannel(id, *cur); + if (cur->PermissionOverwrites.has_value()) + for (const auto &p : *cur->PermissionOverwrites) + m_store.SetPermissionOverwrite(id, p.ID, p); + m_signal_channel_update.emit(id); + } +} + +void DiscordClient::HandleGatewayChannelCreate(const GatewayMessage &msg) { + ChannelData data = msg.Data; + m_store.BeginTransaction(); + m_store.SetChannel(data.ID, data); + m_guild_to_channels[*data.GuildID].insert(data.ID); + if (data.PermissionOverwrites.has_value()) + for (const auto &p : *data.PermissionOverwrites) + m_store.SetPermissionOverwrite(data.ID, p.ID, p); + m_store.EndTransaction(); + m_signal_channel_create.emit(data); +} + +void DiscordClient::HandleGatewayGuildUpdate(const GatewayMessage &msg) { + Snowflake id = msg.Data.at("id"); + auto current = m_store.GetGuild(id); + if (!current.has_value()) return; + current->update_from_json(msg.Data); + m_store.SetGuild(id, *current); + m_signal_guild_update.emit(id); +} + +void DiscordClient::HandleGatewayGuildRoleUpdate(const GatewayMessage &msg) { + GuildRoleUpdateObject data = msg.Data; + m_store.SetRole(data.GuildID, data.Role); + m_signal_role_update.emit(data.GuildID, data.Role.ID); +} + +void DiscordClient::HandleGatewayGuildRoleCreate(const GatewayMessage &msg) { + GuildRoleCreateObject data = msg.Data; + auto guild = *m_store.GetGuild(data.GuildID); + guild.Roles->push_back(data.Role); + m_store.BeginTransaction(); + m_store.SetRole(guild.ID, data.Role); + m_store.SetGuild(guild.ID, guild); + m_store.EndTransaction(); + m_signal_role_create.emit(data.GuildID, data.Role.ID); +} + +void DiscordClient::HandleGatewayGuildRoleDelete(const GatewayMessage &msg) { + GuildRoleDeleteObject data = msg.Data; + auto guild = *m_store.GetGuild(data.GuildID); + const auto pred = [this, id = data.RoleID](const RoleData &role) -> bool { + return role.ID == id; + }; + guild.Roles->erase(std::remove_if(guild.Roles->begin(), guild.Roles->end(), pred), guild.Roles->end()); + m_store.SetGuild(guild.ID, guild); + m_signal_role_delete.emit(data.GuildID, data.RoleID); +} + +void DiscordClient::HandleGatewayMessageReactionAdd(const GatewayMessage &msg) { + MessageReactionAddObject data = msg.Data; + + m_store.AddReaction(data, data.UserID == GetUserData().ID); + if (data.Emoji.ID.IsValid()) + m_signal_reaction_add.emit(data.MessageID, std::to_string(data.Emoji.ID)); + else + m_signal_reaction_add.emit(data.MessageID, data.Emoji.Name); +} + +void DiscordClient::HandleGatewayMessageReactionRemove(const GatewayMessage &msg) { + MessageReactionRemoveObject data = msg.Data; + + m_store.RemoveReaction(data, data.UserID == GetUserData().ID); + if (data.Emoji.ID.IsValid()) + m_signal_reaction_remove.emit(data.MessageID, std::to_string(data.Emoji.ID)); + else + m_signal_reaction_remove.emit(data.MessageID, data.Emoji.Name); +} + +// todo: update channel list item and member list +void DiscordClient::HandleGatewayChannelRecipientAdd(const GatewayMessage &msg) { + ChannelRecipientAdd data = msg.Data; + auto cur = m_store.GetChannel(data.ChannelID); + if (!cur.has_value() || !cur->RecipientIDs.has_value()) return; + if (std::find(cur->RecipientIDs->begin(), cur->RecipientIDs->end(), data.User.ID) == cur->RecipientIDs->end()) + cur->RecipientIDs->push_back(data.User.ID); + m_store.SetUser(data.User.ID, data.User); + m_store.SetChannel(cur->ID, *cur); +} + +void DiscordClient::HandleGatewayChannelRecipientRemove(const GatewayMessage &msg) { + ChannelRecipientRemove data = msg.Data; + m_store.ClearRecipient(data.ChannelID, data.User.ID); +} + +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::HandleGatewayGuildBanRemove(const GatewayMessage &msg) { + GuildBanRemoveObject data = msg.Data; + m_store.SetUser(data.User.ID, data.User); + m_store.ClearBan(data.GuildID, data.User.ID); + m_signal_guild_ban_remove.emit(data.GuildID, data.User.ID); +} + +void DiscordClient::HandleGatewayGuildBanAdd(const GatewayMessage &msg) { + GuildBanAddObject data = msg.Data; + BanData ban; + ban.Reason = ""; + ban.User = data.User; + m_store.SetUser(data.User.ID, data.User); + m_store.SetBan(data.GuildID, data.User.ID, ban); + m_signal_guild_ban_add.emit(data.GuildID, data.User.ID); +} + +void DiscordClient::HandleGatewayInviteCreate(const GatewayMessage &msg) { + InviteCreateObject data = msg.Data; + InviteData invite; + invite.Code = std::move(data.Code); + invite.CreatedAt = std::move(data.CreatedAt); + invite.Channel = *m_store.GetChannel(data.ChannelID); + invite.Inviter = std::move(data.Inviter); + invite.IsTemporary = std::move(data.IsTemporary); + invite.MaxAge = std::move(data.MaxAge); + invite.MaxUses = std::move(data.MaxUses); + invite.TargetUser = std::move(data.TargetUser); + invite.TargetUserType = std::move(data.TargetUserType); + invite.Uses = std::move(data.Uses); + if (data.GuildID.has_value()) + invite.Guild = m_store.GetGuild(*data.GuildID); + m_signal_invite_create.emit(invite); +} + +void DiscordClient::HandleGatewayInviteDelete(const GatewayMessage &msg) { + InviteDeleteObject data = msg.Data; + if (!data.GuildID.has_value()) { + const auto chan = GetChannel(data.ChannelID); + data.GuildID = chan->ID; + } + m_signal_invite_delete.emit(data); +} + +void DiscordClient::HandleGatewayUserNoteUpdate(const GatewayMessage &msg) { + UserNoteUpdateMessage data = msg.Data; + m_signal_note_update.emit(data.ID, data.Note); +} + +void DiscordClient::HandleGatewayGuildEmojisUpdate(const GatewayMessage &msg) { + // like the real client, the emoji data sent in this message is ignored + // we just use it as a signal to re-request all emojis + GuildEmojisUpdateObject data = msg.Data; + const auto cb = [this, id = data.GuildID](const std::vector &emojis) { + m_store.BeginTransaction(); + for (const auto &emoji : emojis) + m_store.SetEmoji(emoji.ID, emoji); + m_store.EndTransaction(); + m_signal_guild_emojis_update.emit(id, emojis); + }; + FetchGuildEmojis(data.GuildID, cb); +} + +void DiscordClient::HandleGatewayGuildJoinRequestCreate(const GatewayMessage &msg) { + GuildJoinRequestCreateData data = msg.Data; + m_guild_join_requests[data.GuildID] = data.Request; + m_signal_guild_join_request_create.emit(data); +} + +void DiscordClient::HandleGatewayGuildJoinRequestUpdate(const GatewayMessage &msg) { + GuildJoinRequestUpdateData data = msg.Data; + m_guild_join_requests[data.GuildID] = data.Request; + m_signal_guild_join_request_update.emit(data); +} + +void DiscordClient::HandleGatewayGuildJoinRequestDelete(const GatewayMessage &msg) { + GuildJoinRequestDeleteData data = msg.Data; + m_guild_join_requests.erase(data.GuildID); + m_signal_guild_join_request_delete.emit(data); +} + +void DiscordClient::HandleGatewayRelationshipRemove(const GatewayMessage &msg) { + RelationshipRemoveData data = msg.Data; + m_user_relationships.erase(data.ID); + m_signal_relationship_remove.emit(data.ID, data.Type); +} + +void DiscordClient::HandleGatewayRelationshipAdd(const GatewayMessage &msg) { + RelationshipAddData data = msg.Data; + m_store.SetUser(data.ID, data.User); + m_user_relationships[data.ID] = data.Type; + m_signal_relationship_add.emit(std::move(data)); +} + +// remarkably this doesnt actually mean a thread was created +// it can also mean you gained access to a thread. yay ... +// except sometimes it doesnt??? i dont know whats going on +void DiscordClient::HandleGatewayThreadCreate(const GatewayMessage &msg) { + ThreadCreateData data = msg.Data; + m_store.SetChannel(data.Channel.ID, data.Channel); + m_signal_thread_create.emit(data.Channel); + if (data.Channel.ThreadMember.has_value()) { + m_signal_added_to_thread.emit(data.Channel.ID); + } +} + +void DiscordClient::HandleGatewayThreadDelete(const GatewayMessage &msg) { + ThreadDeleteData data = msg.Data; + m_store.ClearChannel(data.ID); + m_signal_thread_delete.emit(data); +} + +// this message is received when you load a channel as part of the lazy load request +// so the ui will only update thread when you load a channel in some guild +// which is rather annoying but oh well +void DiscordClient::HandleGatewayThreadListSync(const GatewayMessage &msg) { + ThreadListSyncData data = msg.Data; + for (const auto &thread : data.Threads) + m_store.SetChannel(thread.ID, thread); + m_signal_thread_list_sync.emit(data); +} + +void DiscordClient::HandleGatewayThreadMembersUpdate(const GatewayMessage &msg) { + ThreadMembersUpdateData data = msg.Data; + if (data.AddedMembers.has_value() && + std::find_if(data.AddedMembers->begin(), data.AddedMembers->end(), [this](const auto &x) { + return *x.UserID == m_user_data.ID; // safe to assume UserID is present here + }) != data.AddedMembers->end()) { + m_joined_threads.insert(data.ID); + m_signal_added_to_thread.emit(data.ID); + } else if (data.RemovedMemberIDs.has_value() && + std::find(data.RemovedMemberIDs->begin(), data.RemovedMemberIDs->end(), m_user_data.ID) != data.RemovedMemberIDs->end()) { + m_joined_threads.erase(data.ID); + m_signal_removed_from_thread.emit(data.ID); + } + m_signal_thread_members_update.emit(data); +} + +void DiscordClient::HandleGatewayThreadMemberUpdate(const GatewayMessage &msg) { + ThreadMemberUpdateData data = msg.Data; + m_joined_threads.insert(*data.Member.ThreadID); + if (*data.Member.UserID == GetUserData().ID) + m_signal_added_to_thread.emit(*data.Member.ThreadID); +} + +void DiscordClient::HandleGatewayThreadUpdate(const GatewayMessage &msg) { + ThreadUpdateData data = msg.Data; + m_store.SetChannel(data.Thread.ID, data.Thread); + m_signal_thread_update.emit(data); +} + +void DiscordClient::HandleGatewayThreadMemberListUpdate(const GatewayMessage &msg) { + ThreadMemberListUpdateData data = msg.Data; + m_store.BeginTransaction(); + for (const auto &entry : data.Members) { + m_thread_members[data.ThreadID].push_back(entry.UserID); + if (entry.Member.User.has_value()) + m_store.SetUser(entry.Member.User->ID, *entry.Member.User); + m_store.SetGuildMember(data.GuildID, entry.Member.User->ID, entry.Member); + } + m_store.EndTransaction(); + m_signal_thread_member_list_update.emit(data); +} + +void DiscordClient::HandleGatewayReadySupplemental(const GatewayMessage &msg) { + ReadySupplementalData data = msg.Data; + for (const auto &p : data.MergedPresences.Friends) { + const auto user = GetUser(p.UserID); + if (!user.has_value()) return; // should be sent in READY's `users` + const auto s = p.Presence.Status; + if (s == "online") + m_user_to_status[p.UserID] = PresenceStatus::Online; + else if (s == "offline") + m_user_to_status[p.UserID] = PresenceStatus::Offline; + else if (s == "idle") + m_user_to_status[p.UserID] = PresenceStatus::Idle; + else if (s == "dnd") + m_user_to_status[p.UserID] = PresenceStatus::DND; + m_signal_presence_update.emit(*user, m_user_to_status.at(p.UserID)); + } +} + +void DiscordClient::HandleGatewayReconnect(const GatewayMessage &msg) { + printf("received reconnect\n"); + inflateEnd(&m_zstream); + m_compressed_buf.clear(); + + m_heartbeat_waiter.kill(); + if (m_heartbeat_thread.joinable()) m_heartbeat_thread.join(); + + m_reconnecting = true; + m_wants_resume = true; + m_heartbeat_acked = true; + + m_websocket.Stop(1012); // 1000 (kNormalClosureCode) and 1001 will invalidate the session id + + std::memset(&m_zstream, 0, sizeof(m_zstream)); + inflateInit2(&m_zstream, MAX_WBITS + 32); + + m_websocket.StartConnection(GetGatewayURL()); +} + +void DiscordClient::HandleGatewayInvalidSession(const GatewayMessage &msg) { + printf("invalid session! re-identifying\n"); + + inflateEnd(&m_zstream); + m_compressed_buf.clear(); + + std::memset(&m_zstream, 0, sizeof(m_zstream)); + inflateInit2(&m_zstream, MAX_WBITS + 32); + + m_heartbeat_acked = true; + m_wants_resume = false; + m_reconnecting = true; + + m_heartbeat_waiter.kill(); + if (m_heartbeat_thread.joinable()) m_heartbeat_thread.join(); + + m_websocket.Stop(1000); + + if (m_client_started) + Glib::signal_timeout().connect_once([this] { if (m_client_started) m_websocket.StartConnection(GetGatewayURL()); }, 1000); +} + +bool IsCompleteMessageObject(const nlohmann::json &j) { + const auto required = { "id", "channel_id", "author", "content", "timestamp", "edited_timestamp", "tts", "mention_everyone", "mention_roles", "attachments", "embeds", "pinned", "type" }; + for (const auto &str : required) { + if (!j.contains(str)) return false; + } + return true; +} + +void DiscordClient::HandleGatewayMessageUpdate(const GatewayMessage &msg) { + Snowflake id = msg.Data.at("id"); + + auto current = m_store.GetMessage(id); + if (!current.has_value()) { + // im not sure how the client determines if a MESSAGE_UPDATE is suitable to be stored as a full message but i guess its something like this + if (IsCompleteMessageObject(msg.Data)) { + current = msg.Data; + m_store.SetMessage(id, *current); + // this doesnt mean a message is newly pinned when called here + // it just means theres an (old) message that the client is now aware of that is also pinned + m_signal_message_pinned.emit(*current); + } else + return; + } else { + const bool old_pinned = current->IsPinned; + + current->from_json_edited(msg.Data); + m_store.SetMessage(id, *current); + + if (old_pinned && !current->IsPinned) + m_signal_message_unpinned.emit(*current); + else if (!old_pinned && current->IsPinned) + m_signal_message_pinned.emit(*current); + } + + m_signal_message_update.emit(id, current->ChannelID); +} + +void DiscordClient::HandleGatewayGuildMemberListUpdate(const GatewayMessage &msg) { + GuildMemberListUpdateMessage data = msg.Data; + + m_store.BeginTransaction(); + + bool has_sync = false; + for (const auto &op : data.Ops) { + if (op.Op == "SYNC") { + has_sync = true; + for (const auto &item : *op.Items) { + if (item->Type == "member") { + auto member = static_cast(item.get()); + m_store.SetUser(member->User.ID, member->User); + AddUserToGuild(member->User.ID, data.GuildID); + m_store.SetGuildMember(data.GuildID, member->User.ID, member->GetAsMemberData()); + if (member->Presence.has_value()) { + const auto &s = member->Presence->Status; + if (s == "online") + m_user_to_status[member->User.ID] = PresenceStatus::Online; + else if (s == "offline") + m_user_to_status[member->User.ID] = PresenceStatus::Offline; + else if (s == "idle") + m_user_to_status[member->User.ID] = PresenceStatus::Idle; + else if (s == "dnd") + m_user_to_status[member->User.ID] = PresenceStatus::DND; + } + } + } + } else if (op.Op == "UPDATE") { + if (op.OpItem.has_value() && op.OpItem.value()->Type == "member") { + const auto &m = static_cast(op.OpItem.value().get())->GetAsMemberData(); + m_store.SetGuildMember(data.GuildID, m.User->ID, m); + m_signal_guild_member_update.emit(data.GuildID, m.User->ID); // cheeky + } + } + } + + m_store.EndTransaction(); + + // todo: manage this event a little better + if (has_sync) + m_signal_guild_member_list_update.emit(data.GuildID); +} + +void DiscordClient::HandleGatewayGuildCreate(const GatewayMessage &msg) { + GuildData data = msg.Data; + ProcessNewGuild(data); + + m_signal_guild_create.emit(data); +} + +void DiscordClient::HandleGatewayGuildDelete(const GatewayMessage &msg) { + Snowflake id = msg.Data.at("id"); + bool unavailable = msg.Data.contains("unavilable") && msg.Data.at("unavailable").get(); + + if (unavailable) + printf("guild %" PRIu64 " became unavailable\n", static_cast(id)); + + const auto guild = m_store.GetGuild(id); + if (!guild.has_value()) { + m_store.ClearGuild(id); + m_signal_guild_delete.emit(id); + return; + } + + m_store.ClearGuild(id); + if (guild->Channels.has_value()) + for (const auto &c : *guild->Channels) + m_store.ClearChannel(c.ID); + + m_signal_guild_delete.emit(id); +} + +void DiscordClient::AddUserToGuild(Snowflake user_id, Snowflake guild_id) { + m_guild_to_users[guild_id].insert(user_id); +} + +std::set DiscordClient::GetPrivateChannels() const { + auto ret = std::set(); + + for (const auto &id : m_store.GetChannels()) { + const auto chan = m_store.GetChannel(id); + if (chan->Type == ChannelType::DM || chan->Type == ChannelType::GROUP_DM) + ret.insert(id); + } + + return ret; +} + +EPremiumType DiscordClient::GetSelfPremiumType() const { + const auto &data = GetUserData(); + if (data.PremiumType.has_value()) + return *data.PremiumType; + return EPremiumType::None; +} + +void DiscordClient::HeartbeatThread() { + while (m_client_connected) { + if (!m_heartbeat_acked) { + printf("wow! a heartbeat wasn't acked! how could this happen?"); + } + + m_heartbeat_acked = false; + + HeartbeatMessage msg; + msg.Sequence = m_last_sequence; + nlohmann::json j = msg; + m_websocket.Send(j); + + if (!m_heartbeat_waiter.wait_for(std::chrono::milliseconds(m_heartbeat_msec))) + break; + } +} + +void DiscordClient::SendIdentify() { + IdentifyMessage msg; + msg.Token = m_token; + msg.Capabilities = 125; // no idea what this is + msg.Properties.OS = "Windows"; + msg.Properties.Browser = "Chrome"; + msg.Properties.Device = ""; + msg.Properties.SystemLocale = "en-US"; + msg.Properties.BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"; + msg.Properties.BrowserVersion = "67.0.3396.87"; + msg.Properties.OSVersion = "10"; + msg.Properties.Referrer = ""; + msg.Properties.ReferringDomain = ""; + msg.Properties.ReferrerCurrent = ""; + msg.Properties.ReferringDomainCurrent = ""; + msg.Properties.ReleaseChannel = "stable"; + msg.Properties.ClientBuildNumber = 91734; + msg.Properties.ClientEventSource = ""; + msg.Presence.Status = "online"; + msg.Presence.Since = 0; + msg.Presence.IsAFK = false; + msg.DoesSupportCompression = false; + msg.ClientState.HighestLastMessageID = "0"; + msg.ClientState.ReadStateVersion = 0; + msg.ClientState.UserGuildSettingsVersion = -1; + m_websocket.Send(msg); +} + +void DiscordClient::SendResume() { + ResumeMessage msg; + msg.Sequence = m_last_sequence; + msg.SessionID = m_session_id; + msg.Token = m_token; + m_websocket.Send(msg); +} + +void DiscordClient::HandleSocketOpen() { +} + +void DiscordClient::HandleSocketClose(uint16_t code) { + printf("got socket close code: %d\n", code); + auto close_code = static_cast(code); + auto cb = [this, close_code]() { + m_heartbeat_waiter.kill(); + if (m_heartbeat_thread.joinable()) m_heartbeat_thread.join(); + m_client_connected = false; + + if (m_client_started && !m_reconnecting && close_code == GatewayCloseCode::Abnormal) { + Glib::signal_timeout().connect_once([this] { if (m_client_started) HandleGatewayReconnect(GatewayMessage()); }, 1000); + m_reconnecting = true; + } + + m_signal_disconnected.emit(m_reconnecting, close_code); + }; + m_generic_mutex.lock(); + m_generic_queue.push(cb); + m_generic_dispatch.emit(); + m_generic_mutex.unlock(); +} + +bool DiscordClient::CheckCode(const http::response_type &r) { + if (r.status_code >= 300 || r.error) { + fprintf(stderr, "api request to %s failed with status code %d: %s\n", r.url.c_str(), r.status_code, r.error_string.c_str()); + return false; + } + + return true; +} + +bool DiscordClient::CheckCode(const http::response_type &r, int expected) { + if (!CheckCode(r)) return false; + if (r.status_code != expected) { + fprintf(stderr, "api request to %s returned %d, expected %d\n", r.url.c_str(), r.status_code, expected); + return false; + } + return true; +} + +void DiscordClient::StoreMessageData(Message &msg) { + const auto chan = m_store.GetChannel(msg.ChannelID); + if (chan.has_value() && chan->GuildID.has_value()) + msg.GuildID = *chan->GuildID; + + m_store.BeginTransaction(); + + m_store.SetMessage(msg.ID, msg); + m_store.SetUser(msg.Author.ID, msg.Author); + if (msg.Reactions.has_value()) + for (const auto &r : *msg.Reactions) { + if (!r.Emoji.ID.IsValid()) continue; + const auto cur = m_store.GetEmoji(r.Emoji.ID); + if (!cur.has_value()) + m_store.SetEmoji(r.Emoji.ID, r.Emoji); + } + + for (const auto &user : msg.Mentions) + m_store.SetUser(user.ID, user); + + if (msg.Member.has_value()) + m_store.SetGuildMember(*msg.GuildID, msg.Author.ID, *msg.Member); + + if (msg.Interaction.has_value() && msg.Interaction->Member.has_value()) { + m_store.SetUser(msg.Interaction->User.ID, msg.Interaction->User); + m_store.SetGuildMember(*msg.GuildID, msg.Interaction->User.ID, *msg.Interaction->Member); + } + + m_store.EndTransaction(); + + if (msg.ReferencedMessage.has_value() && msg.MessageReference.has_value() && msg.MessageReference->ChannelID.has_value()) + if (msg.ReferencedMessage.value() != nullptr) + StoreMessageData(**msg.ReferencedMessage); +} + +void DiscordClient::LoadEventMap() { + m_event_map["READY"] = GatewayEvent::READY; + m_event_map["MESSAGE_CREATE"] = GatewayEvent::MESSAGE_CREATE; + m_event_map["MESSAGE_DELETE"] = GatewayEvent::MESSAGE_DELETE; + m_event_map["MESSAGE_UPDATE"] = GatewayEvent::MESSAGE_UPDATE; + m_event_map["GUILD_MEMBER_LIST_UPDATE"] = GatewayEvent::GUILD_MEMBER_LIST_UPDATE; + m_event_map["GUILD_CREATE"] = GatewayEvent::GUILD_CREATE; + m_event_map["GUILD_DELETE"] = GatewayEvent::GUILD_DELETE; + m_event_map["MESSAGE_DELETE_BULK"] = GatewayEvent::MESSAGE_DELETE_BULK; + m_event_map["GUILD_MEMBER_UPDATE"] = GatewayEvent::GUILD_MEMBER_UPDATE; + m_event_map["PRESENCE_UPDATE"] = GatewayEvent::PRESENCE_UPDATE; + m_event_map["CHANNEL_DELETE"] = GatewayEvent::CHANNEL_DELETE; + m_event_map["CHANNEL_UPDATE"] = GatewayEvent::CHANNEL_UPDATE; + m_event_map["CHANNEL_CREATE"] = GatewayEvent::CHANNEL_CREATE; + m_event_map["GUILD_UPDATE"] = GatewayEvent::GUILD_UPDATE; + m_event_map["GUILD_ROLE_UPDATE"] = GatewayEvent::GUILD_ROLE_UPDATE; + m_event_map["GUILD_ROLE_CREATE"] = GatewayEvent::GUILD_ROLE_CREATE; + m_event_map["GUILD_ROLE_DELETE"] = GatewayEvent::GUILD_ROLE_DELETE; + m_event_map["MESSAGE_REACTION_ADD"] = GatewayEvent::MESSAGE_REACTION_ADD; + m_event_map["MESSAGE_REACTION_REMOVE"] = GatewayEvent::MESSAGE_REACTION_REMOVE; + m_event_map["CHANNEL_RECIPIENT_ADD"] = GatewayEvent::CHANNEL_RECIPIENT_ADD; + m_event_map["CHANNEL_RECIPIENT_REMOVE"] = GatewayEvent::CHANNEL_RECIPIENT_REMOVE; + m_event_map["TYPING_START"] = GatewayEvent::TYPING_START; + m_event_map["GUILD_BAN_REMOVE"] = GatewayEvent::GUILD_BAN_REMOVE; + m_event_map["GUILD_BAN_ADD"] = GatewayEvent::GUILD_BAN_ADD; + m_event_map["INVITE_CREATE"] = GatewayEvent::INVITE_CREATE; + m_event_map["INVITE_DELETE"] = GatewayEvent::INVITE_DELETE; + m_event_map["USER_NOTE_UPDATE"] = GatewayEvent::USER_NOTE_UPDATE; + m_event_map["READY_SUPPLEMENTAL"] = GatewayEvent::READY_SUPPLEMENTAL; + m_event_map["GUILD_EMOJIS_UPDATE"] = GatewayEvent::GUILD_EMOJIS_UPDATE; + m_event_map["GUILD_JOIN_REQUEST_CREATE"] = GatewayEvent::GUILD_JOIN_REQUEST_CREATE; + m_event_map["GUILD_JOIN_REQUEST_UPDATE"] = GatewayEvent::GUILD_JOIN_REQUEST_UPDATE; + m_event_map["GUILD_JOIN_REQUEST_DELETE"] = GatewayEvent::GUILD_JOIN_REQUEST_DELETE; + m_event_map["RELATIONSHIP_REMOVE"] = GatewayEvent::RELATIONSHIP_REMOVE; + m_event_map["RELATIONSHIP_ADD"] = GatewayEvent::RELATIONSHIP_ADD; + m_event_map["THREAD_CREATE"] = GatewayEvent::THREAD_CREATE; + m_event_map["THREAD_DELETE"] = GatewayEvent::THREAD_DELETE; + m_event_map["THREAD_LIST_SYNC"] = GatewayEvent::THREAD_LIST_SYNC; + m_event_map["THREAD_MEMBERS_UPDATE"] = GatewayEvent::THREAD_MEMBERS_UPDATE; + m_event_map["THREAD_MEMBER_UPDATE"] = GatewayEvent::THREAD_MEMBER_UPDATE; + m_event_map["THREAD_UPDATE"] = GatewayEvent::THREAD_UPDATE; + m_event_map["THREAD_MEMBER_LIST_UPDATE"] = GatewayEvent::THREAD_MEMBER_LIST_UPDATE; +} + +DiscordClient::type_signal_gateway_ready DiscordClient::signal_gateway_ready() { + return m_signal_gateway_ready; +} + +DiscordClient::type_signal_message_create DiscordClient::signal_message_create() { + return m_signal_message_create; +} + +DiscordClient::type_signal_message_delete DiscordClient::signal_message_delete() { + return m_signal_message_delete; +} + +DiscordClient::type_signal_message_update DiscordClient::signal_message_update() { + return m_signal_message_update; +} + +DiscordClient::type_signal_guild_member_list_update DiscordClient::signal_guild_member_list_update() { + return m_signal_guild_member_list_update; +} + +DiscordClient::type_signal_guild_create DiscordClient::signal_guild_create() { + return m_signal_guild_create; +} + +DiscordClient::type_signal_guild_delete DiscordClient::signal_guild_delete() { + return m_signal_guild_delete; +} + +DiscordClient::type_signal_channel_delete DiscordClient::signal_channel_delete() { + return m_signal_channel_delete; +} + +DiscordClient::type_signal_channel_update DiscordClient::signal_channel_update() { + return m_signal_channel_update; +} + +DiscordClient::type_signal_channel_create DiscordClient::signal_channel_create() { + return m_signal_channel_create; +} + +DiscordClient::type_signal_guild_update DiscordClient::signal_guild_update() { + return m_signal_guild_update; +} + +DiscordClient::type_signal_disconnected DiscordClient::signal_disconnected() { + return m_signal_disconnected; +} + +DiscordClient::type_signal_connected DiscordClient::signal_connected() { + return m_signal_connected; +} + +DiscordClient::type_signal_role_update DiscordClient::signal_role_update() { + return m_signal_role_update; +} + +DiscordClient::type_signal_role_create DiscordClient::signal_role_create() { + return m_signal_role_create; +} + +DiscordClient::type_signal_role_delete DiscordClient::signal_role_delete() { + return m_signal_role_delete; +} + +DiscordClient::type_signal_reaction_add DiscordClient::signal_reaction_add() { + return m_signal_reaction_add; +} + +DiscordClient::type_signal_reaction_remove DiscordClient::signal_reaction_remove() { + return m_signal_reaction_remove; +} + +DiscordClient::type_signal_typing_start DiscordClient::signal_typing_start() { + return m_signal_typing_start; +} + +DiscordClient::type_signal_guild_member_update DiscordClient::signal_guild_member_update() { + return m_signal_guild_member_update; +} + +DiscordClient::type_signal_guild_ban_remove DiscordClient::signal_guild_ban_remove() { + return m_signal_guild_ban_remove; +} + +DiscordClient::type_signal_guild_ban_add DiscordClient::signal_guild_ban_add() { + return m_signal_guild_ban_add; +} + +DiscordClient::type_signal_invite_create DiscordClient::signal_invite_create() { + return m_signal_invite_create; +} + +DiscordClient::type_signal_invite_delete DiscordClient::signal_invite_delete() { + return m_signal_invite_delete; +} + +DiscordClient::type_signal_presence_update DiscordClient::signal_presence_update() { + return m_signal_presence_update; +} + +DiscordClient::type_signal_note_update DiscordClient::signal_note_update() { + return m_signal_note_update; +} + +DiscordClient::type_signal_guild_emojis_update DiscordClient::signal_guild_emojis_update() { + return m_signal_guild_emojis_update; +} + +DiscordClient::type_signal_guild_join_request_create DiscordClient::signal_guild_join_request_create() { + return m_signal_guild_join_request_create; +} + +DiscordClient::type_signal_guild_join_request_update DiscordClient::signal_guild_join_request_update() { + return m_signal_guild_join_request_update; +} + +DiscordClient::type_signal_guild_join_request_delete DiscordClient::signal_guild_join_request_delete() { + return m_signal_guild_join_request_delete; +} + +DiscordClient::type_signal_relationship_remove DiscordClient::signal_relationship_remove() { + return m_signal_relationship_remove; +} + +DiscordClient::type_signal_relationship_add DiscordClient::signal_relationship_add() { + return m_signal_relationship_add; +} + +DiscordClient::type_signal_message_unpinned DiscordClient::signal_message_unpinned() { + return m_signal_message_unpinned; +} + +DiscordClient::type_signal_message_pinned DiscordClient::signal_message_pinned() { + return m_signal_message_pinned; +} + +DiscordClient::type_signal_thread_create DiscordClient::signal_thread_create() { + return m_signal_thread_create; +} + +DiscordClient::type_signal_thread_delete DiscordClient::signal_thread_delete() { + return m_signal_thread_delete; +} + +DiscordClient::type_signal_thread_list_sync DiscordClient::signal_thread_list_sync() { + return m_signal_thread_list_sync; +} + +DiscordClient::type_signal_thread_members_update DiscordClient::signal_thread_members_update() { + return m_signal_thread_members_update; +} + +DiscordClient::type_signal_thread_update DiscordClient::signal_thread_update() { + return m_signal_thread_update; +} + +DiscordClient::type_signal_thread_member_list_update DiscordClient::signal_thread_member_list_update() { + return m_signal_thread_member_list_update; +} + +DiscordClient::type_signal_added_to_thread DiscordClient::signal_added_to_thread() { + return m_signal_added_to_thread; +} + +DiscordClient::type_signal_removed_from_thread DiscordClient::signal_removed_from_thread() { + return m_signal_removed_from_thread; +} + +DiscordClient::type_signal_message_sent DiscordClient::signal_message_sent() { + return m_signal_message_sent; +} + +DiscordClient::type_signal_message_send_fail DiscordClient::signal_message_send_fail() { + return m_signal_message_send_fail; +} diff --git a/src/discord/discord.hpp b/src/discord/discord.hpp new file mode 100644 index 0000000..4b9bc82 --- /dev/null +++ b/src/discord/discord.hpp @@ -0,0 +1,449 @@ +#pragma once +#include "websocket.hpp" +#include "httpclient.hpp" +#include "objects.hpp" +#include "store.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef GetMessage + #undef GetMessage +#endif + +class HeartbeatWaiter { +public: + template + bool wait_for(std::chrono::duration const &time) const { + std::unique_lock lock(m); + return !cv.wait_for(lock, time, [&] { return terminate; }); + } + + void kill() { + std::unique_lock lock(m); + terminate = true; + cv.notify_all(); + } + + void revive() { + std::unique_lock lock(m); + terminate = false; + } + +private: + mutable std::condition_variable cv; + mutable std::mutex m; + bool terminate = false; +}; + +class Abaddon; +class DiscordClient { + friend class Abaddon; + +public: + DiscordClient(bool mem_store = false); + void Start(); + void Stop(); + bool IsStarted() const; + bool IsStoreValid() const; + + std::unordered_set GetGuilds() const; + const UserData &GetUserData() const; + const UserSettings &GetUserSettings() const; + std::vector GetUserSortedGuilds() const; + std::vector GetMessagesForChannel(Snowflake id, size_t limit = 50) const; + std::vector GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit = 50) const; + std::set GetPrivateChannels() const; + + EPremiumType GetSelfPremiumType() const; + + void FetchMessagesInChannel(Snowflake id, sigc::slot &)> cb); + void FetchMessagesInChannelBefore(Snowflake channel_id, Snowflake before_id, sigc::slot &)> cb); + std::optional GetMessage(Snowflake id) const; + std::optional GetChannel(Snowflake id) const; + std::optional GetEmoji(Snowflake id) const; + std::optional GetPermissionOverwrite(Snowflake channel_id, Snowflake id) const; + std::optional GetUser(Snowflake id) const; + std::optional GetRole(Snowflake id) const; + std::optional GetGuild(Snowflake id) const; + std::optional GetMember(Snowflake user_id, Snowflake guild_id) const; + std::optional GetBan(Snowflake guild_id, Snowflake user_id) const; + Snowflake GetMemberHoistedRole(Snowflake guild_id, Snowflake user_id, bool with_color = false) const; + std::optional GetMemberHighestRole(Snowflake guild_id, Snowflake user_id) const; + std::set GetUsersInGuild(Snowflake id) const; + std::set GetChannelsInGuild(Snowflake id) const; + std::vector GetUsersInThread(Snowflake id) const; + std::vector GetActiveThreads(Snowflake channel_id) const; + void GetArchivedPublicThreads(Snowflake channel_id, sigc::slot callback); + + bool IsThreadJoined(Snowflake thread_id) const; + bool HasGuildPermission(Snowflake user_id, Snowflake guild_id, Permission perm) const; + + bool HasAnyChannelPermission(Snowflake user_id, Snowflake channel_id, Permission perm) const; + bool HasChannelPermission(Snowflake user_id, Snowflake channel_id, Permission perm) const; + 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) + + void ChatMessageCallback(std::string nonce, const http::response_type &response); + + void SendChatMessage(const std::string &content, Snowflake channel); + void SendChatMessage(const std::string &content, Snowflake channel, Snowflake referenced_message); + void DeleteMessage(Snowflake channel_id, Snowflake id); + void EditMessage(Snowflake channel_id, Snowflake id, std::string content); + void SendLazyLoad(Snowflake id); + void SendThreadLazyLoad(Snowflake id); + void JoinGuild(std::string code); + void LeaveGuild(Snowflake id); + void KickUser(Snowflake user_id, Snowflake guild_id); + void BanUser(Snowflake user_id, Snowflake guild_id); // todo: reason, delete messages + void UpdateStatus(PresenceStatus status, bool is_afk); + void UpdateStatus(PresenceStatus status, bool is_afk, const ActivityData &obj); + void CreateDM(Snowflake user_id); + void CreateDM(Snowflake user_id, sigc::slot callback); + void CloseDM(Snowflake channel_id); + std::optional FindDM(Snowflake user_id); // wont find group dms + void AddReaction(Snowflake id, Glib::ustring param); + void RemoveReaction(Snowflake id, Glib::ustring param); + void SetGuildName(Snowflake id, const Glib::ustring &name); + void SetGuildName(Snowflake id, const Glib::ustring &name, sigc::slot callback); + void SetGuildIcon(Snowflake id, const std::string &data); + void SetGuildIcon(Snowflake id, const std::string &data, sigc::slot callback); + void UnbanUser(Snowflake guild_id, Snowflake user_id); + void UnbanUser(Snowflake guild_id, Snowflake user_id, sigc::slot callback); + void DeleteInvite(const std::string &code); + void DeleteInvite(const std::string &code, sigc::slot callback); + void AddGroupDMRecipient(Snowflake channel_id, Snowflake user_id); + void RemoveGroupDMRecipient(Snowflake channel_id, Snowflake user_id); + void ModifyRolePermissions(Snowflake guild_id, Snowflake role_id, Permission permissions, sigc::slot callback); + void ModifyRoleName(Snowflake guild_id, Snowflake role_id, const Glib::ustring &name, sigc::slot callback); + void ModifyRoleColor(Snowflake guild_id, Snowflake role_id, uint32_t color, sigc::slot callback); + void ModifyRoleColor(Snowflake guild_id, Snowflake role_id, Gdk::RGBA color, sigc::slot callback); + void ModifyRolePosition(Snowflake guild_id, Snowflake role_id, int position, sigc::slot callback); + void ModifyEmojiName(Snowflake guild_id, Snowflake emoji_id, const Glib::ustring &name, sigc::slot callback); + void DeleteEmoji(Snowflake guild_id, Snowflake emoji_id, sigc::slot callback); + std::optional GetGuildApplication(Snowflake guild_id) const; + void RemoveRelationship(Snowflake id, sigc::slot callback); + void SendFriendRequest(const Glib::ustring &username, int discriminator, sigc::slot callback); + void PutRelationship(Snowflake id, sigc::slot callback); // send fr by id, accept incoming + void Pin(Snowflake channel_id, Snowflake message_id, sigc::slot callback); + void Unpin(Snowflake channel_id, Snowflake message_id, sigc::slot callback); + void LeaveThread(Snowflake channel_id, const std::string &location, sigc::slot callback); + void ArchiveThread(Snowflake channel_id, sigc::slot callback); + void UnArchiveThread(Snowflake channel_id, sigc::slot callback); + + bool CanModifyRole(Snowflake guild_id, Snowflake role_id) const; + bool CanModifyRole(Snowflake guild_id, Snowflake role_id, Snowflake user_id) const; + + // real client doesn't seem to use the single role endpoints so neither do we + template + auto SetMemberRoles(Snowflake guild_id, Snowflake user_id, Iter begin, Iter end, sigc::slot callback) { + ModifyGuildMemberObject obj; + obj.Roles = { begin, end }; + m_http.MakePATCH("/guilds/" + std::to_string(guild_id) + "/members/" + std::to_string(user_id), nlohmann::json(obj).dump(), [this, callback](const http::response_type &response) { + if (CheckCode(response)) + callback(DiscordError::NONE); + else + callback(GetCodeFromResponse(response)); + }); + } + + // FetchGuildBans fetches all bans+reasons via api, this func fetches stored bans (so usually just GUILD_BAN_ADD data) + std::vector GetBansInGuild(Snowflake guild_id); + void FetchGuildBan(Snowflake guild_id, Snowflake user_id, sigc::slot callback); + void FetchGuildBans(Snowflake guild_id, sigc::slot)> callback); + + void FetchInvite(std::string code, sigc::slot)> callback); + void FetchGuildInvites(Snowflake guild_id, sigc::slot)> callback); + + void FetchAuditLog(Snowflake guild_id, sigc::slot callback); + + void FetchGuildEmojis(Snowflake guild_id, sigc::slot)> callback); + + void FetchUserProfile(Snowflake user_id, sigc::slot callback); + void FetchUserNote(Snowflake user_id, sigc::slot callback); + void SetUserNote(Snowflake user_id, std::string note); + void SetUserNote(Snowflake user_id, std::string note, sigc::slot callback); + void FetchUserRelationships(Snowflake user_id, sigc::slot)> callback); + + void FetchPinned(Snowflake id, sigc::slot, DiscordError code)> callback); + + bool IsVerificationRequired(Snowflake guild_id); + void GetVerificationGateInfo(Snowflake guild_id, sigc::slot)> callback); + void AcceptVerificationGate(Snowflake guild_id, VerificationGateInfoObject info, sigc::slot callback); + + void UpdateToken(std::string token); + void SetUserAgent(std::string agent); + + PresenceStatus GetUserStatus(Snowflake id) const; + + std::map GetRelationships() const; + std::set GetRelationships(RelationshipType type) const; + std::optional GetRelationship(Snowflake id) const; + +private: + static const constexpr int InflateChunkSize = 0x10000; + std::vector m_compressed_buf; + std::vector m_decompress_buf; + z_stream m_zstream; + + std::string GetAPIURL(); + std::string GetGatewayURL(); + + static DiscordError GetCodeFromResponse(const http::response_type &response); + + void ProcessNewGuild(GuildData &guild); + + void HandleGatewayMessageRaw(std::string str); + void HandleGatewayMessage(std::string str); + void HandleGatewayHello(const GatewayMessage &msg); + void HandleGatewayReady(const GatewayMessage &msg); + void HandleGatewayMessageCreate(const GatewayMessage &msg); + void HandleGatewayMessageDelete(const GatewayMessage &msg); + void HandleGatewayMessageUpdate(const GatewayMessage &msg); + void HandleGatewayGuildMemberListUpdate(const GatewayMessage &msg); + void HandleGatewayGuildCreate(const GatewayMessage &msg); + void HandleGatewayGuildDelete(const GatewayMessage &msg); + void HandleGatewayMessageDeleteBulk(const GatewayMessage &msg); + void HandleGatewayGuildMemberUpdate(const GatewayMessage &msg); + void HandleGatewayPresenceUpdate(const GatewayMessage &msg); + void HandleGatewayChannelDelete(const GatewayMessage &msg); + void HandleGatewayChannelUpdate(const GatewayMessage &msg); + void HandleGatewayChannelCreate(const GatewayMessage &msg); + void HandleGatewayGuildUpdate(const GatewayMessage &msg); + void HandleGatewayGuildRoleUpdate(const GatewayMessage &msg); + void HandleGatewayGuildRoleCreate(const GatewayMessage &msg); + void HandleGatewayGuildRoleDelete(const GatewayMessage &msg); + void HandleGatewayMessageReactionAdd(const GatewayMessage &msg); + void HandleGatewayMessageReactionRemove(const GatewayMessage &msg); + void HandleGatewayChannelRecipientAdd(const GatewayMessage &msg); + void HandleGatewayChannelRecipientRemove(const GatewayMessage &msg); + void HandleGatewayTypingStart(const GatewayMessage &msg); + void HandleGatewayGuildBanRemove(const GatewayMessage &msg); + void HandleGatewayGuildBanAdd(const GatewayMessage &msg); + void HandleGatewayInviteCreate(const GatewayMessage &msg); + void HandleGatewayInviteDelete(const GatewayMessage &msg); + void HandleGatewayUserNoteUpdate(const GatewayMessage &msg); + void HandleGatewayGuildEmojisUpdate(const GatewayMessage &msg); + void HandleGatewayGuildJoinRequestCreate(const GatewayMessage &msg); + void HandleGatewayGuildJoinRequestUpdate(const GatewayMessage &msg); + void HandleGatewayGuildJoinRequestDelete(const GatewayMessage &msg); + void HandleGatewayRelationshipRemove(const GatewayMessage &msg); + void HandleGatewayRelationshipAdd(const GatewayMessage &msg); + void HandleGatewayThreadCreate(const GatewayMessage &msg); + void HandleGatewayThreadDelete(const GatewayMessage &msg); + void HandleGatewayThreadListSync(const GatewayMessage &msg); + void HandleGatewayThreadMembersUpdate(const GatewayMessage &msg); + void HandleGatewayThreadMemberUpdate(const GatewayMessage &msg); + void HandleGatewayThreadUpdate(const GatewayMessage &msg); + void HandleGatewayThreadMemberListUpdate(const GatewayMessage &msg); + void HandleGatewayReadySupplemental(const GatewayMessage &msg); + void HandleGatewayReconnect(const GatewayMessage &msg); + void HandleGatewayInvalidSession(const GatewayMessage &msg); + void HeartbeatThread(); + void SendIdentify(); + void SendResume(); + + void HandleSocketOpen(); + void HandleSocketClose(uint16_t code); + + bool CheckCode(const http::response_type &r); + bool CheckCode(const http::response_type &r, int expected); + + void StoreMessageData(Message &msg); + + std::string m_token; + + void AddUserToGuild(Snowflake user_id, Snowflake guild_id); + std::map> m_guild_to_users; + std::map> m_guild_to_channels; + std::map m_guild_join_requests; + std::map m_user_to_status; + std::map m_user_relationships; + std::set m_joined_threads; + std::map> m_thread_members; + + UserData m_user_data; + UserSettings m_user_settings; + + Store m_store; + HTTPClient m_http; + Websocket m_websocket; + std::atomic m_client_connected = false; + std::atomic m_ready_received = false; + bool m_client_started = false; + + std::map m_event_map; + void LoadEventMap(); + + std::thread m_heartbeat_thread; + std::atomic m_last_sequence = -1; + std::atomic m_heartbeat_msec = 0; + HeartbeatWaiter m_heartbeat_waiter; + std::atomic m_heartbeat_acked = true; + + bool m_reconnecting = false; // reconnecting either to resume or reidentify + bool m_wants_resume = false; // reconnecting specifically to resume + std::string m_session_id; + + mutable std::mutex m_msg_mutex; + Glib::Dispatcher m_msg_dispatch; + std::queue m_msg_queue; + void MessageDispatch(); + + mutable std::mutex m_generic_mutex; + Glib::Dispatcher m_generic_dispatch; + std::queue> m_generic_queue; + + std::set m_channels_pinned_requested; + std::set m_channels_lazy_loaded; + + // signals +public: + typedef sigc::signal type_signal_gateway_ready; + typedef sigc::signal type_signal_message_create; + typedef sigc::signal type_signal_message_delete; + typedef sigc::signal type_signal_message_update; + typedef sigc::signal type_signal_guild_member_list_update; + typedef sigc::signal type_signal_guild_create; + typedef sigc::signal type_signal_guild_delete; + typedef sigc::signal type_signal_channel_delete; + typedef sigc::signal type_signal_channel_update; + typedef sigc::signal type_signal_channel_create; + typedef sigc::signal type_signal_guild_update; + typedef sigc::signal type_signal_role_update; // guild id, role id + typedef sigc::signal type_signal_role_create; // guild id, role id + typedef sigc::signal type_signal_role_delete; // guild id, role id + typedef sigc::signal type_signal_reaction_add; + typedef sigc::signal type_signal_reaction_remove; + typedef sigc::signal type_signal_typing_start; // user id, channel id + typedef sigc::signal type_signal_guild_member_update; // guild id, user id + typedef sigc::signal type_signal_guild_ban_remove; // guild id, user id + typedef sigc::signal type_signal_guild_ban_add; // guild id, user id + typedef sigc::signal type_signal_invite_create; + typedef sigc::signal type_signal_invite_delete; + typedef sigc::signal type_signal_presence_update; + typedef sigc::signal type_signal_note_update; + typedef sigc::signal> type_signal_guild_emojis_update; // guild id + typedef sigc::signal type_signal_guild_join_request_create; + typedef sigc::signal type_signal_guild_join_request_update; + typedef sigc::signal type_signal_guild_join_request_delete; + typedef sigc::signal type_signal_relationship_remove; + typedef sigc::signal type_signal_relationship_add; + typedef sigc::signal type_signal_thread_create; + typedef sigc::signal type_signal_thread_delete; + typedef sigc::signal type_signal_thread_list_sync; + typedef sigc::signal type_signal_thread_members_update; + typedef sigc::signal type_signal_thread_update; + typedef sigc::signal type_signal_thread_member_list_update; + + // not discord dispatch events + typedef sigc::signal type_signal_added_to_thread; + typedef sigc::signal type_signal_removed_from_thread; + typedef sigc::signal type_signal_message_unpinned; + typedef sigc::signal type_signal_message_pinned; + typedef sigc::signal type_signal_message_sent; + + typedef sigc::signal type_signal_message_send_fail; // retry after param will be 0 if it failed for a reason that isnt slowmode + typedef sigc::signal type_signal_disconnected; // bool true if reconnecting + typedef sigc::signal type_signal_connected; + + type_signal_gateway_ready signal_gateway_ready(); + type_signal_message_create signal_message_create(); + type_signal_message_delete signal_message_delete(); + type_signal_message_update signal_message_update(); + type_signal_guild_member_list_update signal_guild_member_list_update(); + type_signal_guild_create signal_guild_create(); // structs are complete in this signal + type_signal_guild_delete signal_guild_delete(); + type_signal_channel_delete signal_channel_delete(); + type_signal_channel_update signal_channel_update(); + type_signal_channel_create signal_channel_create(); + type_signal_guild_update signal_guild_update(); + type_signal_role_update signal_role_update(); + type_signal_role_create signal_role_create(); + type_signal_role_delete signal_role_delete(); + type_signal_reaction_add signal_reaction_add(); + type_signal_reaction_remove signal_reaction_remove(); + type_signal_typing_start signal_typing_start(); + type_signal_guild_member_update signal_guild_member_update(); + type_signal_guild_ban_remove signal_guild_ban_remove(); + type_signal_guild_ban_add signal_guild_ban_add(); + type_signal_invite_create signal_invite_create(); + type_signal_invite_delete signal_invite_delete(); // safe to assume guild id is set + type_signal_presence_update signal_presence_update(); + type_signal_note_update signal_note_update(); + type_signal_guild_emojis_update signal_guild_emojis_update(); + type_signal_guild_join_request_create signal_guild_join_request_create(); + type_signal_guild_join_request_update signal_guild_join_request_update(); + type_signal_guild_join_request_delete signal_guild_join_request_delete(); + type_signal_relationship_remove signal_relationship_remove(); + type_signal_relationship_add signal_relationship_add(); + type_signal_message_unpinned signal_message_unpinned(); + type_signal_message_pinned signal_message_pinned(); + type_signal_thread_create signal_thread_create(); + type_signal_thread_delete signal_thread_delete(); + type_signal_thread_list_sync signal_thread_list_sync(); + type_signal_thread_members_update signal_thread_members_update(); + type_signal_thread_update signal_thread_update(); + type_signal_thread_member_list_update signal_thread_member_list_update(); + + type_signal_added_to_thread signal_added_to_thread(); + type_signal_removed_from_thread signal_removed_from_thread(); + type_signal_message_sent signal_message_sent(); + type_signal_message_send_fail signal_message_send_fail(); + type_signal_disconnected signal_disconnected(); + type_signal_connected signal_connected(); + +protected: + type_signal_gateway_ready m_signal_gateway_ready; + type_signal_message_create m_signal_message_create; + type_signal_message_delete m_signal_message_delete; + type_signal_message_update m_signal_message_update; + type_signal_guild_member_list_update m_signal_guild_member_list_update; + type_signal_guild_create m_signal_guild_create; + type_signal_guild_delete m_signal_guild_delete; + type_signal_channel_delete m_signal_channel_delete; + type_signal_channel_update m_signal_channel_update; + type_signal_channel_create m_signal_channel_create; + type_signal_guild_update m_signal_guild_update; + type_signal_role_update m_signal_role_update; + type_signal_role_create m_signal_role_create; + type_signal_role_delete m_signal_role_delete; + type_signal_reaction_add m_signal_reaction_add; + type_signal_reaction_remove m_signal_reaction_remove; + type_signal_typing_start m_signal_typing_start; + type_signal_guild_member_update m_signal_guild_member_update; + type_signal_guild_ban_remove m_signal_guild_ban_remove; + type_signal_guild_ban_add m_signal_guild_ban_add; + type_signal_invite_create m_signal_invite_create; + type_signal_invite_delete m_signal_invite_delete; + type_signal_presence_update m_signal_presence_update; + type_signal_note_update m_signal_note_update; + type_signal_guild_emojis_update m_signal_guild_emojis_update; + type_signal_guild_join_request_create m_signal_guild_join_request_create; + type_signal_guild_join_request_update m_signal_guild_join_request_update; + type_signal_guild_join_request_delete m_signal_guild_join_request_delete; + type_signal_relationship_remove m_signal_relationship_remove; + type_signal_relationship_add m_signal_relationship_add; + type_signal_message_unpinned m_signal_message_unpinned; + type_signal_message_pinned m_signal_message_pinned; + type_signal_thread_create m_signal_thread_create; + type_signal_thread_delete m_signal_thread_delete; + type_signal_thread_list_sync m_signal_thread_list_sync; + type_signal_thread_members_update m_signal_thread_members_update; + type_signal_thread_update m_signal_thread_update; + type_signal_thread_member_list_update m_signal_thread_member_list_update; + + type_signal_removed_from_thread m_signal_removed_from_thread; + type_signal_added_to_thread m_signal_added_to_thread; + type_signal_message_sent m_signal_message_sent; + type_signal_message_send_fail m_signal_message_send_fail; + type_signal_disconnected m_signal_disconnected; + type_signal_connected m_signal_connected; +}; diff --git a/src/discord/emoji.cpp b/src/discord/emoji.cpp new file mode 100644 index 0000000..1a97eb8 --- /dev/null +++ b/src/discord/emoji.cpp @@ -0,0 +1,51 @@ +#include "emoji.hpp" + +void from_json(const nlohmann::json &j, EmojiData &m) { + JS_N("id", m.ID); + JS_N("name", m.Name); + JS_O("roles", m.Roles); + JS_O("user", m.Creator); + JS_O("require_colons", m.NeedsColons); + JS_O("managed", m.IsManaged); + JS_O("animated", m.IsAnimated); + JS_O("available", m.IsAvailable); +} + +void to_json(nlohmann::json &j, const EmojiData &m) { + if (m.ID.IsValid()) + j["id"] = m.ID; + else + j["id"] = nullptr; + if (m.Name != "") + j["name"] = m.Name; + else + j["name"] = nullptr; + JS_IF("roles", m.Roles); + JS_IF("user", m.Creator); + JS_IF("require_colons", m.NeedsColons); + JS_IF("managed", m.IsManaged); + JS_IF("animated", m.IsAnimated); + JS_IF("available", m.IsAvailable); +} + +std::string EmojiData::GetURL(const char *ext, const char *size) const { + if (size != nullptr) + return "https://cdn.discordapp.com/emojis/" + std::to_string(ID) + "." + ext + "?size=" + size; + else + return "https://cdn.discordapp.com/emojis/" + std::to_string(ID) + "." + ext; +} + +std::string EmojiData::URLFromID(const std::string &emoji_id, const char *ext, const char *size) { + if (size != nullptr) + return "https://cdn.discordapp.com/emojis/" + emoji_id + "." + ext + "?size=" + size; + else + return "https://cdn.discordapp.com/emojis/" + emoji_id + "." + ext; +} + +std::string EmojiData::URLFromID(Snowflake emoji_id, const char *ext, const char *size) { + return URLFromID(std::to_string(emoji_id), ext, size); +} + +std::string EmojiData::URLFromID(const Glib::ustring &emoji_id, const char *ext, const char *size) { + return URLFromID(emoji_id.raw(), ext, size); +} diff --git a/src/discord/emoji.hpp b/src/discord/emoji.hpp new file mode 100644 index 0000000..156e127 --- /dev/null +++ b/src/discord/emoji.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include "json.hpp" +#include "snowflake.hpp" +#include "user.hpp" + +struct EmojiData { + Snowflake ID; // null + std::string Name; // null (in reactions) + std::optional> Roles; + std::optional Creator; // only reliable to access ID + std::optional NeedsColons; + std::optional IsManaged; + std::optional IsAnimated; + std::optional IsAvailable; + + friend void from_json(const nlohmann::json &j, EmojiData &m); + friend void to_json(nlohmann::json &j, const EmojiData &m); + + std::string GetURL(const char *ext = "png", const char *size = nullptr) const; + static std::string URLFromID(const std::string &emoji_id, const char *ext = "png", const char *size = nullptr); + static std::string URLFromID(Snowflake emoji_id, const char *ext = "png", const char *size = nullptr); + static std::string URLFromID(const Glib::ustring &emoji_id, const char *ext = "png", const char *size = nullptr); +}; diff --git a/src/discord/errors.hpp b/src/discord/errors.hpp new file mode 100644 index 0000000..4579563 --- /dev/null +++ b/src/discord/errors.hpp @@ -0,0 +1,36 @@ +#pragma once + +enum class DiscordError { + GENERIC = 0, + INVALID_FORM_BODY = 50035, + RELATIONSHIP_INCOMING_DISABLED = 80000, + RELATIONSHIP_INCOMING_BLOCKED = 80001, + RELATIONSHIP_INVALID_USER_BOT = 80002, // this is misspelled in discord's source lul + RELATIONSHIP_INVALID_SELF = 80003, + RELATIONSHIP_INVALID_DISCORD_TAG = 80004, + RELATIONSHIP_ALREADY_FRIENDS = 80007, + + NONE = -1, +}; + +constexpr const char *GetDiscordErrorDisplayString(DiscordError error) { + switch (error) { + case DiscordError::INVALID_FORM_BODY: + return "Something's wrong with your input"; + case DiscordError::RELATIONSHIP_INCOMING_DISABLED: + return "This user isn't accepting friend requests"; + case DiscordError::RELATIONSHIP_INCOMING_BLOCKED: + return "You are blocked by this user"; + case DiscordError::RELATIONSHIP_INVALID_USER_BOT: + return "You can't send a request to a bot"; + case DiscordError::RELATIONSHIP_INVALID_SELF: + return "You can't send a request to yourself"; + case DiscordError::RELATIONSHIP_INVALID_DISCORD_TAG: + return "No users with that tag exist"; + case DiscordError::RELATIONSHIP_ALREADY_FRIENDS: + return "You are already friends with that user"; + case DiscordError::GENERIC: + default: + return "An error occurred"; + } +} diff --git a/src/discord/guild.cpp b/src/discord/guild.cpp new file mode 100644 index 0000000..a02b896 --- /dev/null +++ b/src/discord/guild.cpp @@ -0,0 +1,221 @@ +#include "guild.hpp" +#include "abaddon.hpp" + +void from_json(const nlohmann::json &j, GuildData &m) { + JS_D("id", m.ID); + if (j.contains("unavailable")) { + m.IsUnavailable = true; + return; + } + + JS_D("name", m.Name); + JS_N("icon", m.Icon); + JS_N("splash", m.Splash); + JS_ON("discovery_splash", m.DiscoverySplash); + JS_O("owner", m.IsOwner); + JS_O("owner_id", m.OwnerID); + std::optional tmp; + JS_O("permissions", tmp); + if (tmp.has_value()) + m.Permissions = std::stoull(*tmp); + JS_O("region", m.VoiceRegion); + JS_ON("afk_channel_id", m.AFKChannelID); + JS_O("afk_timeout", m.AFKTimeout); + JS_O("embed_enabled", m.IsEmbedEnabled); + JS_ON("embed_channel_id", m.EmbedChannelID); + JS_O("verification_level", m.VerificationLevel); + JS_O("default_message_notifications", m.DefaultMessageNotifications); + JS_O("explicit_content_filter", m.ExplicitContentFilter); + JS_O("roles", m.Roles); + JS_O("emojis", m.Emojis); + JS_O("features", m.Features); + JS_O("mfa_level", m.MFALevel); + JS_ON("application_id", m.ApplicationID); + JS_O("widget_enabled", m.IsWidgetEnabled); + JS_ON("widget_channel_id", m.WidgetChannelID); + JS_ON("system_channel_id", m.SystemChannelID); + JS_O("system_channel_flags", m.SystemChannelFlags); + JS_ON("rules_channel_id", m.RulesChannelID); + JS_O("joined_at", m.JoinedAt); + JS_O("large", m.IsLarge); + JS_O("unavailable", m.IsUnavailable); + JS_O("member_count", m.MemberCount); + // JS_O("voice_states", m.VoiceStates); + // JS_O("members", m.Members); + JS_O("channels", m.Channels); + JS_O("threads", m.Threads); + // JS_O("presences", m.Presences); + JS_ON("max_presences", m.MaxPresences); + JS_O("max_members", m.MaxMembers); + JS_ON("vanity_url_code", m.VanityURL); + JS_ON("description", m.Description); + JS_ON("banner", m.BannerHash); + JS_O("premium_tier", m.PremiumTier); + JS_O("premium_subscription_count", m.PremiumSubscriptionCount); + JS_O("preferred_locale", m.PreferredLocale); + JS_ON("public_updates_channel_id", m.PublicUpdatesChannelID); + JS_O("max_video_channel_users", m.MaxVideoChannelUsers); + JS_O("approximate_member_count", tmp); + if (tmp.has_value()) + m.ApproximateMemberCount = std::stol(*tmp); + JS_O("approximate_presence_count", tmp); + if (tmp.has_value()) + m.ApproximatePresenceCount = std::stol(*tmp); +} + +void GuildData::update_from_json(const nlohmann::json &j) { + if (j.contains("unavailable")) { + IsUnavailable = true; + return; + } + + JS_RD("name", Name); + JS_RD("icon", Icon); + JS_RD("splash", Splash); + JS_RD("discovery_splash", DiscoverySplash); + JS_RD("owner", IsOwner); + JS_RD("owner_id", OwnerID); + std::string tmp; + JS_RD("permissions", tmp); + if (tmp != "") + Permissions = std::stoull(tmp); + JS_RD("region", VoiceRegion); + JS_RD("afk_channel_id", AFKChannelID); + JS_RD("afk_timeout", AFKTimeout); + JS_RD("embed_enabled", IsEmbedEnabled); + JS_RD("embed_channel_id", EmbedChannelID); + JS_RD("verification_level", VerificationLevel); + JS_RD("default_message_notifications", DefaultMessageNotifications); + JS_RD("explicit_content_filter", ExplicitContentFilter); + JS_RD("roles", Roles); + JS_RD("emojis", Emojis); + JS_RD("features", Features); + JS_RD("mfa_level", MFALevel); + JS_RD("application_id", ApplicationID); + JS_RD("widget_enabled", IsWidgetEnabled); + JS_RD("widget_channel_id", WidgetChannelID); + JS_RD("system_channel_id", SystemChannelID); + JS_RD("system_channel_flags", SystemChannelFlags); + JS_RD("rules_channel_id", RulesChannelID); + JS_RD("joined_at", JoinedAt); + JS_RD("large", IsLarge); + JS_RD("unavailable", IsUnavailable); + JS_RD("member_count", MemberCount); + // JS_O("voice_states", m.VoiceStates); + // JS_O("members", m.Members); + JS_RD("channels", Channels); + // JS_O("presences", m.Presences); + JS_RD("max_presences", MaxPresences); + JS_RD("max_members", MaxMembers); + JS_RD("vanity_url_code", VanityURL); + JS_RD("description", Description); + JS_RD("banner", BannerHash); + JS_RD("premium_tier", PremiumTier); + JS_RD("premium_subscription_count", PremiumSubscriptionCount); + JS_RD("preferred_locale", PreferredLocale); + JS_RD("public_updates_channel_id", PublicUpdatesChannelID); + JS_RD("max_video_channel_users", MaxVideoChannelUsers); + JS_RD("approximate_member_count", ApproximateMemberCount); + JS_RD("approximate_presence_count", ApproximatePresenceCount); +} + +bool GuildData::HasFeature(const std::string &search_feature) { + if (!Features.has_value()) return false; + for (const auto &feature : *Features) + if (search_feature == feature) + return true; + return false; +} + +bool GuildData::HasIcon() const { + return Icon != ""; +} + +bool GuildData::HasAnimatedIcon() const { + return HasIcon() && Icon[0] == 'a' && Icon[1] == '_'; +} + +std::string GuildData::GetIconURL(std::string ext, std::string size) const { + return "https://cdn.discordapp.com/icons/" + std::to_string(ID) + "/" + Icon + "." + ext + "?size=" + size; +} + +std::vector GuildData::GetSortedChannels(Snowflake ignore) const { + std::vector ret; + + const auto &discord = Abaddon::Get().GetDiscordClient(); + auto channels = discord.GetChannelsInGuild(ID); + + std::unordered_map> category_to_channels; + std::map> position_to_categories; + std::map> orphan_channels; + for (const auto &channel_id : channels) { + const auto data = discord.GetChannel(channel_id); + if (!data->ParentID.has_value() && (data->Type == ChannelType::GUILD_TEXT || data->Type == ChannelType::GUILD_NEWS)) + orphan_channels[*data->Position].push_back(*data); + else if (data->ParentID.has_value() && (data->Type == ChannelType::GUILD_TEXT || data->Type == ChannelType::GUILD_NEWS)) + category_to_channels[*data->ParentID].push_back(*data); + else if (data->Type == ChannelType::GUILD_CATEGORY) + position_to_categories[*data->Position].push_back(*data); + } + + for (auto &[pos, channels] : orphan_channels) { + std::sort(channels.begin(), channels.end(), [&](const ChannelData &a, const ChannelData &b) -> bool { + return a.ID < b.ID; + }); + for (const auto &chan : channels) + ret.push_back(chan.ID); + } + + for (auto &[pos, categories] : position_to_categories) { + std::sort(categories.begin(), categories.end(), [&](const ChannelData &a, const ChannelData &b) -> bool { + return a.ID < b.ID; + }); + for (const auto &category : categories) { + ret.push_back(category.ID); + if (ignore == category.ID) continue; // stupid hack to save me some time + auto it = category_to_channels.find(category.ID); + if (it == category_to_channels.end()) continue; + auto &channels = it->second; + std::sort(channels.begin(), channels.end(), [&](const ChannelData &a, const ChannelData &b) -> bool { + return a.Position < b.Position; + }); + for (auto &channel : channels) { + ret.push_back(channel.ID); + } + } + } + + return ret; +} + +std::vector GuildData::FetchRoles() const { + if (!Roles.has_value()) return {}; + std::vector ret; + ret.reserve(Roles->size()); + for (const auto thing : *Roles) { + auto r = Abaddon::Get().GetDiscordClient().GetRole(thing.ID); + if (r.has_value()) + ret.push_back(*r); + } + std::sort(ret.begin(), ret.end(), [](const RoleData &a, const RoleData &b) -> bool { + return a.Position > b.Position; + }); + return ret; +} + +void from_json(const nlohmann::json &j, GuildApplicationData &m) { + JS_D("user_id", m.UserID); + JS_D("guild_id", m.GuildID); + auto tmp = j.at("application_status").get(); + if (tmp == "STARTED") + m.ApplicationStatus = GuildApplicationStatus::STARTED; + else if (tmp == "PENDING") + m.ApplicationStatus = GuildApplicationStatus::PENDING; + else if (tmp == "REJECTED") + m.ApplicationStatus = GuildApplicationStatus::REJECTED; + else if (tmp == "APPROVED") + m.ApplicationStatus = GuildApplicationStatus::APPROVED; + JS_N("rejection_reason", m.RejectionReason); + JS_N("last_seen", m.LastSeen); + JS_N("created_at", m.CreatedAt); +} diff --git a/src/discord/guild.hpp b/src/discord/guild.hpp new file mode 100644 index 0000000..3c3828d --- /dev/null +++ b/src/discord/guild.hpp @@ -0,0 +1,100 @@ +#pragma once +#include "json.hpp" +#include "snowflake.hpp" +#include "role.hpp" +#include "channel.hpp" +#include "emoji.hpp" +#include +#include +#include + +enum class GuildApplicationStatus { + STARTED, + PENDING, + REJECTED, + APPROVED, + UNKNOWN, +}; + +struct GuildApplicationData { + Snowflake UserID; + Snowflake GuildID; + GuildApplicationStatus ApplicationStatus; + std::optional RejectionReason; + std::optional LastSeen; + std::optional CreatedAt; + + friend void from_json(const nlohmann::json &j, GuildApplicationData &m); +}; + +// a bot is apparently only supposed to receive the `id` and `unavailable` as false +// but user tokens seem to get the full objects (minus users) + +// everythings optional cuz of muh partial guild object +// anything not marked optional in https://discord.com/developers/docs/resources/guild#guild-object is guaranteed to be set when returned from Store::GetGuild +struct GuildData { + Snowflake ID; + std::string Name; + std::string Icon; // null + std::string Splash; // null + std::optional DiscoverySplash; // null + std::optional IsOwner; + std::optional OwnerID; + std::optional Permissions; + std::optional PermissionsNew; + std::optional VoiceRegion; + std::optional AFKChannelID; // null + std::optional AFKTimeout; + std::optional IsEmbedEnabled; // deprecated + std::optional EmbedChannelID; // null, deprecated + std::optional VerificationLevel; + std::optional DefaultMessageNotifications; + std::optional ExplicitContentFilter; + std::optional> Roles; // only access id + std::optional> Emojis; // only access id + std::optional> Features; + std::optional MFALevel; + std::optional ApplicationID; // null + std::optional IsWidgetEnabled; + std::optional WidgetChannelID; // null + std::optional SystemChannelID; // null + std::optional SystemChannelFlags; + std::optional RulesChannelID; // null + std::optional JoinedAt; // * + std::optional IsLarge; // * + std::optional IsUnavailable; // * + std::optional MemberCount; // * + // std::vector VoiceStates; // opt* + // std::vector Members; // opt* - incomplete anyways + std::optional> Channels; // * + // std::vector Presences; // opt* + std::optional MaxPresences; // null + std::optional MaxMembers; + std::optional VanityURL; // null + std::optional Description; // null + std::optional BannerHash; // null + std::optional PremiumTier; + std::optional PremiumSubscriptionCount; + std::optional PreferredLocale; + std::optional PublicUpdatesChannelID; // null + std::optional MaxVideoChannelUsers; + std::optional ApproximateMemberCount; + std::optional ApproximatePresenceCount; + std::optional> Threads; // only with permissions to view, id only + + // undocumented + // std::map GuildHashes; + bool IsLazy = false; + + // * - documentation says only sent in GUILD_CREATE, but these can be sent anyways in the READY event + + friend void from_json(const nlohmann::json &j, GuildData &m); + void update_from_json(const nlohmann::json &j); + + bool HasFeature(const std::string &feature); + bool HasIcon() const; + bool HasAnimatedIcon() const; + std::string GetIconURL(std::string ext = "png", std::string size = "32") const; + std::vector GetSortedChannels(Snowflake ignore = Snowflake::Invalid) const; + std::vector FetchRoles() const; // sorted +}; diff --git a/src/discord/httpclient.cpp b/src/discord/httpclient.cpp new file mode 100644 index 0000000..05474df --- /dev/null +++ b/src/discord/httpclient.cpp @@ -0,0 +1,139 @@ +#include "httpclient.hpp" + +//#define USE_LOCAL_PROXY +HTTPClient::HTTPClient() { + m_dispatcher.connect(sigc::mem_fun(*this, &HTTPClient::RunCallbacks)); +} + +void HTTPClient::SetBase(const std::string &url) { + m_api_base = url; +} + +void HTTPClient::SetUserAgent(std::string agent) { + m_agent = agent; +} + +void HTTPClient::SetAuth(std::string auth) { + m_authorization = auth; +} + +void HTTPClient::MakeDELETE(const std::string &path, std::function cb) { + printf("DELETE %s\n", path.c_str()); + m_futures.push_back(std::async(std::launch::async, [this, path, cb] { + http::request req(http::REQUEST_DELETE, m_api_base + path); + req.set_header("Authorization", m_authorization); + req.set_user_agent(m_agent != "" ? m_agent : "Abaddon"); +#ifdef USE_LOCAL_PROXY + req.set_proxy("http://127.0.0.1:8888"); + req.set_verify_ssl(false); +#endif + + auto res = req.execute(); + + OnResponse(res, cb); + })); +} + +void HTTPClient::MakePATCH(const std::string &path, const std::string &payload, std::function cb) { + printf("PATCH %s\n", path.c_str()); + m_futures.push_back(std::async(std::launch::async, [this, path, cb, payload] { + http::request req(http::REQUEST_PATCH, m_api_base + path); + req.set_header("Authorization", m_authorization); + req.set_header("Content-Type", "application/json"); + req.set_user_agent(m_agent != "" ? m_agent : "Abaddon"); + req.set_body(payload); +#ifdef USE_LOCAL_PROXY + req.set_proxy("http://127.0.0.1:8888"); + req.set_verify_ssl(false); +#endif + + auto res = req.execute(); + + OnResponse(res, cb); + })); +} + +void HTTPClient::MakePOST(const std::string &path, const std::string &payload, std::function cb) { + printf("POST %s\n", path.c_str()); + m_futures.push_back(std::async(std::launch::async, [this, path, cb, payload] { + http::request req(http::REQUEST_POST, m_api_base + path); + req.set_header("Authorization", m_authorization); + req.set_header("Content-Type", "application/json"); + req.set_user_agent(m_agent != "" ? m_agent : "Abaddon"); + req.set_body(payload); +#ifdef USE_LOCAL_PROXY + req.set_proxy("http://127.0.0.1:8888"); + req.set_verify_ssl(false); +#endif + + auto res = req.execute(); + + OnResponse(res, cb); + })); +} + +void HTTPClient::MakePUT(const std::string &path, const std::string &payload, std::function cb) { + printf("PUT %s\n", path.c_str()); + m_futures.push_back(std::async(std::launch::async, [this, path, cb, payload] { + http::request req(http::REQUEST_PUT, m_api_base + path); + req.set_header("Authorization", m_authorization); + if (payload != "") + req.set_header("Content-Type", "application/json"); + req.set_user_agent(m_agent != "" ? m_agent : "Abaddon"); + req.set_body(payload); +#ifdef USE_LOCAL_PROXY + req.set_proxy("http://127.0.0.1:8888"); + req.set_verify_ssl(false); +#endif + + auto res = req.execute(); + + OnResponse(res, cb); + })); +} + +void HTTPClient::MakeGET(const std::string &path, std::function cb) { + printf("GET %s\n", path.c_str()); + m_futures.push_back(std::async(std::launch::async, [this, path, cb] { + http::request req(http::REQUEST_GET, m_api_base + path); + req.set_header("Authorization", m_authorization); + req.set_header("Content-Type", "application/json"); + req.set_user_agent(m_agent != "" ? m_agent : "Abaddon"); +#ifdef USE_LOCAL_PROXY + req.set_proxy("http://127.0.0.1:8888"); + req.set_verify_ssl(false); +#endif + + auto res = req.execute(); + + OnResponse(res, cb); + })); +} + +void HTTPClient::CleanupFutures() { + for (auto it = m_futures.begin(); it != m_futures.end();) { + if (it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) + it = m_futures.erase(it); + else + it++; + } +} + +void HTTPClient::RunCallbacks() { + m_mutex.lock(); + m_queue.front()(); + m_queue.pop(); + m_mutex.unlock(); +} + +void HTTPClient::OnResponse(const http::response_type &r, std::function cb) { + CleanupFutures(); + try { + m_mutex.lock(); + m_queue.push([this, r, cb] { cb(r); }); + m_dispatcher.emit(); + m_mutex.unlock(); + } catch (const std::exception &e) { + fprintf(stderr, "error handling response (%s, code %d): %s\n", r.url.c_str(), r.status_code, e.what()); + } +} diff --git a/src/discord/httpclient.hpp b/src/discord/httpclient.hpp new file mode 100644 index 0000000..da8be37 --- /dev/null +++ b/src/discord/httpclient.hpp @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include "http.hpp" + +class HTTPClient { +public: + HTTPClient(); + + void SetBase(const std::string &url); + + void SetUserAgent(std::string agent); + void SetAuth(std::string auth); + void MakeDELETE(const std::string &path, std::function cb); + void MakeGET(const std::string &path, std::function cb); + void MakePATCH(const std::string &path, const std::string &payload, std::function cb); + void MakePOST(const std::string &path, const std::string &payload, std::function cb); + void MakePUT(const std::string &path, const std::string &payload, std::function cb); + +private: + void OnResponse(const http::response_type &r, std::function cb); + void CleanupFutures(); + + mutable std::mutex m_mutex; + Glib::Dispatcher m_dispatcher; + std::queue> m_queue; + void RunCallbacks(); + + std::vector> m_futures; + std::string m_api_base; + std::string m_authorization; + std::string m_agent; +}; diff --git a/src/discord/interactions.cpp b/src/discord/interactions.cpp new file mode 100644 index 0000000..cc439fc --- /dev/null +++ b/src/discord/interactions.cpp @@ -0,0 +1,11 @@ +#include "interactions.hpp" +#include "json.hpp" +#include "abaddon.hpp" + +void from_json(const nlohmann::json &j, MessageInteractionData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_D("name", m.Name); + JS_D("user", m.User); + JS_O("member", m.Member); +} diff --git a/src/discord/interactions.hpp b/src/discord/interactions.hpp new file mode 100644 index 0000000..c076145 --- /dev/null +++ b/src/discord/interactions.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include "member.hpp" +#include "user.hpp" +#include "snowflake.hpp" + +enum class InteractionType { + Pong = 1, // ACK a Ping + Acknowledge = 2, // DEPRECATED ACK a command without sending a message, eating the user's input + ChannelMessage = 3, // DEPRECATED respond with a message, eating the user's input + ChannelMessageWithSource = 4, // respond to an interaction with a message + DeferredChannelMessageWithSource = 5, // ACK an interaction and edit to a response later, the user sees a loading state +}; + +struct MessageInteractionData { + Snowflake ID; // id of the interaction + InteractionType Type; // the type of interaction + std::string Name; // the name of the ApplicationCommand + UserData User; // the user who invoked the interaction + // undocumented??? + std::optional Member; // the member who invoked the interaction (in a guild) + + friend void from_json(const nlohmann::json &j, MessageInteractionData &m); +}; diff --git a/src/discord/invite.cpp b/src/discord/invite.cpp new file mode 100644 index 0000000..63043a1 --- /dev/null +++ b/src/discord/invite.cpp @@ -0,0 +1,39 @@ +#include "invite.hpp" + +void from_json(const nlohmann::json &j, InviteChannelData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_ON("name", m.Name); + if (j.contains("recipients") && j.at("recipients").is_null()) { + m.RecipientUsernames.emplace(); + for (const auto &x : j.at("recipients")) + m.RecipientUsernames->push_back(x.at("username").get()); + } +} + +void from_json(const nlohmann::json &j, InviteData &m) { + JS_D("code", m.Code); + JS_O("guild", m.Guild); + JS_O("channel", m.Channel); + JS_O("inviter", m.Inviter); + JS_O("target_user", m.TargetUser); + JS_O("target_user_type", m.TargetUserType); + JS_O("approximate_presence_count", m.PresenceCount); + JS_O("approximate_member_count", m.MemberCount); + JS_O("uses", m.Uses); + JS_O("max_uses", m.MaxUses); + JS_O("max_age", m.MaxAge); + JS_O("temporary", m.IsTemporary); + JS_O("created_at", m.CreatedAt); +} + +InviteChannelData::InviteChannelData(const ChannelData &c) { + ID = c.ID; + Type = c.Type; + Name = c.Name; + if (Type == ChannelType::GROUP_DM) { + RecipientUsernames.emplace(); + for (const auto &r : c.GetDMRecipients()) + RecipientUsernames->push_back(r.Username); + } +} diff --git a/src/discord/invite.hpp b/src/discord/invite.hpp new file mode 100644 index 0000000..c4c2cf3 --- /dev/null +++ b/src/discord/invite.hpp @@ -0,0 +1,41 @@ +#pragma once +#include "json.hpp" +#include "guild.hpp" +#include + +enum class ETargetUserType { + STREAM = 1 +}; + +class InviteChannelData { +public: + InviteChannelData() = default; + InviteChannelData(const ChannelData &c); + + Snowflake ID; + ChannelType Type; + std::optional Name; + std::optional> RecipientUsernames; + // std::optional Icon; + + friend void from_json(const nlohmann::json &j, InviteChannelData &m); +}; + +class InviteData { +public: + std::string Code; + std::optional Guild; + std::optional Channel; + std::optional Inviter; + std::optional TargetUser; + std::optional TargetUserType; + std::optional PresenceCount; + std::optional MemberCount; + std::optional Uses; + std::optional MaxUses; + std::optional MaxAge; + std::optional IsTemporary; + std::optional CreatedAt; + + friend void from_json(const nlohmann::json &j, InviteData &m); +}; diff --git a/src/discord/json.hpp b/src/discord/json.hpp new file mode 100644 index 0000000..837080b --- /dev/null +++ b/src/discord/json.hpp @@ -0,0 +1,148 @@ +#pragma once +#include +#include +#include "util.hpp" + +namespace detail { // more or less because idk what to name this stuff +template +inline void json_direct(const ::nlohmann::json &j, const char *key, T &val) { + if constexpr (::util::is_optional::value) + val = j.at(key).get(); + else + j.at(key).get_to(val); +} + +template +inline void json_optional(const ::nlohmann::json &j, const char *key, T &val) { + if constexpr (::util::is_optional::value) { + if (j.contains(key)) + val = j.at(key).get(); + else + val = ::std::nullopt; + } else { + if (j.contains(key)) + j.at(key).get_to(val); + } +} + +template +inline void json_nullable(const ::nlohmann::json &j, const char *key, T &val) { + if constexpr (::util::is_optional::value) { + const auto &at = j.at(key); + if (!at.is_null()) + val = at.get(); + else + val = ::std::nullopt; + } else { + const auto &at = j.at(key); + if (!at.is_null()) + at.get_to(val); + } +} + +template +inline void json_optional_nullable(const ::nlohmann::json &j, const char *key, T &val) { + if constexpr (::util::is_optional::value) { + if (j.contains(key)) { + const auto &at = j.at(key); + if (!at.is_null()) + val = at.get(); + else + val = ::std::nullopt; + } else { + val = ::std::nullopt; + } + } else { + if (j.contains(key)) { + const auto &at = j.at(key); + if (!at.is_null()) + at.get_to(val); + } + } +} + +template +inline void json_update_optional_nullable(const ::nlohmann::json &j, const char *key, T &val) { + if constexpr (::util::is_optional::value) { + if (j.contains(key)) { + const auto &at = j.at(key); + if (!at.is_null()) + val = at.get(); + else + val = ::std::nullopt; + } + } else { + if (j.contains(key)) { + const auto &at = j.at(key); + if (!at.is_null()) + at.get_to(val); + else + val = T(); + } + } +} + +template +inline void json_update_optional_nullable_default(const ::nlohmann::json &j, const char *key, T &val, const U &fallback) { + if constexpr (::util::is_optional::value) { + if (j.contains(key)) { + const auto &at = j.at(key); + if (at.is_null()) + val = fallback; + else + val = at.get(); + } + } else { + if (j.contains(key)) { + const auto &at = j.at(key); + if (at.is_null()) + val = fallback; + else + at.get_to(val); + } + } +} +} // namespace detail + +// get a json value that is guaranteed to be present and non-null +#define JS_D(k, t) \ + do { \ + detail::json_direct(j, k, t); \ + } while (0) + +// get a json value that may not be present +#define JS_O(k, t) \ + do { \ + detail::json_optional(j, k, t); \ + } while (0) + +// get a json value that may be null +#define JS_N(k, t) \ + do { \ + detail::json_nullable(j, k, t); \ + } while (0) + +// get a json value that may not be present or may be null +#define JS_ON(k, t) \ + do { \ + detail::json_optional_nullable(j, k, t); \ + } while (0) + +// set from a json value only if it is present. null will assign default-constructed value +#define JS_RD(k, t) \ + do { \ + detail::json_update_optional_nullable(j, k, t); \ + } while (0) + +// set from a json value only if it is present. null will assign the given default +#define JS_RV(k, t, d) \ + do { \ + detail::json_update_optional_nullable_default(j, k, t, d); \ + } while (0) + +// set a json value from a std::optional only if it has a value +#define JS_IF(k, v) \ + do { \ + if (v.has_value()) \ + j[k] = *v; \ + } while (0) diff --git a/src/discord/member.cpp b/src/discord/member.cpp new file mode 100644 index 0000000..29c4fae --- /dev/null +++ b/src/discord/member.cpp @@ -0,0 +1,40 @@ +#include "member.hpp" +#include "abaddon.hpp" + +void from_json(const nlohmann::json &j, GuildMember &m) { + JS_O("user", m.User); + JS_ON("nick", m.Nickname); + JS_D("roles", m.Roles); + JS_D("joined_at", m.JoinedAt); + JS_ON("premium_since", m.PremiumSince); + JS_D("deaf", m.IsDeafened); + JS_D("mute", m.IsMuted); + JS_O("user_id", m.UserID); + JS_ON("avatar", m.Avatar); + JS_O("pending", m.IsPending); +} + +std::vector GuildMember::GetSortedRoles() const { + std::vector roles; + for (const auto role_id : Roles) { + const auto role = Abaddon::Get().GetDiscordClient().GetRole(role_id); + if (!role.has_value()) continue; + roles.push_back(std::move(*role)); + } + + std::sort(roles.begin(), roles.end(), [](const RoleData &a, const RoleData &b) { + return a.Position > b.Position; + }); + + return roles; +} + +void GuildMember::update_from_json(const nlohmann::json &j) { + JS_RD("roles", Roles); + JS_RD("user", User); + JS_RD("nick", Nickname); + JS_RD("joined_at", JoinedAt); + JS_RD("premium_since", PremiumSince); + JS_RD("avatar", Avatar); + JS_RD("pending", IsPending); +} diff --git a/src/discord/member.hpp b/src/discord/member.hpp new file mode 100644 index 0000000..e17da05 --- /dev/null +++ b/src/discord/member.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "snowflake.hpp" +#include "json.hpp" +#include "user.hpp" +#include "role.hpp" +#include +#include + +struct GuildMember { + std::optional User; // only reliable to access id. only opt in MESSAGE_* + std::string Nickname; + std::vector Roles; + std::string JoinedAt; + std::optional PremiumSince; // null + bool IsDeafened; + bool IsMuted; + std::optional UserID; // present in merged_members + std::optional IsPending; // this uses `pending` not `is_pending` + + // undocuemtned moment !!!1 + std::optional Avatar; + + std::vector GetSortedRoles() const; + + void update_from_json(const nlohmann::json &j); + friend void from_json(const nlohmann::json &j, GuildMember &m); +}; diff --git a/src/discord/message.cpp b/src/discord/message.cpp new file mode 100644 index 0000000..70c557d --- /dev/null +++ b/src/discord/message.cpp @@ -0,0 +1,265 @@ +#include "message.hpp" + +void to_json(nlohmann::json &j, const EmbedFooterData &m) { + j["text"] = m.Text; + JS_IF("icon_url", m.IconURL); + JS_IF("proxy_icon_url", m.ProxyIconURL); +} + +void from_json(const nlohmann::json &j, EmbedFooterData &m) { + JS_D("text", m.Text); + JS_O("icon_url", m.IconURL); + JS_O("proxy_icon_url", m.ProxyIconURL); +} + +void to_json(nlohmann::json &j, const EmbedImageData &m) { + JS_IF("url", m.URL); + JS_IF("proxy_url", m.ProxyURL); + JS_IF("height", m.Height); + JS_IF("width", m.Width); +} + +void from_json(const nlohmann::json &j, EmbedImageData &m) { + JS_O("url", m.URL); + JS_O("proxy_url", m.ProxyURL); + JS_O("height", m.Height); + JS_O("width", m.Width); +} + +void to_json(nlohmann::json &j, const EmbedThumbnailData &m) { + JS_IF("url", m.URL); + JS_IF("proxy_url", m.ProxyURL); + JS_IF("height", m.Height); + JS_IF("width", m.Width); +} + +void from_json(const nlohmann::json &j, EmbedThumbnailData &m) { + JS_O("url", m.URL); + JS_O("proxy_url", m.ProxyURL); + JS_O("height", m.Height); + JS_O("width", m.Width); +} + +void to_json(nlohmann::json &j, const EmbedVideoData &m) { + JS_IF("url", m.URL); + JS_IF("height", m.Height); + JS_IF("width", m.Width); +} + +void from_json(const nlohmann::json &j, EmbedVideoData &m) { + JS_O("url", m.URL); + JS_O("height", m.Height); + JS_O("width", m.Width); +} + +void to_json(nlohmann::json &j, const EmbedProviderData &m) { + JS_IF("name", m.Name); + JS_IF("url", m.URL); +} + +void from_json(const nlohmann::json &j, EmbedProviderData &m) { + JS_O("name", m.Name); + JS_ON("url", m.URL); +} + +void to_json(nlohmann::json &j, const EmbedAuthorData &m) { + JS_IF("name", m.Name); + JS_IF("url", m.URL); + JS_IF("icon_url", m.IconURL); + JS_IF("proxy_icon_url", m.ProxyIconURL); +} + +void from_json(const nlohmann::json &j, EmbedAuthorData &m) { + JS_O("name", m.Name); + JS_O("url", m.URL); + JS_O("icon_url", m.IconURL); + JS_O("proxy_icon_url", m.ProxyIconURL); +} + +void to_json(nlohmann::json &j, const EmbedFieldData &m) { + j["name"] = m.Name; + j["value"] = m.Value; + JS_IF("inline", m.Inline); +} + +void from_json(const nlohmann::json &j, EmbedFieldData &m) { + JS_D("name", m.Name); + JS_D("value", m.Value); + JS_O("inline", m.Inline); +} + +void to_json(nlohmann::json &j, const EmbedData &m) { + JS_IF("title", m.Title); + JS_IF("type", m.Type); + JS_IF("description", m.Description); + JS_IF("url", m.URL); + JS_IF("timestamp", m.Timestamp); + JS_IF("color", m.Color); + JS_IF("footer", m.Footer); + JS_IF("image", m.Image); + JS_IF("thumbnail", m.Thumbnail); + JS_IF("video", m.Video); + JS_IF("provider", m.Provider); + JS_IF("author", m.Author); + JS_IF("fields", m.Fields); +} + +void from_json(const nlohmann::json &j, EmbedData &m) { + JS_O("title", m.Title); + JS_O("type", m.Type); + JS_O("description", m.Description); + JS_O("url", m.URL); + JS_O("timestamp", m.Timestamp); + JS_O("color", m.Color); + JS_O("footer", m.Footer); + JS_O("image", m.Image); + JS_O("thumbnail", m.Thumbnail); + JS_O("video", m.Video); + JS_O("provider", m.Provider); + JS_O("author", m.Author); + JS_O("fields", m.Fields); +} + +void to_json(nlohmann::json &j, const AttachmentData &m) { + j["id"] = m.ID; + j["filename"] = m.Filename; + j["size"] = m.Bytes; + j["url"] = m.URL; + j["proxy_url"] = m.ProxyURL; + JS_IF("height", m.Height); + JS_IF("width", m.Width); +} + +void from_json(const nlohmann::json &j, AttachmentData &m) { + JS_D("id", m.ID); + JS_D("filename", m.Filename); + JS_D("size", m.Bytes); + JS_D("url", m.URL); + JS_D("proxy_url", m.ProxyURL); + JS_ON("height", m.Height); + JS_ON("width", m.Width); +} + +void from_json(const nlohmann::json &j, MessageReferenceData &m) { + JS_O("message_id", m.MessageID); + JS_O("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); +} + +void to_json(nlohmann::json &j, const MessageReferenceData &m) { + JS_IF("message_id", m.MessageID); + JS_IF("channel_id", m.ChannelID); + JS_IF("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, ReactionData &m) { + JS_D("count", m.Count); + JS_D("me", m.HasReactedWith); + JS_D("emoji", m.Emoji); +} + +void to_json(nlohmann::json &j, const ReactionData &m) { + j["count"] = m.Count; + j["me"] = m.HasReactedWith; + j["emoji"] = m.Emoji; +} + +void from_json(const nlohmann::json &j, MessageApplicationData &m) { + JS_D("id", m.ID); + JS_O("cover_image", m.CoverImage); + JS_D("description", m.Description); + JS_N("icon", m.Icon); + JS_D("name", m.Name); +} + +void to_json(nlohmann::json &j, const MessageApplicationData &m) { + j["id"] = m.ID; + JS_IF("cover_image", m.CoverImage); + j["description"] = m.Description; + if (m.Icon == "") + j["icon"] = nullptr; + else + j["icon"] = m.Icon; + j["name"] = m.Name; +} + +void from_json(const nlohmann::json &j, Message &m) { + JS_D("id", m.ID); + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); + JS_D("author", m.Author); + JS_O("member", m.Member); + JS_D("content", m.Content); + JS_D("timestamp", m.Timestamp); + JS_N("edited_timestamp", m.EditedTimestamp); + if (!j.at("edited_timestamp").is_null()) + m.SetEdited(); + JS_D("tts", m.IsTTS); + JS_D("mention_everyone", m.DoesMentionEveryone); + JS_D("mentions", m.Mentions); + // JS_D("mention_roles", m.MentionRoles); + // JS_O("mention_channels", m.MentionChannels); + JS_D("attachments", m.Attachments); + JS_D("embeds", m.Embeds); + JS_O("reactions", m.Reactions); + JS_O("nonce", m.Nonce); + JS_D("pinned", m.IsPinned); + JS_O("webhook_id", m.WebhookID); + JS_D("type", m.Type); + // JS_O("activity", m.Activity); + JS_O("application", m.Application); + JS_O("message_reference", m.MessageReference); + JS_O("flags", m.Flags); + JS_O("stickers", m.Stickers); + if (j.contains("referenced_message")) { + if (!j.at("referenced_message").is_null()) { + m.ReferencedMessage = std::make_shared(j.at("referenced_message").get()); + } else + m.ReferencedMessage = nullptr; + } + JS_O("interaction", m.Interaction); + JS_O("sticker_items", m.StickerItems); +} + +void Message::from_json_edited(const nlohmann::json &j) { + JS_D("id", ID); + JS_D("channel_id", ChannelID); + JS_O("guild_id", GuildID); + JS_O("author", Author); + JS_O("member", Member); + JS_O("content", Content); + JS_O("timestamp", Timestamp); + JS_ON("edited_timestamp", EditedTimestamp); + if (EditedTimestamp.size() > 0) + SetEdited(); + JS_O("tts", IsTTS); + JS_O("mention_everyone", DoesMentionEveryone); + JS_O("mentions", Mentions); + JS_O("embeds", Embeds); + JS_O("nonce", Nonce); + JS_O("pinned", IsPinned); + JS_O("webhook_id", WebhookID); + JS_O("type", Type); + JS_O("application", Application); + JS_O("message_reference", MessageReference); + JS_O("flags", Flags); + JS_O("stickers", Stickers); + JS_O("interaction", Interaction); + JS_O("sticker_items", StickerItems); +} + +void Message::SetDeleted() { + m_deleted = true; +} + +void Message::SetEdited() { + m_edited = true; +} + +bool Message::IsDeleted() const { + return m_deleted; +} + +bool Message::IsEdited() const { + return m_edited; +} diff --git a/src/discord/message.hpp b/src/discord/message.hpp new file mode 100644 index 0000000..56f4c0f --- /dev/null +++ b/src/discord/message.hpp @@ -0,0 +1,218 @@ +#pragma once +#include +#include +#include "snowflake.hpp" +#include "json.hpp" +#include "user.hpp" +#include "sticker.hpp" +#include "emoji.hpp" +#include "member.hpp" +#include "interactions.hpp" + +enum class MessageType { + DEFAULT = 0, // yep + RECIPIENT_ADD = 1, // yep + RECIPIENT_REMOVE = 2, // yep + CALL = 3, // yep (sorta) + CHANNEL_NAME_CHANGE = 4, // yep + CHANNEL_ICON_CHANGE = 5, // yep + CHANNEL_PINNED_MESSAGE = 6, // yep + GUILD_MEMBER_JOIN = 7, // yep + USER_PREMIUM_GUILD_SUBSCRIPTION = 8, // yep + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9, // yep + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10, // yep + USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11, // yep + CHANNEL_FOLLOW_ADD = 12, // yep + GUILD_DISCOVERY_DISQUALIFIED = 14, // yep + GUILD_DISCOVERY_REQUALIFIED = 15, // yep + GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16, // yep + GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17, // yep + THREAD_CREATED = 18, // yep + INLINE_REPLY = 19, // yep + APPLICATION_COMMAND = 20, // yep + THREAD_STARTER_MESSAGE = 21, // nope +}; + +enum class MessageFlags { + NONE = 0, + CROSSPOSTED = 1 << 0, // this message has been published to subscribed channels (via Channel Following) + IS_CROSSPOST = 1 << 1, // this message originated from a message in another channel (via Channel Following) + SUPPRESS_EMBEDS = 1 << 2, // do not include any embeds when serializing this message + SOURCE_MESSAGE_DELETE = 1 << 3, // the source message for this crosspost has been deleted (via Channel Following) + URGENT = 1 << 4, // this message came from the urgent message system + HAS_THREAD = 1 << 5, // this message has an associated thread, with the same id as the message + EPHEMERAL = 1 << 6, // this message is only visible to the user who invoked the Interaction + LOADING = 1 << 7, // this message is an Interaction Response and the bot is "thinking" +}; + +struct EmbedFooterData { + std::string Text; + std::optional IconURL; + std::optional ProxyIconURL; + + friend void to_json(nlohmann::json &j, const EmbedFooterData &m); + friend void from_json(const nlohmann::json &j, EmbedFooterData &m); +}; + +struct EmbedImageData { + std::optional URL; + std::optional ProxyURL; + std::optional Height; + std::optional Width; + + friend void to_json(nlohmann::json &j, const EmbedImageData &m); + friend void from_json(const nlohmann::json &j, EmbedImageData &m); +}; + +struct EmbedThumbnailData { + std::optional URL; + std::optional ProxyURL; + std::optional Height; + std::optional Width; + + friend void to_json(nlohmann::json &j, const EmbedThumbnailData &m); + friend void from_json(const nlohmann::json &j, EmbedThumbnailData &m); +}; + +struct EmbedVideoData { + std::optional URL; + std::optional Height; + std::optional Width; + + friend void to_json(nlohmann::json &j, const EmbedVideoData &m); + friend void from_json(const nlohmann::json &j, EmbedVideoData &m); +}; + +struct EmbedProviderData { + std::optional Name; + std::optional URL; // null + + friend void to_json(nlohmann::json &j, const EmbedProviderData &m); + friend void from_json(const nlohmann::json &j, EmbedProviderData &m); +}; + +struct EmbedAuthorData { + std::optional Name; + std::optional URL; + std::optional IconURL; + std::optional ProxyIconURL; + + friend void to_json(nlohmann::json &j, const EmbedAuthorData &m); + friend void from_json(const nlohmann::json &j, EmbedAuthorData &m); +}; + +struct EmbedFieldData { + std::string Name; + std::string Value; + std::optional Inline; + + friend void to_json(nlohmann::json &j, const EmbedFieldData &m); + friend void from_json(const nlohmann::json &j, EmbedFieldData &m); +}; + +struct EmbedData { + std::optional Title; + std::optional Type; + std::optional Description; + std::optional URL; + std::optional Timestamp; + std::optional Color; + std::optional Footer; + std::optional Image; + std::optional Thumbnail; + std::optional Video; + std::optional Provider; + std::optional Author; + std::optional> Fields; + + friend void to_json(nlohmann::json &j, const EmbedData &m); + friend void from_json(const nlohmann::json &j, EmbedData &m); +}; + +struct AttachmentData { + Snowflake ID; + std::string Filename; + int Bytes; + std::string URL; + std::string ProxyURL; + std::optional Height; // null + std::optional Width; // null + + friend void to_json(nlohmann::json &j, const AttachmentData &m); + friend void from_json(const nlohmann::json &j, AttachmentData &m); +}; + +struct MessageReferenceData { + std::optional MessageID; + std::optional ChannelID; + std::optional GuildID; + + friend void from_json(const nlohmann::json &j, MessageReferenceData &m); + friend void to_json(nlohmann::json &j, const MessageReferenceData &m); +}; + +struct ReactionData { + int Count; + bool HasReactedWith; + EmojiData Emoji; + + friend void from_json(const nlohmann::json &j, ReactionData &m); + friend void to_json(nlohmann::json &j, const ReactionData &m); +}; + +struct MessageApplicationData { + Snowflake ID; + std::optional CoverImage; + std::string Description; + std::string Icon; // null + std::string Name; + + friend void from_json(const nlohmann::json &j, MessageApplicationData &m); + friend void to_json(nlohmann::json &j, const MessageApplicationData &m); +}; + +struct Message { + Snowflake ID; + Snowflake ChannelID; + std::optional GuildID; + UserData Author; + std::optional Member; + std::string Content; + std::string Timestamp; + std::string EditedTimestamp; // null + bool IsTTS; + bool DoesMentionEveryone; + std::vector Mentions; // full user accessible + // std::vector MentionRoles; + // std::optional> MentionChannels; + std::vector Attachments; + std::vector Embeds; + std::optional> Reactions; + std::optional Nonce; + bool IsPinned; + std::optional WebhookID; + MessageType Type; + // std::optional ActivityData; + std::optional Application; + std::optional MessageReference; + std::optional Flags = MessageFlags::NONE; + std::optional> Stickers; + std::optional> ReferencedMessage; // has_value && null means deleted + std::optional Interaction; + std::optional> StickerItems; + + friend void from_json(const nlohmann::json &j, Message &m); + void from_json_edited(const nlohmann::json &j); // for MESSAGE_UPDATE + + // custom fields to track changes + bool IsPending = false; // for user-sent messages yet to be received in a MESSAGE_CREATE + + void SetDeleted(); + void SetEdited(); + bool IsDeleted() const; + bool IsEdited() const; + +private: + bool m_deleted = false; + bool m_edited = false; +}; diff --git a/src/discord/objects.cpp b/src/discord/objects.cpp new file mode 100644 index 0000000..c6de2ce --- /dev/null +++ b/src/discord/objects.cpp @@ -0,0 +1,534 @@ +#include "objects.hpp" + +void from_json(const nlohmann::json &j, GatewayMessage &m) { + JS_D("op", m.Opcode); + m.Data = j.at("d"); + + JS_ON("t", m.Type); + JS_ON("s", m.Sequence); +} + +void from_json(const nlohmann::json &j, HelloMessageData &m) { + JS_D("heartbeat_interval", m.HeartbeatInterval); +} + +void from_json(const nlohmann::json &j, MessageDeleteData &m) { + JS_D("id", m.ID); + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, MessageDeleteBulkData &m) { + JS_D("ids", m.IDs); + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, GuildMemberListUpdateMessage::GroupItem &m) { + m.Type = "group"; + JS_D("id", m.ID); + JS_D("count", m.Count); +} + +GuildMember GuildMemberListUpdateMessage::MemberItem::GetAsMemberData() const { + return m_member_data; +} + +void from_json(const nlohmann::json &j, GuildMemberListUpdateMessage::MemberItem &m) { + m.Type = "member"; + JS_D("user", m.User); + JS_D("roles", m.Roles); + JS_D("mute", m.IsMuted); + JS_D("joined_at", m.JoinedAt); + JS_D("deaf", m.IsDefeaned); + JS_N("hoisted_role", m.HoistedRole); + JS_ON("premium_since", m.PremiumSince); + JS_ON("nick", m.Nickname); + JS_ON("presence", m.Presence); + m.m_member_data = j; +} + +void from_json(const nlohmann::json &j, GuildMemberListUpdateMessage::OpObject &m) { + JS_D("op", m.Op); + if (m.Op == "SYNC") { + m.Items.emplace(); + JS_D("range", m.Range); + for (const auto &ij : j.at("items")) { + if (ij.contains("group")) + m.Items->push_back(std::make_unique(ij.at("group"))); + else if (ij.contains("member")) + m.Items->push_back(std::make_unique(ij.at("member"))); + } + } else if (m.Op == "UPDATE") { + JS_D("index", m.Index); + const auto &ij = j.at("item"); + if (ij.contains("member")) + m.OpItem = std::make_unique(ij.at("member")); + } +} + +void from_json(const nlohmann::json &j, GuildMemberListUpdateMessage &m) { + JS_D("online_count", m.OnlineCount); + JS_D("member_count", m.MemberCount); + JS_D("id", m.ListIDHash); + JS_D("guild_id", m.GuildID); + JS_D("groups", m.Groups); + JS_D("ops", m.Ops); +} + +void to_json(nlohmann::json &j, const LazyLoadRequestMessage &m) { + j["op"] = GatewayOp::LazyLoadRequest; + j["d"] = nlohmann::json::object(); + j["d"]["guild_id"] = m.GuildID; + if (m.Channels.has_value()) { + j["d"]["channels"] = nlohmann::json::object(); + for (const auto &[key, chans] : *m.Channels) + j["d"]["channels"][std::to_string(key)] = chans; + } + if (m.ShouldGetTyping) + j["d"]["typing"] = *m.ShouldGetTyping; + if (m.ShouldGetActivities) + j["d"]["activities"] = *m.ShouldGetActivities; + if (m.ShouldGetThreads) + j["d"]["threads"] = *m.ShouldGetThreads; + if (m.Members.has_value()) + j["d"]["members"] = *m.Members; + if (m.ThreadIDs.has_value()) + j["d"]["thread_member_lists"] = *m.ThreadIDs; +} + +void to_json(nlohmann::json &j, const UpdateStatusMessage &m) { + j["op"] = GatewayOp::UpdateStatus; + j["d"] = nlohmann::json::object(); + j["d"]["since"] = m.Since; + j["d"]["activities"] = m.Activities; + j["d"]["afk"] = m.IsAFK; + switch (m.Status) { + case PresenceStatus::Online: + j["d"]["status"] = "online"; + break; + case PresenceStatus::Offline: + j["d"]["status"] = "invisible"; + break; + case PresenceStatus::Idle: + j["d"]["status"] = "idle"; + break; + case PresenceStatus::DND: + j["d"]["status"] = "dnd"; + break; + } +} + +void from_json(const nlohmann::json &j, ReadyEventData &m) { + JS_D("v", m.GatewayVersion); + JS_D("user", m.SelfUser); + JS_D("guilds", m.Guilds); + JS_D("session_id", m.SessionID); + JS_O("analytics_token", m.AnalyticsToken); + JS_O("friend_suggestion_count", m.FriendSuggestionCount); + JS_D("user_settings", m.Settings); + JS_D("private_channels", m.PrivateChannels); + JS_O("users", m.Users); + JS_ON("merged_members", m.MergedMembers); + JS_O("relationships", m.Relationships); + JS_O("guild_join_requests", m.GuildJoinRequests); +} + +void from_json(const nlohmann::json &j, MergedPresence &m) { + JS_D("user_id", m.UserID); + JS_O("last_modified", m.LastModified); + m.Presence = j; +} + +void from_json(const nlohmann::json &j, SupplementalMergedPresencesData &m) { + JS_D("guilds", m.Guilds); + JS_D("friends", m.Friends); +} + +void from_json(const nlohmann::json &j, ReadySupplementalData &m) { + JS_D("merged_presences", m.MergedPresences); +} + +void to_json(nlohmann::json &j, const IdentifyProperties &m) { + j["os"] = m.OS; + j["browser"] = m.Browser; + j["device"] = m.Device; + j["system_locale"] = m.SystemLocale; + j["browser_user_agent"] = m.BrowserUserAgent; + j["browser_version"] = m.BrowserVersion; + j["os_version"] = m.OSVersion; + j["referrer"] = m.Referrer; + j["referring_domain"] = m.ReferringDomain; + j["referrer_current"] = m.ReferrerCurrent; + j["referring_domain_current"] = m.ReferringDomainCurrent; + j["release_channel"] = m.ReleaseChannel; + j["client_build_number"] = m.ClientBuildNumber; + if (m.ClientEventSource == "") + j["client_event_source"] = nullptr; + else + j["client_event_source"] = m.ClientEventSource; +} + +void to_json(nlohmann::json &j, const ClientStateProperties &m) { + j["guild_hashes"] = m.GuildHashes; + j["highest_last_message_id"] = m.HighestLastMessageID; + j["read_state_version"] = m.ReadStateVersion; + j["user_guild_settings_version"] = m.UserGuildSettingsVersion; +} + +void to_json(nlohmann::json &j, const IdentifyMessage &m) { + j["op"] = GatewayOp::Identify; + j["d"] = nlohmann::json::object(); + j["d"]["token"] = m.Token; + j["d"]["capabilities"] = m.Capabilities; + j["d"]["properties"] = m.Properties; + j["d"]["presence"] = m.Presence; + j["d"]["compress"] = m.DoesSupportCompression; + j["d"]["client_state"] = m.ClientState; +} + +void to_json(nlohmann::json &j, const HeartbeatMessage &m) { + j["op"] = GatewayOp::Heartbeat; + if (m.Sequence == -1) + j["d"] = nullptr; + else + j["d"] = m.Sequence; +} + +void to_json(nlohmann::json &j, const CreateMessageObject &m) { + j["content"] = m.Content; + JS_IF("message_reference", m.MessageReference); + JS_IF("nonce", m.Nonce); +} + +void to_json(nlohmann::json &j, const MessageEditObject &m) { + if (m.Content.size() > 0) + j["content"] = m.Content; + + // todo EmbedData to_json + // if (m.Embeds.size() > 0) + // j["embeds"] = m.Embeds; + + if (m.Flags != -1) + j["flags"] = m.Flags; +} + +void from_json(const nlohmann::json &j, GuildMemberUpdateMessage &m) { + JS_D("guild_id", m.GuildID); + JS_D("roles", m.Roles); + JS_D("user", m.User); + JS_ON("nick", m.Nick); + JS_D("joined_at", m.JoinedAt); +} + +void from_json(const nlohmann::json &j, ClientStatusData &m) { + JS_O("desktop", m.Desktop); + JS_O("mobile", m.Mobile); + JS_O("web", m.Web); +} + +void from_json(const nlohmann::json &j, PresenceUpdateMessage &m) { + m.User = j.at("user"); + JS_O("guild_id", m.GuildID); + JS_D("status", m.StatusMessage); + JS_D("activities", m.Activities); + JS_D("client_status", m.ClientStatus); +} + +void to_json(nlohmann::json &j, const CreateDMObject &m) { + std::vector conv; + for (const auto &id : m.Recipients) + conv.push_back(std::to_string(id)); + j["recipients"] = conv; +} + +void to_json(nlohmann::json &j, const ResumeMessage &m) { + j["op"] = GatewayOp::Resume; + j["d"] = nlohmann::json::object(); + j["d"]["token"] = m.Token; + j["d"]["session_id"] = m.SessionID; + j["d"]["seq"] = m.Sequence; +} + +void from_json(const nlohmann::json &j, GuildRoleUpdateObject &m) { + JS_D("guild_id", m.GuildID); + JS_D("role", m.Role); +} + +void from_json(const nlohmann::json &j, GuildRoleCreateObject &m) { + JS_D("guild_id", m.GuildID); + JS_D("role", m.Role); +} + +void from_json(const nlohmann::json &j, GuildRoleDeleteObject &m) { + JS_D("guild_id", m.GuildID); + JS_D("role_id", m.RoleID); +} + +void from_json(const nlohmann::json &j, MessageReactionAddObject &m) { + JS_D("user_id", m.UserID); + JS_D("channel_id", m.ChannelID); + JS_D("message_id", m.MessageID); + JS_O("guild_id", m.GuildID); + JS_O("member", m.Member); + JS_D("emoji", m.Emoji); +} + +void from_json(const nlohmann::json &j, MessageReactionRemoveObject &m) { + JS_D("user_id", m.UserID); + JS_D("channel_id", m.ChannelID); + JS_D("message_id", m.MessageID); + JS_O("guild_id", m.GuildID); + JS_D("emoji", m.Emoji); +} + +void from_json(const nlohmann::json &j, ChannelRecipientAdd &m) { + JS_D("user", m.User); + JS_D("channel_id", m.ChannelID); +} + +void from_json(const nlohmann::json &j, ChannelRecipientRemove &m) { + JS_D("user", m.User); + JS_D("channel_id", m.ChannelID); +} + +void from_json(const nlohmann::json &j, TypingStartObject &m) { + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); + JS_D("user_id", m.UserID); + JS_D("timestamp", m.Timestamp); + JS_O("member", m.Member); +} + +void to_json(nlohmann::json &j, const ModifyGuildObject &m) { + JS_IF("name", m.Name); + JS_IF("icon", m.IconData); +} + +void from_json(const nlohmann::json &j, GuildBanRemoveObject &m) { + JS_D("guild_id", m.GuildID); + JS_D("user", m.User); +} + +void from_json(const nlohmann::json &j, GuildBanAddObject &m) { + JS_D("guild_id", m.GuildID); + JS_D("user", m.User); +} + +void from_json(const nlohmann::json &j, InviteCreateObject &m) { + JS_D("channel_id", m.ChannelID); + JS_D("code", m.Code); + JS_D("created_at", m.CreatedAt); + JS_O("guild_id", m.GuildID); + JS_O("inviter", m.Inviter); + JS_D("max_age", m.MaxAge); + JS_D("max_uses", m.MaxUses); + JS_O("target_user", m.TargetUser); + JS_O("target_user_type", m.TargetUserType); + JS_D("temporary", m.IsTemporary); + JS_D("uses", m.Uses); +} + +void from_json(const nlohmann::json &j, InviteDeleteObject &m) { + JS_D("channel_id", m.ChannelID); + JS_O("guild_id", m.GuildID); + JS_D("code", m.Code); +} + +void from_json(const nlohmann::json &j, ConnectionData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_D("name", m.Name); + JS_D("verified", m.IsVerified); +} + +void from_json(const nlohmann::json &j, MutualGuildData &m) { + JS_D("id", m.ID); + JS_ON("nick", m.Nick); +} + +void from_json(const nlohmann::json &j, UserProfileData &m) { + JS_D("connected_accounts", m.ConnectedAccounts); + JS_D("mutual_guilds", m.MutualGuilds); + JS_ON("premium_guild_since", m.PremiumGuildSince); + JS_ON("premium_since", m.PremiumSince); + JS_D("user", m.User); +} + +void from_json(const nlohmann::json &j, UserNoteObject &m) { + JS_ON("note", m.Note); + JS_ON("note_user_id", m.NoteUserID); + JS_ON("user_id", m.UserID); +} + +void to_json(nlohmann::json &j, const UserSetNoteObject &m) { + j["note"] = m.Note; +} + +void from_json(const nlohmann::json &j, UserNoteUpdateMessage &m) { + JS_D("note", m.Note); + JS_D("id", m.ID); +} + +void from_json(const nlohmann::json &j, RelationshipsData &m) { + j.get_to(m.Users); +} + +void to_json(nlohmann::json &j, const ModifyGuildMemberObject &m) { + JS_IF("roles", m.Roles); +} + +void to_json(nlohmann::json &j, const ModifyGuildRoleObject &m) { + JS_IF("name", m.Name); + JS_IF("color", m.Color); + JS_IF("hoist", m.IsHoisted); + JS_IF("mentionable", m.Mentionable); + if (m.Permissions.has_value()) + j["permissions"] = std::to_string(static_cast(*m.Permissions)); +} + +void to_json(nlohmann::json &j, const ModifyGuildRolePositionsObject::PositionParam &m) { + j["id"] = m.ID; + JS_IF("position", m.Position); +} + +void to_json(nlohmann::json &j, const ModifyGuildRolePositionsObject &m) { + j = m.Positions; +} + +void from_json(const nlohmann::json &j, GuildEmojisUpdateObject &m) { + JS_D("guild_id", m.GuildID); +} + +void to_json(nlohmann::json &j, const ModifyGuildEmojiObject &m) { + JS_IF("name", m.Name); +} + +void from_json(const nlohmann::json &j, GuildJoinRequestCreateData &m) { + auto tmp = j.at("status").get(); + if (tmp == "STARTED") + m.Status = GuildApplicationStatus::STARTED; + else if (tmp == "PENDING") + m.Status = GuildApplicationStatus::PENDING; + else if (tmp == "REJECTED") + m.Status = GuildApplicationStatus::REJECTED; + else if (tmp == "APPROVED") + m.Status = GuildApplicationStatus::APPROVED; + JS_D("request", m.Request); + JS_D("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, GuildJoinRequestDeleteData &m) { + JS_D("user_id", m.UserID); + JS_D("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, VerificationFieldObject &m) { + JS_D("field_type", m.Type); + JS_D("label", m.Label); + JS_D("required", m.Required); + JS_D("values", m.Values); +} + +void from_json(const nlohmann::json &j, VerificationGateInfoObject &m) { + JS_O("description", m.Description); + JS_O("form_fields", m.VerificationFields); + JS_O("version", m.Version); + JS_O("enabled", m.Enabled); +} + +void to_json(nlohmann::json &j, const VerificationFieldObject &m) { + j["field_type"] = m.Type; + j["label"] = m.Label; + j["required"] = m.Required; + j["values"] = m.Values; + JS_IF("response", m.Response); +} + +void to_json(nlohmann::json &j, const VerificationGateInfoObject &m) { + JS_IF("description", m.Description); + JS_IF("form_fields", m.VerificationFields); + JS_IF("version", m.Version); + JS_IF("enabled", m.Enabled); +} + +void from_json(const nlohmann::json &j, RateLimitedResponse &m) { + JS_D("code", m.Code); + JS_D("global", m.Global); + JS_O("message", m.Message); + JS_D("retry_after", m.RetryAfter); +} + +void from_json(const nlohmann::json &j, RelationshipRemoveData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); +} + +void from_json(const nlohmann::json &j, RelationshipAddData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_D("user", m.User); +} + +void to_json(nlohmann::json &j, const FriendRequestObject &m) { + j["username"] = m.Username; + j["discriminator"] = m.Discriminator; +} + +void to_json(nlohmann::json &j, const PutRelationshipObject &m) { + JS_IF("type", m.Type); +} + +void from_json(const nlohmann::json &j, ThreadCreateData &m) { + j.get_to(m.Channel); +} + +void from_json(const nlohmann::json &j, ThreadDeleteData &m) { + JS_D("id", m.ID); + JS_D("guild_id", m.GuildID); + JS_D("parent_id", m.ParentID); + JS_D("type", m.Type); +} + +void from_json(const nlohmann::json &j, ThreadListSyncData &m) { + JS_D("threads", m.Threads); + JS_D("guild_id", m.GuildID); +} + +void from_json(const nlohmann::json &j, ThreadMembersUpdateData &m) { + JS_D("id", m.ID); + JS_D("guild_id", m.GuildID); + JS_D("member_count", m.MemberCount); + JS_O("added_members", m.AddedMembers); + JS_O("removed_member_ids", m.RemovedMemberIDs); +} + +void from_json(const nlohmann::json &j, ArchivedThreadsResponseData &m) { + JS_D("threads", m.Threads); + JS_D("members", m.Members); + JS_D("has_more", m.HasMore); +} + +void from_json(const nlohmann::json &j, ThreadMemberUpdateData &m) { + m.Member = j; +} + +void from_json(const nlohmann::json &j, ThreadUpdateData &m) { + m.Thread = j; +} + +void from_json(const nlohmann::json &j, ThreadMemberListUpdateData::UserEntry &m) { + JS_D("user_id", m.UserID); + JS_D("member", m.Member); +} + +void from_json(const nlohmann::json &j, ThreadMemberListUpdateData &m) { + JS_D("thread_id", m.ThreadID); + JS_D("guild_id", m.GuildID); + JS_D("members", m.Members); +} + +void to_json(nlohmann::json &j, const ModifyChannelObject &m) { + JS_IF("archived", m.Archived); + JS_IF("locked", m.Locked); +} diff --git a/src/discord/objects.hpp b/src/discord/objects.hpp new file mode 100644 index 0000000..7084efb --- /dev/null +++ b/src/discord/objects.hpp @@ -0,0 +1,747 @@ +#pragma once +#include +#include +#include +#include +#include "snowflake.hpp" +#include "user.hpp" +#include "role.hpp" +#include "member.hpp" +#include "channel.hpp" +#include "guild.hpp" +#include "usersettings.hpp" +#include "message.hpp" +#include "invite.hpp" +#include "permissions.hpp" +#include "emoji.hpp" +#include "activity.hpp" +#include "sticker.hpp" +#include "ban.hpp" +#include "auditlog.hpp" +#include "relationship.hpp" +#include "errors.hpp" + +// most stuff below should just be objects that get processed and thrown away immediately + +enum class GatewayOp : int { + Event = 0, + Heartbeat = 1, + Identify = 2, + UpdateStatus = 3, + Resume = 6, + Reconnect = 7, + InvalidSession = 9, + Hello = 10, + HeartbeatAck = 11, + LazyLoadRequest = 14, +}; + +enum class GatewayEvent : int { + READY, + MESSAGE_CREATE, + MESSAGE_DELETE, + MESSAGE_UPDATE, + GUILD_MEMBER_LIST_UPDATE, + GUILD_CREATE, + GUILD_DELETE, + MESSAGE_DELETE_BULK, + GUILD_MEMBER_UPDATE, + PRESENCE_UPDATE, + CHANNEL_DELETE, + CHANNEL_UPDATE, + CHANNEL_CREATE, + GUILD_UPDATE, + GUILD_ROLE_UPDATE, + GUILD_ROLE_CREATE, + GUILD_ROLE_DELETE, + MESSAGE_REACTION_ADD, + MESSAGE_REACTION_REMOVE, + CHANNEL_RECIPIENT_ADD, + CHANNEL_RECIPIENT_REMOVE, + TYPING_START, + GUILD_BAN_REMOVE, + GUILD_BAN_ADD, + INVITE_CREATE, + INVITE_DELETE, + USER_NOTE_UPDATE, + READY_SUPPLEMENTAL, + GUILD_EMOJIS_UPDATE, + GUILD_JOIN_REQUEST_CREATE, + GUILD_JOIN_REQUEST_UPDATE, + GUILD_JOIN_REQUEST_DELETE, + RELATIONSHIP_REMOVE, + RELATIONSHIP_ADD, + THREAD_CREATE, + THREAD_UPDATE, + THREAD_DELETE, + THREAD_LIST_SYNC, + THREAD_MEMBER_UPDATE, + THREAD_MEMBERS_UPDATE, + THREAD_MEMBER_LIST_UPDATE, +}; + +enum class GatewayCloseCode : uint16_t { + // standard + Normal = 1000, + GoingAway = 1001, + ProtocolError = 1002, + Unsupported = 1003, + NoStatus = 1005, + Abnormal = 1006, + UnsupportedPayload = 1007, + PolicyViolation = 1008, + TooLarge = 1009, + MandatoryExtension = 1010, + ServerError = 1011, + ServiceRestart = 1012, + TryAgainLater = 1013, + BadGateway = 1014, + TLSHandshakeFailed = 1015, + + // discord + UnknownError = 4000, + UnknownOpcode = 4001, + DecodeError = 4002, + NotAuthenticated = 4003, + AuthenticationFailed = 4004, + AlreadyAuthenticated = 4005, + InvalidSequence = 4007, + RateLimited = 4008, + SessionTimedOut = 4009, + InvalidShard = 4010, + ShardingRequired = 4011, + InvalidAPIVersion = 4012, + InvalidIntents = 4013, + DisallowedIntents = 4014, + + // internal + UserDisconnect = 4091, + Reconnecting = 4092, +}; + +struct GatewayMessage { + GatewayOp Opcode; + nlohmann::json Data; + std::string Type; + int Sequence = -1; + + friend void from_json(const nlohmann::json &j, GatewayMessage &m); +}; + +struct HelloMessageData { + int HeartbeatInterval; + + friend void from_json(const nlohmann::json &j, HelloMessageData &m); +}; + +struct MessageDeleteData { + Snowflake ID; // + Snowflake ChannelID; // + Snowflake GuildID; // opt + + friend void from_json(const nlohmann::json &j, MessageDeleteData &m); +}; + +struct MessageDeleteBulkData { + std::vector IDs; // + Snowflake ChannelID; // + Snowflake GuildID; // opt + + friend void from_json(const nlohmann::json &j, MessageDeleteBulkData &m); +}; + +struct GuildMemberListUpdateMessage { + struct Item { + virtual ~Item() = default; + + std::string Type; + }; + + struct GroupItem : Item { + std::string ID; + int Count; + + friend void from_json(const nlohmann::json &j, GroupItem &m); + }; + + struct MemberItem : Item { + UserData User; + std::vector Roles; + std::optional Presence; + std::string PremiumSince; // opt + std::string Nickname; // opt + bool IsMuted; + std::string JoinedAt; + std::string HoistedRole; // null + bool IsDefeaned; + + GuildMember GetAsMemberData() const; + + friend void from_json(const nlohmann::json &j, MemberItem &m); + + private: + GuildMember m_member_data; + }; + + struct OpObject { + std::string Op; + std::optional Index; + std::optional>> Items; // SYNC + std::optional> Range; // SYNC + std::optional> OpItem; // UPDATE + + friend void from_json(const nlohmann::json &j, OpObject &m); + }; + + int OnlineCount; + int MemberCount; + std::string ListIDHash; + std::string GuildID; + std::vector Groups; + std::vector Ops; + + friend void from_json(const nlohmann::json &j, GuildMemberListUpdateMessage &m); +}; + +struct LazyLoadRequestMessage { + Snowflake GuildID; + std::optional ShouldGetTyping; + std::optional ShouldGetActivities; + std::optional ShouldGetThreads; + std::optional> Members; // snowflake? + std::optional>>> Channels; // channel ID -> range of sidebar + std::optional> ThreadIDs; + + friend void to_json(nlohmann::json &j, const LazyLoadRequestMessage &m); +}; + +struct UpdateStatusMessage { + int Since = 0; + std::vector Activities; + PresenceStatus Status; + bool IsAFK = false; + + friend void to_json(nlohmann::json &j, const UpdateStatusMessage &m); +}; + +struct ReadyEventData { + int GatewayVersion; + UserData SelfUser; + std::vector Guilds; + std::string SessionID; + std::vector PrivateChannels; + + // undocumented + std::optional> Users; + std::optional AnalyticsToken; + std::optional FriendSuggestionCount; + UserSettings Settings; + std::optional>> MergedMembers; + std::optional> Relationships; + std::optional> GuildJoinRequests; + // std::vector ConnectedAccounts; // opt + // std::map Consents; // opt + // std::vector Experiments; // opt + // std::vector GuildExperiments; // opt + // std::map Notes; // opt + // std::vector Presences; // opt + // std::vector ReadStates; // opt + // Unknown Tutorial; // opt, null + // std::vector UserGuildSettings; // opt + + friend void from_json(const nlohmann::json &j, ReadyEventData &m); +}; + +struct MergedPresence { + Snowflake UserID; + std::optional LastModified; + PresenceData Presence; + + friend void from_json(const nlohmann::json &j, MergedPresence &m); +}; + +struct SupplementalMergedPresencesData { + std::vector> Guilds; + std::vector Friends; + + friend void from_json(const nlohmann::json &j, SupplementalMergedPresencesData &m); +}; + +struct ReadySupplementalData { + SupplementalMergedPresencesData MergedPresences; + + friend void from_json(const nlohmann::json &j, ReadySupplementalData &m); +}; + +struct IdentifyProperties { + std::string OS; + std::string Browser; + std::string Device; + std::string SystemLocale; + std::string BrowserUserAgent; + std::string BrowserVersion; + std::string OSVersion; + std::string Referrer; + std::string ReferringDomain; + std::string ReferrerCurrent; + std::string ReferringDomainCurrent; + std::string ReleaseChannel; + int ClientBuildNumber; + std::string ClientEventSource; // empty -> null + + friend void to_json(nlohmann::json &j, const IdentifyProperties &m); +}; + +struct ClientStateProperties { + std::map GuildHashes; + std::string HighestLastMessageID = "0"; + int ReadStateVersion = 0; + int UserGuildSettingsVersion = -1; + + friend void to_json(nlohmann::json &j, const ClientStateProperties &m); +}; + +struct IdentifyMessage : GatewayMessage { + std::string Token; + IdentifyProperties Properties; + PresenceData Presence; + ClientStateProperties ClientState; + bool DoesSupportCompression = false; + int Capabilities; + + friend void to_json(nlohmann::json &j, const IdentifyMessage &m); +}; + +struct HeartbeatMessage : GatewayMessage { + int Sequence; + + friend void to_json(nlohmann::json &j, const HeartbeatMessage &m); +}; + +struct CreateMessageObject { + std::string Content; + std::optional MessageReference; + std::optional Nonce; + + friend void to_json(nlohmann::json &j, const CreateMessageObject &m); +}; + +struct MessageEditObject { + std::string Content; // opt, null + std::vector Embeds; // opt, null + int Flags = -1; // opt, null + + friend void to_json(nlohmann::json &j, const MessageEditObject &m); +}; + +struct GuildMemberUpdateMessage { + Snowflake GuildID; // + std::vector Roles; // + UserData User; // + std::string Nick; // opt, null + std::string JoinedAt; + std::string PremiumSince; // opt, null + + friend void from_json(const nlohmann::json &j, GuildMemberUpdateMessage &m); +}; + +struct ClientStatusData { + std::optional Desktop; + std::optional Mobile; + std::optional Web; + + friend void from_json(const nlohmann::json &j, ClientStatusData &m); +}; + +struct PresenceUpdateMessage { + nlohmann::json User; // the client updates an existing object from this data + std::optional GuildID; + std::string StatusMessage; + std::vector Activities; + ClientStatusData ClientStatus; + + friend void from_json(const nlohmann::json &j, PresenceUpdateMessage &m); +}; + +struct CreateDMObject { + std::vector Recipients; + + friend void to_json(nlohmann::json &j, const CreateDMObject &m); +}; + +struct ResumeMessage : GatewayMessage { + std::string Token; + std::string SessionID; + int Sequence; + + friend void to_json(nlohmann::json &j, const ResumeMessage &m); +}; + +struct GuildRoleUpdateObject { + Snowflake GuildID; + RoleData Role; + + friend void from_json(const nlohmann::json &j, GuildRoleUpdateObject &m); +}; + +struct GuildRoleCreateObject { + Snowflake GuildID; + RoleData Role; + + friend void from_json(const nlohmann::json &j, GuildRoleCreateObject &m); +}; + +struct GuildRoleDeleteObject { + Snowflake GuildID; + Snowflake RoleID; + + friend void from_json(const nlohmann::json &j, GuildRoleDeleteObject &m); +}; + +struct MessageReactionAddObject { + Snowflake UserID; + Snowflake ChannelID; + Snowflake MessageID; + std::optional GuildID; + std::optional Member; + EmojiData Emoji; + + friend void from_json(const nlohmann::json &j, MessageReactionAddObject &m); +}; + +struct MessageReactionRemoveObject { + Snowflake UserID; + Snowflake ChannelID; + Snowflake MessageID; + std::optional GuildID; + EmojiData Emoji; + + friend void from_json(const nlohmann::json &j, MessageReactionRemoveObject &m); +}; + +struct ChannelRecipientAdd { + UserData User; + Snowflake ChannelID; + + friend void from_json(const nlohmann::json &j, ChannelRecipientAdd &m); +}; + +struct ChannelRecipientRemove { + UserData User; + Snowflake ChannelID; + + friend void from_json(const nlohmann::json &j, ChannelRecipientRemove &m); +}; + +struct TypingStartObject { + Snowflake ChannelID; + std::optional GuildID; + Snowflake UserID; + uint64_t Timestamp; + std::optional Member; + + friend void from_json(const nlohmann::json &j, TypingStartObject &m); +}; + +// implement rest as needed +struct ModifyGuildObject { + std::optional Name; + std::optional IconData; + + friend void to_json(nlohmann::json &j, const ModifyGuildObject &m); +}; + +struct GuildBanRemoveObject { + Snowflake GuildID; + UserData User; + + friend void from_json(const nlohmann::json &j, GuildBanRemoveObject &m); +}; + +struct GuildBanAddObject { + Snowflake GuildID; + UserData User; + + friend void from_json(const nlohmann::json &j, GuildBanAddObject &m); +}; + +struct InviteCreateObject { + Snowflake ChannelID; + std::string Code; + std::string CreatedAt; + std::optional GuildID; + std::optional Inviter; + int MaxAge; + int MaxUses; + UserData TargetUser; + std::optional TargetUserType; + bool IsTemporary; + int Uses; + + friend void from_json(const nlohmann::json &j, InviteCreateObject &m); +}; + +struct InviteDeleteObject { + Snowflake ChannelID; + std::optional GuildID; + std::string Code; + + friend void from_json(const nlohmann::json &j, InviteDeleteObject &m); +}; + +struct ConnectionData { + std::string ID; + std::string Type; + std::string Name; + bool IsVerified; + + friend void from_json(const nlohmann::json &j, ConnectionData &m); +}; + +struct MutualGuildData { + Snowflake ID; + std::optional Nick; // null + + friend void from_json(const nlohmann::json &j, MutualGuildData &m); +}; + +struct UserProfileData { + std::vector ConnectedAccounts; + std::vector MutualGuilds; + std::optional PremiumGuildSince; // null + std::optional PremiumSince; // null + UserData User; + + friend void from_json(const nlohmann::json &j, UserProfileData &m); +}; + +struct UserNoteObject { + // idk if these can be null or missing but i play it safe + std::optional Note; + std::optional NoteUserID; + std::optional UserID; + + friend void from_json(const nlohmann::json &j, UserNoteObject &m); +}; + +struct UserSetNoteObject { + std::string Note; + + friend void to_json(nlohmann::json &j, const UserSetNoteObject &m); +}; + +struct UserNoteUpdateMessage { + std::string Note; + Snowflake ID; + + friend void from_json(const nlohmann::json &j, UserNoteUpdateMessage &m); +}; + +struct RelationshipsData { + std::vector Users; + + friend void from_json(const nlohmann::json &j, RelationshipsData &m); +}; + +struct ModifyGuildMemberObject { + // std::optional Nick; + // std::optional IsMuted; + // std::optional IsDeaf; + // std::optional ChannelID; + + std::optional> Roles; + + friend void to_json(nlohmann::json &j, const ModifyGuildMemberObject &m); +}; + +struct ModifyGuildRoleObject { + std::optional Name; + std::optional Permissions; + std::optional Color; + std::optional IsHoisted; + std::optional Mentionable; + + friend void to_json(nlohmann::json &j, const ModifyGuildRoleObject &m); +}; + +struct ModifyGuildRolePositionsObject { + struct PositionParam { + Snowflake ID; + std::optional Position; // no idea why this can be optional + + friend void to_json(nlohmann::json &j, const PositionParam &m); + }; + std::vector Positions; + + friend void to_json(nlohmann::json &j, const ModifyGuildRolePositionsObject &m); +}; + +struct GuildEmojisUpdateObject { + Snowflake GuildID; + // std::vector Emojis; + // GuildHashes, undocumented + + friend void from_json(const nlohmann::json &j, GuildEmojisUpdateObject &m); +}; + +struct ModifyGuildEmojiObject { + std::optional Name; + // std::optional> Roles; + + friend void to_json(nlohmann::json &j, const ModifyGuildEmojiObject &m); +}; + +struct GuildJoinRequestCreateData { + GuildApplicationStatus Status; + GuildApplicationData Request; + Snowflake GuildID; + + friend void from_json(const nlohmann::json &j, GuildJoinRequestCreateData &m); +}; + +using GuildJoinRequestUpdateData = GuildJoinRequestCreateData; + +struct GuildJoinRequestDeleteData { + Snowflake UserID; + Snowflake GuildID; + + friend void from_json(const nlohmann::json &j, GuildJoinRequestDeleteData &m); +}; + +struct VerificationFieldObject { + std::string Type; + std::string Label; + bool Required; + std::vector Values; + std::optional Response; // present in client to server + + friend void from_json(const nlohmann::json &j, VerificationFieldObject &m); + friend void to_json(nlohmann::json &j, const VerificationFieldObject &m); +}; + +struct VerificationGateInfoObject { + std::optional Description; + std::optional> VerificationFields; + std::optional Version; + std::optional Enabled; // present only in client to server in modify gate + + friend void from_json(const nlohmann::json &j, VerificationGateInfoObject &m); + friend void to_json(nlohmann::json &j, const VerificationGateInfoObject &m); +}; + +// not sure what the structure for this really is +struct RateLimitedResponse { + int Code; + bool Global; + std::optional Message; + float RetryAfter; + + friend void from_json(const nlohmann::json &j, RateLimitedResponse &m); +}; + +struct RelationshipRemoveData { + Snowflake ID; + RelationshipType Type; + + friend void from_json(const nlohmann::json &j, RelationshipRemoveData &m); +}; + +struct RelationshipAddData { + Snowflake ID; + // Nickname; same deal as the other comment somewhere else + RelationshipType Type; + UserData User; + // std::optional ShouldNotify; // i guess if the client should send a notification. not worth caring about + + friend void from_json(const nlohmann::json &j, RelationshipAddData &m); +}; + +struct FriendRequestObject { + std::string Username; + int Discriminator; + + friend void to_json(nlohmann::json &j, const FriendRequestObject &m); +}; + +struct PutRelationshipObject { + std::optional Type; + + friend void to_json(nlohmann::json &j, const PutRelationshipObject &m); +}; + +struct ThreadCreateData { + ChannelData Channel; + + friend void from_json(const nlohmann::json &j, ThreadCreateData &m); +}; + +struct ThreadDeleteData { + Snowflake ID; + Snowflake GuildID; + Snowflake ParentID; + ChannelType Type; + + friend void from_json(const nlohmann::json &j, ThreadDeleteData &m); +}; + +// pretty different from docs +struct ThreadListSyncData { + std::vector Threads; + Snowflake GuildID; + // std::optional> MostRecentMessages; + + friend void from_json(const nlohmann::json &j, ThreadListSyncData &m); +}; + +struct ThreadMembersUpdateData { + Snowflake ID; + Snowflake GuildID; + int MemberCount; + std::optional> AddedMembers; + std::optional> RemovedMemberIDs; + + friend void from_json(const nlohmann::json &j, ThreadMembersUpdateData &m); +}; + +struct ArchivedThreadsResponseData { + std::vector Threads; + std::vector Members; + bool HasMore; + + friend void from_json(const nlohmann::json &j, ArchivedThreadsResponseData &m); +}; + +struct ThreadMemberUpdateData { + ThreadMemberObject Member; + + friend void from_json(const nlohmann::json &j, ThreadMemberUpdateData &m); +}; + +struct ThreadUpdateData { + ChannelData Thread; + + friend void from_json(const nlohmann::json &j, ThreadUpdateData &m); +}; + +struct ThreadMemberListUpdateData { + struct UserEntry { + Snowflake UserID; + // PresenceData Presence; + GuildMember Member; + + friend void from_json(const nlohmann::json &j, UserEntry &m); + }; + + Snowflake ThreadID; + Snowflake GuildID; + std::vector Members; + + friend void from_json(const nlohmann::json &j, ThreadMemberListUpdateData &m); +}; + +struct ModifyChannelObject { + std::optional Archived; + std::optional Locked; + + friend void to_json(nlohmann::json &j, const ModifyChannelObject &m); +}; diff --git a/src/discord/permissions.cpp b/src/discord/permissions.cpp new file mode 100644 index 0000000..63eeb9f --- /dev/null +++ b/src/discord/permissions.cpp @@ -0,0 +1,11 @@ +#include "permissions.hpp" + +void from_json(const nlohmann::json &j, PermissionOverwrite &m) { + JS_D("id", m.ID); + std::string tmp; + m.Type = j.at("type").get() == 0 ? PermissionOverwrite::ROLE : PermissionOverwrite::MEMBER; + JS_D("allow", tmp); + m.Allow = static_cast(std::stoull(tmp)); + JS_D("deny", tmp); + m.Deny = static_cast(std::stoull(tmp)); +} diff --git a/src/discord/permissions.hpp b/src/discord/permissions.hpp new file mode 100644 index 0000000..56ef742 --- /dev/null +++ b/src/discord/permissions.hpp @@ -0,0 +1,224 @@ +#pragma once +#include +#include "snowflake.hpp" +#include "json.hpp" +#include "util.hpp" + +constexpr static uint64_t PERMISSION_MAX_BIT = 36; +enum class Permission : uint64_t { + NONE = 0, + CREATE_INSTANT_INVITE = (1ULL << 0), // Allows creation of instant invites + KICK_MEMBERS = (1ULL << 1), // Allows kicking members + BAN_MEMBERS = (1ULL << 2), // Allows banning members + ADMINISTRATOR = (1ULL << 3), // Allows all permissions and bypasses channel permission overwrites + MANAGE_CHANNELS = (1ULL << 4), // Allows management and editing of channels + MANAGE_GUILD = (1ULL << 5), // Allows management and editing of the guild + ADD_REACTIONS = (1ULL << 6), // Allows for the addition of reactions to messages + VIEW_AUDIT_LOG = (1ULL << 7), // Allows for viewing of audit logs + PRIORITY_SPEAKER = (1ULL << 8), // Allows for using priority speaker in a voice channel + STREAM = (1ULL << 9), // Allows the user to go live + VIEW_CHANNEL = (1ULL << 10), // Allows guild members to view a channel, which includes reading messages in text channels + SEND_MESSAGES = (1ULL << 11), // Allows for sending messages in a channel + SEND_TTS_MESSAGES = (1ULL << 12), // Allows for sending of /tts messages + MANAGE_MESSAGES = (1ULL << 13), // Allows for deletion of other users messages + EMBED_LINKS = (1ULL << 14), // Links sent by users with this permission will be auto-embedded + ATTACH_FILES = (1ULL << 15), // Allows for uploading images and files + READ_MESSAGE_HISTORY = (1ULL << 16), // Allows for reading of message history + MENTION_EVERYONE = (1ULL << 17), // Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all online users in a channel + USE_EXTERNAL_EMOJIS = (1ULL << 18), // Allows the usage of custom emojis from other servers + VIEW_GUILD_INSIGHTS = (1ULL << 19), // Allows for viewing guild insights + CONNECT = (1ULL << 20), // Allows for joining of a voice channel + SPEAK = (1ULL << 21), // Allows for speaking in a voice channel + MUTE_MEMBERS = (1ULL << 22), // Allows for muting members in a voice channel + DEAFEN_MEMBERS = (1ULL << 23), // Allows for deafening of members in a voice channel + MOVE_MEMBERS = (1ULL << 24), // Allows for moving of members between voice channels + USE_VAD = (1ULL << 25), // Allows for using voice-activity-detection in a voice channel + CHANGE_NICKNAME = (1ULL << 26), // Allows for modification of own nickname + MANAGE_NICKNAMES = (1ULL << 27), // Allows for modification of other users nicknames + MANAGE_ROLES = (1ULL << 28), // Allows management and editing of roles + MANAGE_WEBHOOKS = (1ULL << 29), // Allows management and editing of webhooks + MANAGE_EMOJIS = (1ULL << 30), // Allows management and editing of emojis + USE_SLASH_COMMANDS = (1ULL << 31), // Allows members to use slash commands in text channels + REQUEST_TO_SPEAK = (1ULL << 32), // Allows for requesting to speak in stage channels + MANAGE_THREADS = (1ULL << 34), // Allows for deleting and archiving threads, and viewing all private threads + USE_PUBLIC_THREADS = (1ULL << 35), // Allows for creating and participating in threads + USE_PRIVATE_THREADS = (1ULL << 36), // Allows for creating and participating in private threads + + ALL = 0x1FFFFFFFFFULL, +}; +template<> +struct Bitwise { + static const bool enable = true; +}; + +struct PermissionOverwrite { + enum OverwriteType : uint8_t { + ROLE = 0, + MEMBER = 1, + }; + + Snowflake ID; + OverwriteType Type; + Permission Allow; + Permission Deny; + + friend void from_json(const nlohmann::json &j, PermissionOverwrite &m); +}; + +constexpr const char *GetPermissionString(Permission perm) { + switch (perm) { + case Permission::NONE: + return "None"; + case Permission::CREATE_INSTANT_INVITE: + return "Create Invite"; + case Permission::KICK_MEMBERS: + return "Kick Members"; + case Permission::BAN_MEMBERS: + return "Ban Members"; + case Permission::ADMINISTRATOR: + return "Administrator"; + case Permission::MANAGE_CHANNELS: + return "Manage Channels"; + case Permission::MANAGE_GUILD: + return "Manage Server"; + case Permission::ADD_REACTIONS: + return "Add Reactions"; + case Permission::VIEW_AUDIT_LOG: + return "View Audit Log"; + case Permission::PRIORITY_SPEAKER: + return "Use Priority Speaker"; + case Permission::STREAM: + return "Video"; + case Permission::VIEW_CHANNEL: + return "View Channel"; + case Permission::SEND_MESSAGES: + return "Send Messages"; + case Permission::SEND_TTS_MESSAGES: + return "Use TTS"; + case Permission::MANAGE_MESSAGES: + return "Manage Messages"; + case Permission::EMBED_LINKS: + return "Embed Links"; + case Permission::ATTACH_FILES: + return "Attach Files"; + case Permission::READ_MESSAGE_HISTORY: + return "Read Message History"; + case Permission::MENTION_EVERYONE: + return "Mention @everyone"; + case Permission::USE_EXTERNAL_EMOJIS: + return "Use External Emojis"; + case Permission::VIEW_GUILD_INSIGHTS: + return "View Server Insights"; + case Permission::CONNECT: + return "Connect to Voice"; + case Permission::SPEAK: + return "Speak in Voice"; + case Permission::MUTE_MEMBERS: + return "Mute Members"; + case Permission::DEAFEN_MEMBERS: + return "Deafen Members"; + case Permission::MOVE_MEMBERS: + return "Move Members"; + case Permission::USE_VAD: + return "Use Voice Activation"; + case Permission::CHANGE_NICKNAME: + return "Change Nickname"; + case Permission::MANAGE_NICKNAMES: + return "Manage Nicknames"; + case Permission::MANAGE_ROLES: + return "Manage Roles"; + case Permission::MANAGE_WEBHOOKS: + return "Manage Webhooks"; + case Permission::MANAGE_EMOJIS: + return "Manage Emojis"; + case Permission::USE_SLASH_COMMANDS: + return "Use Slash Commands"; + case Permission::MANAGE_THREADS: + return "Manage Threads"; + case Permission::USE_PUBLIC_THREADS: + return "Use Public Threads"; + case Permission::USE_PRIVATE_THREADS: + return "Use Private Threads"; + default: + return "Unknown Permission"; + } +} + +constexpr const char *GetPermissionDescription(Permission perm) { + switch (perm) { + case Permission::NONE: + return ""; + case Permission::CREATE_INSTANT_INVITE: + return "Allows members to invite new people to this server."; + case Permission::KICK_MEMBERS: + return "Allows members to remove other members from this server. Kicked members will be able to rejoin if they have another invite."; + case Permission::BAN_MEMBERS: + return "Allows members to permanently ban other members from this server."; + case Permission::ADMINISTRATOR: + return "Members with this permission will have every permission and will also bypass all channel specific permissions or restrictions (for example, these members would get access to all private channels). This is a dangerous permission to grant."; + case Permission::MANAGE_CHANNELS: + return "Allows members to create, edit, or delete channels."; + case Permission::MANAGE_GUILD: + return "Allows members to change this server's name, switch regions, and add bots to this server."; + case Permission::ADD_REACTIONS: + return "Allows members to add new emoji reactions to a message. If this permission is disabled, members can still react using any existing reactions on a message."; + case Permission::VIEW_AUDIT_LOG: + return "Allows members to view a record of who made which changes in this server."; + case Permission::PRIORITY_SPEAKER: + return "Allows members to be more easily heard in voice channels. When activated, the volume of others without this permission will be automatically lowered. Priority Speaker is activated by using the Push to Talk (Priority) keybind."; + case Permission::STREAM: + return "Allows members to share their video, screen share, or stream a game in this server."; + case Permission::VIEW_CHANNEL: + return "Allows members to view channels by default (excluding private channels)."; + case Permission::SEND_MESSAGES: + return "Allows members to send messages in text channels."; + case Permission::SEND_TTS_MESSAGES: + return "Allows members to send text-to-speech messages by starting a message with /tts. These messages can be heard by anyone focused on thsi channel."; + case Permission::MANAGE_MESSAGES: + return "Allows members to delete messages by other members or pin any message"; + case Permission::EMBED_LINKS: + return "Allows links that members share to show embedded content in text channels."; + case Permission::ATTACH_FILES: + return "Allows members to upload files or media in text channels."; + case Permission::READ_MESSAGE_HISTORY: + return "Allows members to read previous messages sent in channels. If this permission is disabled, members only see messages sent when they are online and focused on that channel."; + case Permission::MENTION_EVERYONE: + return "Allows members to use @everyone (everyone in the server) or @here (only online members in that channel). They can also @mention all roles, even if the role's \"Allow anyone to mention this role\" permission is disabled."; + case Permission::USE_EXTERNAL_EMOJIS: + return "Allows members to use emoji from other servers, if they're a Discord Nitro member"; + case Permission::VIEW_GUILD_INSIGHTS: + return "Allows members to view Server Insights, which shows data on community growth, engagement, and more."; + case Permission::CONNECT: + return "Allows members to join voice channels and hear others."; + case Permission::SPEAK: + return "Allows members to talk in voice channels. If this permission is disabled, members are default muted until somebody with the \"Mute Members\" permission un-mutes them."; + case Permission::MUTE_MEMBERS: + return "Allows members to mute other members in voice channels for everyone."; + case Permission::DEAFEN_MEMBERS: + return "Allows members to deafen other members in voice channels, which means they won't be able to speak or hear others."; + case Permission::MOVE_MEMBERS: + return "Allows members to move other members between voice channels that the member with the permission has access to."; + case Permission::USE_VAD: + return "Allows members to speak in voice channels by simply talking. If this permission is disabled, members are required to use Push-to-talk. Good for controlling background noise or noisy members."; + case Permission::CHANGE_NICKNAME: + return "Allows members to change their own nickname, a custom name for just this server."; + case Permission::MANAGE_NICKNAMES: + return "Allows members to change the nicknames of other members."; + case Permission::MANAGE_ROLES: + return "Allows members to create new roles and edit or delete roles lower than their highest role. Also allows members to change permissions of individual channels that they have access to."; + case Permission::MANAGE_WEBHOOKS: + return "Allows members to create, edit, or delete webhooks, which can post messages from other apps or sites into this server."; + case Permission::MANAGE_EMOJIS: + return "Allows members to add or remove custom emojis in this server."; + case Permission::USE_SLASH_COMMANDS: + return "Allows members to use slash commands in text channels."; + case Permission::MANAGE_THREADS: + return "Allows members to rename, delete, archive/unarchive, and turn on slow mode for threads."; + case Permission::USE_PUBLIC_THREADS: + return "Allows members to talk in threads. The \"Send Messages\" permission must be enabled for members to start new threads; if it's disabled, they can only respond to existing threads."; + case Permission::USE_PRIVATE_THREADS: + return "Allows members to create and chat in private threads. The \"Send Messages\" permission must be enabled for members to start new private threads; if it's disabled, they can only respond to private threads they're added to."; + default: + return ""; + } +} diff --git a/src/discord/relationship.cpp b/src/discord/relationship.cpp new file mode 100644 index 0000000..d65d2c1 --- /dev/null +++ b/src/discord/relationship.cpp @@ -0,0 +1,6 @@ +#include "relationship.hpp" + +void from_json(const nlohmann::json &j, RelationshipData &m) { + JS_D("type", m.Type); + JS_D("id", m.ID); +} diff --git a/src/discord/relationship.hpp b/src/discord/relationship.hpp new file mode 100644 index 0000000..d492bd3 --- /dev/null +++ b/src/discord/relationship.hpp @@ -0,0 +1,21 @@ +#pragma once +#include "json.hpp" +#include "user.hpp" + +enum class RelationshipType { + None = 0, + Friend = 1, + Blocked = 2, + PendingIncoming = 3, + PendingOutgoing = 4, + Implicit = 5, +}; + +struct RelationshipData { + // Snowflake UserID; this is the same as ID apparently but it looks new so i wont touch it + RelationshipType Type; + Snowflake ID; + // Unknown Nickname; // null + + friend void from_json(const nlohmann::json &j, RelationshipData &m); +}; diff --git a/src/discord/role.cpp b/src/discord/role.cpp new file mode 100644 index 0000000..07a912e --- /dev/null +++ b/src/discord/role.cpp @@ -0,0 +1,14 @@ +#include "role.hpp" + +void from_json(const nlohmann::json &j, RoleData &m) { + JS_D("id", m.ID); + JS_D("name", m.Name); + JS_D("color", m.Color); + JS_D("hoist", m.IsHoisted); + JS_D("position", m.Position); + std::string tmp; + JS_D("permissions", tmp); + m.Permissions = static_cast(std::stoull(tmp)); + JS_D("managed", m.IsManaged); + JS_D("mentionable", m.IsMentionable); +} diff --git a/src/discord/role.hpp b/src/discord/role.hpp new file mode 100644 index 0000000..f638b65 --- /dev/null +++ b/src/discord/role.hpp @@ -0,0 +1,20 @@ +#pragma once +#include "snowflake.hpp" +#include "json.hpp" +#include "permissions.hpp" +#include +#include + +struct RoleData { + Snowflake ID; + std::string Name; + int Color; + bool IsHoisted; + int Position; + int PermissionsLegacy; + Permission Permissions; + bool IsManaged; + bool IsMentionable; + + friend void from_json(const nlohmann::json &j, RoleData &m); +}; diff --git a/src/discord/snowflake.cpp b/src/discord/snowflake.cpp new file mode 100644 index 0000000..cea9153 --- /dev/null +++ b/src/discord/snowflake.cpp @@ -0,0 +1,67 @@ +#include "snowflake.hpp" +#include +#include +#include + +constexpr static uint64_t DiscordEpochSeconds = 1420070400; + +const Snowflake Snowflake::Invalid = -1ULL; + +Snowflake::Snowflake() + : m_num(Invalid) {} + +Snowflake::Snowflake(uint64_t n) + : m_num(n) {} + +Snowflake::Snowflake(const std::string &str) { + if (str.size()) + m_num = std::stoull(str); + else + m_num = Invalid; +} +Snowflake::Snowflake(const Glib::ustring &str) { + if (str.size()) + m_num = std::strtoull(str.c_str(), nullptr, 10); + else + m_num = Invalid; +}; + +Snowflake Snowflake::FromNow() { + using namespace std::chrono; + // not guaranteed to work but it probably will anyway + static uint64_t counter = 0; + const auto millis_since_epoch = static_cast(duration_cast(system_clock::now().time_since_epoch()).count()); + const auto epoch = millis_since_epoch - DiscordEpochSeconds * 1000; + uint64_t snowflake = epoch << 22; + // worker id and process id would be OR'd in here but there's no point + snowflake |= counter++ % 4096; + return snowflake; +} + +bool Snowflake::IsValid() const { + return m_num != Invalid; +} + +std::string Snowflake::GetLocalTimestamp() const { + const time_t secs_since_epoch = (m_num / SecondsInterval) + DiscordEpochSeconds; + const std::tm tm = *localtime(&secs_since_epoch); + std::stringstream ss; + const static std::locale locale(""); + ss.imbue(locale); + ss << std::put_time(&tm, "%X %x"); + return ss.str(); +} + +void from_json(const nlohmann::json &j, Snowflake &s) { + if (j.is_string()) { + std::string tmp; + j.get_to(tmp); + s.m_num = std::stoull(tmp); + } else { + j.get_to(s.m_num); + } +} + +void to_json(nlohmann::json &j, const Snowflake &s) { + j = std::to_string(s); +} diff --git a/src/discord/snowflake.hpp b/src/discord/snowflake.hpp new file mode 100644 index 0000000..0b79723 --- /dev/null +++ b/src/discord/snowflake.hpp @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +struct Snowflake { + Snowflake(); + Snowflake(uint64_t n); + Snowflake(const std::string &str); + Snowflake(const Glib::ustring &str); + + static Snowflake FromNow(); // not thread safe + + bool IsValid() const; + std::string GetLocalTimestamp() const; + + bool operator==(const Snowflake &s) const noexcept { + return m_num == s.m_num; + } + + bool operator<(const Snowflake &s) const noexcept { + return m_num < s.m_num; + } + + operator uint64_t() const noexcept { + return m_num; + } + + const static Snowflake Invalid; // makes sense to me + const static uint64_t SecondsInterval = 4194304000ULL; // the "difference" between two snowflakes one second apart + + friend void from_json(const nlohmann::json &j, Snowflake &s); + friend void to_json(nlohmann::json &j, const Snowflake &s); + +private: + friend struct std::hash; + friend struct std::less; + unsigned long long m_num; +}; + +namespace std { +template<> +struct hash { + std::size_t operator()(const Snowflake &k) const { + return k.m_num; + } +}; + +template<> +struct less { + bool operator()(const Snowflake &l, const Snowflake &r) const { + return l.m_num < r.m_num; + } +}; +} // namespace std diff --git a/src/discord/sticker.cpp b/src/discord/sticker.cpp new file mode 100644 index 0000000..b92d031 --- /dev/null +++ b/src/discord/sticker.cpp @@ -0,0 +1,52 @@ +#include "sticker.hpp" + +void to_json(nlohmann::json &j, const StickerData &m) { + j["id"] = m.ID; + j["pack_id"] = m.PackID; + j["name"] = m.Name; + j["description"] = m.Description; + JS_IF("tags", m.Tags); + JS_IF("asset", m.AssetHash); + JS_IF("preview_asset", m.PreviewAssetHash); + j["format_type"] = m.FormatType; +} + +void from_json(const nlohmann::json &j, StickerData &m) { + JS_D("id", m.ID); + JS_D("pack_id", m.PackID); + JS_D("name", m.Name); + JS_D("description", m.Description); + JS_O("tags", m.Tags); + JS_O("asset", m.AssetHash); + JS_ON("preview_asset", m.PreviewAssetHash); + JS_D("format_type", m.FormatType); +} + +std::string StickerData::GetURL() const { + if (!AssetHash.has_value()) return ""; + if (FormatType == StickerFormatType::PNG || FormatType == StickerFormatType::APNG) + return "https://media.discordapp.net/stickers/" + std::to_string(ID) + "/" + *AssetHash + ".png?size=256"; + else if (FormatType == StickerFormatType::LOTTIE) + return "https://media.discordapp.net/stickers/" + std::to_string(ID) + "/" + *AssetHash + ".json"; + return ""; +} + +void to_json(nlohmann::json &j, const StickerItem &m) { + j["id"] = m.ID; + j["name"] = m.Name; + j["format_type"] = m.FormatType; +} + +void from_json(const nlohmann::json &j, StickerItem &m) { + JS_D("id", m.ID); + JS_D("name", m.Name); + JS_D("format_type", m.FormatType); +} + +std::string StickerItem::GetURL() const { + if (FormatType == StickerFormatType::PNG || FormatType == StickerFormatType::APNG) + return "https://media.discordapp.net/stickers/" + std::to_string(ID) + ".png?size=256"; + else if (FormatType == StickerFormatType::LOTTIE) + return "https://media.discordapp.net/stickers/" + std::to_string(ID) + ".json"; + return ""; +} diff --git a/src/discord/sticker.hpp b/src/discord/sticker.hpp new file mode 100644 index 0000000..d23fe7b --- /dev/null +++ b/src/discord/sticker.hpp @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include "snowflake.hpp" +#include "json.hpp" + +// unstable + +enum class StickerFormatType { + PNG = 1, + APNG = 2, + LOTTIE = 3, +}; + +struct StickerData { + Snowflake ID; + Snowflake PackID; + std::string Name; + std::string Description; + std::optional Tags; + std::optional AssetHash; + std::optional PreviewAssetHash; + StickerFormatType FormatType; + + friend void to_json(nlohmann::json &j, const StickerData &m); + friend void from_json(const nlohmann::json &j, StickerData &m); + + std::string GetURL() const; +}; + +struct StickerItem { + StickerFormatType FormatType; + Snowflake ID; + std::string Name; + + friend void to_json(nlohmann::json &j, const StickerItem &m); + friend void from_json(const nlohmann::json &j, StickerItem &m); + + std::string GetURL() const; +}; diff --git a/src/discord/store.cpp b/src/discord/store.cpp new file mode 100644 index 0000000..9b615fd --- /dev/null +++ b/src/discord/store.cpp @@ -0,0 +1,2232 @@ +#include "store.hpp" +#include + +using namespace std::literals::string_literals; + +// hopefully the casting between signed and unsigned int64 doesnt cause issues + +Store::Store(bool mem_store) + : m_db_path(mem_store ? ":memory:" : std::filesystem::temp_directory_path() / "abaddon-store.db") + , m_db(m_db_path.string().c_str()) { + if (!m_db.OK()) { + fprintf(stderr, "error opening database: %s\n", m_db.ErrStr()); + return; + } + + m_db.Execute(R"( + PRAGMA writable_schema = 1; + DELETE FROM sqlite_master; + PRAGMA writable_schema = 0; + VACUUM; + PRAGMA integrity_check; + )"); + if (!m_db.OK()) { + fprintf(stderr, "failed to clear database: %s\n", m_db.ErrStr()); + return; + } + + if (m_db.Execute("PRAGMA journal_mode = WAL") != SQLITE_OK) { + fprintf(stderr, "enabling write-ahead-log failed: %s\n", m_db.ErrStr()); + return; + } + + if (m_db.Execute("PRAGMA synchronous = NORMAL") != SQLITE_OK) { + fprintf(stderr, "setting synchronous failed: %s\n", m_db.ErrStr()); + return; + } + + m_ok &= CreateTables(); + m_ok &= CreateStatements(); +} + +Store::~Store() { + m_db.Close(); + if (!m_db.OK()) { + fprintf(stderr, "error closing database: %s\n", m_db.ErrStr()); + return; + } + + if (m_db_path != ":memory:") { + std::error_code ec; + std::filesystem::remove(m_db_path, ec); + } +} + +bool Store::IsValid() const { + return m_db.OK() && m_ok; +} + +void Store::SetBan(Snowflake guild_id, Snowflake user_id, const BanData &ban) { + auto &s = m_stmt_set_ban; + + s->Bind(1, guild_id); + s->Bind(2, user_id); + s->Bind(3, ban.Reason); + + if (!s->Insert()) + fprintf(stderr, "ban insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(guild_id), static_cast(user_id), m_db.ErrStr()); + + s->Reset(); +} + +void Store::SetChannel(Snowflake id, const ChannelData &chan) { + auto &s = m_stmt_set_chan; + + s->Bind(1, id); + s->Bind(2, chan.Type); + s->Bind(3, chan.GuildID); + s->Bind(4, chan.Position); + s->Bind(5, chan.Name); + s->Bind(6, chan.Topic); + s->Bind(7, chan.IsNSFW); + s->Bind(8, chan.LastMessageID); + s->Bind(9, chan.Bitrate); + s->Bind(10, chan.UserLimit); + s->Bind(11, chan.RateLimitPerUser); + s->Bind(12, chan.Icon); + s->Bind(13, chan.OwnerID); + s->Bind(14, chan.ApplicationID); + s->Bind(15, chan.ParentID); + s->Bind(16, chan.LastPinTimestamp); + if (chan.ThreadMetadata.has_value()) { + s->Bind(17, chan.ThreadMetadata->IsArchived); + s->Bind(18, chan.ThreadMetadata->AutoArchiveDuration); + s->Bind(19, chan.ThreadMetadata->ArchiveTimestamp); + } else { + s->Bind(17); + s->Bind(18); + s->Bind(19); + } + + if (!s->Insert()) + fprintf(stderr, "channel insert failed for %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + + if (chan.Recipients.has_value()) { + BeginTransaction(); + auto &s = m_stmt_set_recipient; + for (const auto &r : *chan.Recipients) { + s->Bind(1, chan.ID); + s->Bind(2, r.ID); + if (!s->Insert()) + fprintf(stderr, "recipient insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(chan.ID), static_cast(r.ID), m_db.ErrStr()); + s->Reset(); + } + EndTransaction(); + } else if (chan.RecipientIDs.has_value()) { + BeginTransaction(); + auto &s = m_stmt_set_recipient; + for (const auto &id : *chan.RecipientIDs) { + s->Bind(1, chan.ID); + s->Bind(2, id); + if (!s->Insert()) + fprintf(stderr, "recipient insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(chan.ID), static_cast(id), m_db.ErrStr()); + s->Reset(); + } + EndTransaction(); + } + + s->Reset(); +} + +void Store::SetEmoji(Snowflake id, const EmojiData &emoji) { + auto &s = m_stmt_set_emoji; + + s->Bind(1, id); + s->Bind(2, emoji.Name); + if (emoji.Creator.has_value()) + s->Bind(3, emoji.Creator->ID); + else + s->Bind(3); + s->Bind(4, emoji.NeedsColons); + s->Bind(5, emoji.IsManaged); + s->Bind(6, emoji.IsAnimated); + s->Bind(7, emoji.IsAvailable); + + if (emoji.Roles.has_value()) { + BeginTransaction(); + + auto &s = m_stmt_set_emoji_role; + + for (const auto &r : *emoji.Roles) { + s->Bind(1, id); + s->Bind(2, r); + if (!s->Insert()) + fprintf(stderr, "emoji role insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(id), static_cast(r), m_db.ErrStr()); + s->Reset(); + } + + EndTransaction(); + } + + if (!s->Insert()) + fprintf(stderr, "emoji insert failed for %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + + s->Reset(); +} + +void Store::SetGuild(Snowflake id, const GuildData &guild) { + BeginTransaction(); + auto &s = m_stmt_set_guild; + + s->Bind(1, guild.ID); + s->Bind(2, guild.Name); + s->Bind(3, guild.Icon); + s->Bind(4, guild.Splash); + s->Bind(5, guild.IsOwner); + s->Bind(6, guild.OwnerID); + s->Bind(7, guild.PermissionsNew); + s->Bind(8, guild.VoiceRegion); + s->Bind(9, guild.AFKChannelID); + s->Bind(10, guild.AFKTimeout); + s->Bind(11, guild.VerificationLevel); + s->Bind(12, guild.DefaultMessageNotifications); + s->Bind(13, guild.MFALevel); + s->Bind(14, guild.ApplicationID); + s->Bind(15, guild.IsWidgetEnabled); + s->Bind(16, guild.WidgetChannelID); + s->Bind(17, guild.SystemChannelFlags); + s->Bind(18, guild.RulesChannelID); + s->Bind(19, guild.JoinedAt); + s->Bind(20, guild.IsLarge); + s->Bind(21, guild.IsUnavailable); + s->Bind(22, guild.MemberCount); + s->Bind(23, guild.MaxPresences); + s->Bind(24, guild.MaxMembers); + s->Bind(25, guild.VanityURL); + s->Bind(26, guild.Description); + s->Bind(27, guild.BannerHash); + s->Bind(28, guild.PremiumTier); + s->Bind(29, guild.PremiumSubscriptionCount); + s->Bind(30, guild.PreferredLocale); + s->Bind(31, guild.PublicUpdatesChannelID); + s->Bind(32, guild.MaxVideoChannelUsers); + s->Bind(33, guild.ApproximateMemberCount); + s->Bind(34, guild.ApproximatePresenceCount); + s->Bind(35, guild.IsLazy); + + if (!s->Insert()) + fprintf(stderr, "guild insert failed for %" PRIu64 ": %s\n", static_cast(guild.ID), m_db.ErrStr()); + + s->Reset(); + + if (guild.Emojis.has_value()) { + auto &s = m_stmt_set_guild_emoji; + for (const auto &emoji : *guild.Emojis) { + s->Bind(1, guild.ID); + s->Bind(2, emoji.ID); + if (!s->Insert()) + fprintf(stderr, "guild emoji insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(guild.ID), static_cast(emoji.ID), m_db.ErrStr()); + s->Reset(); + } + } + + if (guild.Features.has_value()) { + auto &s = m_stmt_set_guild_feature; + + for (const auto &feature : *guild.Features) { + s->Bind(1, guild.ID); + s->Bind(2, feature); + if (!s->Insert()) + fprintf(stderr, "guild feature insert failed for %" PRIu64 "/%s: %s\n", static_cast(guild.ID), feature.c_str(), m_db.ErrStr()); + s->Reset(); + } + } + + if (guild.Threads.has_value()) { + auto &s = m_stmt_set_thread; + + for (const auto &thread : *guild.Threads) { + s->Bind(1, guild.ID); + s->Bind(2, thread.ID); + if (!s->Insert()) + fprintf(stderr, "guild thread insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(guild.ID), static_cast(thread.ID), m_db.ErrStr()); + s->Reset(); + } + } + + EndTransaction(); +} + +void Store::SetGuildMember(Snowflake guild_id, Snowflake user_id, const GuildMember &data) { + auto &s = m_stmt_set_member; + + s->Bind(1, user_id); + s->Bind(2, guild_id); + s->Bind(3, data.Nickname); + s->Bind(4, data.JoinedAt); + s->Bind(5, data.PremiumSince); + s->Bind(6, data.IsDeafened); + s->Bind(7, data.IsMuted); + s->Bind(8, data.Avatar); + s->Bind(9, data.IsPending); + + if (!s->Insert()) + fprintf(stderr, "member insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(user_id), static_cast(guild_id), m_db.ErrStr()); + + s->Reset(); + + { + auto &s = m_stmt_set_member_roles; + + BeginTransaction(); + for (const auto &role : data.Roles) { + s->Bind(1, user_id); + s->Bind(2, role); + if (!s->Insert()) + fprintf(stderr, "member role insert failed for %" PRIu64 "/%" PRIu64 "/%" PRIu64 ": %s\n", + static_cast(user_id), static_cast(guild_id), static_cast(role), m_db.ErrStr()); + s->Reset(); + } + EndTransaction(); + } +} + +void Store::SetMessageInteractionPair(Snowflake message_id, const MessageInteractionData &interaction) { + auto &s = m_stmt_set_interaction; + + s->Bind(1, message_id); + s->Bind(2, interaction.ID); + s->Bind(3, interaction.Type); + s->Bind(4, interaction.Name); + s->Bind(5, interaction.User.ID); + + if (!s->Insert()) + fprintf(stderr, "message interaction failed for %" PRIu64 ": %s\n", static_cast(message_id), m_db.ErrStr()); + + s->Reset(); +} + +void Store::SetMessage(Snowflake id, const Message &message) { + auto &s = m_stmt_set_msg; + + BeginTransaction(); + + s->Bind(1, id); + s->Bind(2, message.ChannelID); + s->Bind(3, message.GuildID); + s->Bind(4, message.Author.ID); + s->Bind(5, message.Content); + s->Bind(6, message.Timestamp); + s->Bind(7, message.EditedTimestamp); + s->Bind(8, message.IsTTS); + s->Bind(9, message.DoesMentionEveryone); + s->BindAsJSON(10, message.Embeds); + s->Bind(11, message.IsPinned); + s->Bind(12, message.WebhookID); + s->Bind(13, message.Type); + s->BindAsJSON(14, message.Application); + s->Bind(15, message.Flags); + s->BindAsJSON(16, message.Stickers); + s->Bind(17, message.IsDeleted()); + s->Bind(18, message.IsEdited()); + s->Bind(19, message.IsPending); + s->Bind(20, message.Nonce); + s->BindAsJSON(21, message.StickerItems); + + if (!s->Insert()) + fprintf(stderr, "message insert failed for %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + + s->Reset(); + + if (message.MessageReference.has_value()) { + auto &s = m_stmt_set_msg_ref; + s->Bind(1, message.ID); + s->Bind(2, message.MessageReference->MessageID); + s->Bind(3, message.MessageReference->ChannelID); + s->Bind(4, message.MessageReference->GuildID); + + if (!s->Insert()) + fprintf(stderr, "message ref insert failed for %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + + s->Reset(); + } + + for (const auto &u : message.Mentions) { + auto &s = m_stmt_set_mention; + s->Bind(1, id); + s->Bind(2, u.ID); + if (!s->Insert()) + fprintf(stderr, "message mention insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(id), static_cast(u.ID), m_db.ErrStr()); + s->Reset(); + } + + for (const auto &a : message.Attachments) { + auto &s = m_stmt_set_attachment; + s->Bind(1, id); + s->Bind(2, a.ID); + s->Bind(3, a.Filename); + s->Bind(4, a.Bytes); + s->Bind(5, a.URL); + s->Bind(6, a.ProxyURL); + s->Bind(7, a.Height); + s->Bind(8, a.Width); + if (!s->Insert()) + fprintf(stderr, "message attachment insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(id), static_cast(a.ID), m_db.ErrStr()); + s->Reset(); + } + + if (message.Reactions.has_value()) { + auto &s = m_stmt_add_reaction; + for (size_t i = 0; i < message.Reactions->size(); i++) { + const auto &reaction = (*message.Reactions)[i]; + s->Bind(1, id); + s->Bind(2, reaction.Emoji.ID); + s->Bind(3, reaction.Emoji.Name); + s->Bind(4, reaction.Count); + s->Bind(5, reaction.HasReactedWith); + s->Bind(6, i); + if (!s->Insert()) + fprintf(stderr, "message reaction insert failed for %" PRIu64 "/%" PRIu64 "/%s: %s\n", static_cast(id), static_cast(reaction.Emoji.ID), reaction.Emoji.Name.c_str(), m_db.ErrStr()); + s->Reset(); + } + } + + if (message.Interaction.has_value()) + SetMessageInteractionPair(id, *message.Interaction); + + EndTransaction(); +} + +void Store::SetPermissionOverwrite(Snowflake channel_id, Snowflake id, const PermissionOverwrite &perm) { + auto &s = m_stmt_set_perm; + + s->Bind(1, perm.ID); + s->Bind(2, channel_id); + s->Bind(3, perm.Type); + s->Bind(4, perm.Allow); + s->Bind(5, perm.Deny); + + if (!s->Insert()) + fprintf(stderr, "permission insert failed for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(channel_id), static_cast(id), m_db.ErrStr()); + + s->Reset(); +} + +void Store::SetRole(Snowflake guild_id, const RoleData &role) { + auto &s = m_stmt_set_role; + + s->Bind(1, role.ID); + s->Bind(2, guild_id); + s->Bind(3, role.Name); + s->Bind(4, role.Color); + s->Bind(5, role.IsHoisted); + s->Bind(6, role.Position); + s->Bind(7, role.Permissions); + s->Bind(8, role.IsManaged); + s->Bind(9, role.IsMentionable); + + if (!s->Insert()) + fprintf(stderr, "role insert failed for %" PRIu64 ": %s\n", static_cast(role.ID), m_db.ErrStr()); + + s->Reset(); +} + +void Store::SetUser(Snowflake id, const UserData &user) { + auto &s = m_stmt_set_user; + + s->Bind(1, id); + s->Bind(2, user.Username); + s->Bind(3, user.Discriminator); + s->Bind(4, user.Avatar); + s->Bind(5, user.IsBot); + s->Bind(6, user.IsSystem); + s->Bind(7, user.IsMFAEnabled); + s->Bind(8, user.PremiumType); + s->Bind(9, user.PublicFlags); + + if (!s->Insert()) + fprintf(stderr, "user insert failed for %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + + s->Reset(); +} + +std::optional Store::GetBan(Snowflake guild_id, Snowflake user_id) const { + auto &s = m_stmt_get_ban; + + s->Bind(1, guild_id); + s->Bind(2, user_id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching ban for %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(guild_id), static_cast(user_id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + BanData r; + r.User.ID = user_id; + s->Get(2, r.Reason); + + s->Reset(); + + return r; +} + +std::vector Store::GetBans(Snowflake guild_id) const { + auto &s = m_stmt_get_bans; + + std::vector ret; + s->Bind(1, guild_id); + while (s->FetchOne()) { + auto &ban = ret.emplace_back(); + s->Get(1, ban.User.ID); + s->Get(2, ban.Reason); + } + + s->Reset(); + + return ret; +} + +std::vector Store::GetLastMessages(Snowflake id, size_t num) const { + auto &s = m_stmt_get_last_msgs; + std::vector msgs; + s->Bind(1, id); + s->Bind(2, num); + while (s->FetchOne()) { + auto msg = GetMessageBound(s); + msgs.push_back(std::move(msg)); + } + + s->Reset(); + + for (auto &msg : msgs) { + if (msg.MessageReference.has_value() && msg.MessageReference->MessageID.has_value()) { + auto ref = GetMessage(*msg.MessageReference->MessageID); + if (ref.has_value()) + msg.ReferencedMessage = std::make_shared(std::move(*ref)); + } + } + + return msgs; +} + +std::vector Store::GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit) const { + std::vector msgs; + + auto &s = m_stmt_get_messages_before; + + s->Bind(1, channel_id); + s->Bind(2, message_id); + s->Bind(3, limit); + + while (s->FetchOne()) { + auto msg = GetMessageBound(s); + msgs.push_back(std::move(msg)); + } + + s->Reset(); + + for (auto &msg : msgs) { + if (msg.MessageReference.has_value() && msg.MessageReference->MessageID.has_value()) { + auto ref = GetMessage(*msg.MessageReference->MessageID); + if (ref.has_value()) + msg.ReferencedMessage = std::make_shared(std::move(*ref)); + } + } + + return msgs; +} + +std::vector Store::GetPinnedMessages(Snowflake channel_id) const { + std::vector msgs; + + auto &s = m_stmt_get_pins; + + s->Bind(1, channel_id); + + while (s->FetchOne()) { + auto msg = GetMessageBound(s); + msgs.push_back(std::move(msg)); + } + + s->Reset(); + + for (auto &msg : msgs) { + if (msg.MessageReference.has_value() && msg.MessageReference->MessageID.has_value()) { + auto ref = GetMessage(*msg.MessageReference->MessageID); + if (ref.has_value()) + msg.ReferencedMessage = std::make_shared(std::move(*ref)); + } + } + + return msgs; +} + +std::vector Store::GetActiveThreads(Snowflake channel_id) const { + std::vector ret; + + auto &s = m_stmt_get_active_threads; + + s->Bind(1, channel_id); + while (s->FetchOne()) { + Snowflake x; + s->Get(0, x); + auto chan = GetChannel(x); + if (chan.has_value()) + ret.push_back(*chan); + } + + s->Reset(); + + return ret; +} + +void Store::AddReaction(const MessageReactionAddObject &data, bool byself) { + auto &s = m_stmt_add_reaction; + + s->Bind(1, data.MessageID); + s->Bind(2, data.Emoji.ID); + s->Bind(3, data.Emoji.Name); + s->Bind(4, 1); + if (byself) + s->Bind(5, true); + else + s->Bind(5); + s->Bind(6); + + if (!s->Insert()) + fprintf(stderr, "failed to add reaction for %" PRIu64 ": %s\n", static_cast(data.MessageID), m_db.ErrStr()); + + s->Reset(); +} + +void Store::RemoveReaction(const MessageReactionRemoveObject &data, bool byself) { + auto &s = m_stmt_sub_reaction; + + s->Bind(1, data.MessageID); + s->Bind(2, data.Emoji.ID); + s->Bind(3, data.Emoji.Name); + if (byself) + s->Bind(4, false); + else + s->Bind(4); + + if (!s->Insert()) + fprintf(stderr, "failed to remove reaction for %" PRIu64 ": %s\n", static_cast(data.MessageID), m_db.ErrStr()); + + s->Reset(); +} + +std::optional Store::GetChannel(Snowflake id) const { + auto &s = m_stmt_get_chan; + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching channel %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + ChannelData r; + + // uncomment as necessary + r.ID = id; + s->Get(1, r.Type); + s->Get(2, r.GuildID); + s->Get(3, r.Position); + s->Get(4, r.Name); + s->Get(5, r.Topic); + s->Get(6, r.IsNSFW); + s->Get(7, r.LastMessageID); + s->Get(10, r.RateLimitPerUser); + s->Get(12, r.OwnerID); + s->Get(14, r.ParentID); + if (!s->IsNull(16)) { + r.ThreadMetadata.emplace(); + s->Get(16, r.ThreadMetadata->IsArchived); + s->Get(17, r.ThreadMetadata->AutoArchiveDuration); + s->Get(18, r.ThreadMetadata->ArchiveTimestamp); + } + + s->Reset(); + + { + auto &s = m_stmt_get_recipients; + s->Bind(1, id); + std::vector recipients; + while (s->FetchOne()) { + auto &r = recipients.emplace_back(); + s->Get(0, r); + } + s->Reset(); + if (!recipients.empty()) + r.RecipientIDs = std::move(recipients); + } + + return r; +} + +std::optional Store::GetEmoji(Snowflake id) const { + auto &s = m_stmt_get_emoji; + + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching emoji %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + EmojiData r; + + r.ID = id; + s->Get(1, r.Name); + if (!s->IsNull(2)) { + r.Creator.emplace(); + s->Get(2, r.Creator->ID); + } + s->Get(3, r.NeedsColons); + s->Get(4, r.IsManaged); + s->Get(5, r.IsAnimated); + s->Get(6, r.IsAvailable); + + { + auto &s = m_stmt_get_emoji_roles; + + s->Bind(1, id); + r.Roles.emplace(); + while (s->FetchOne()) { + Snowflake id; + s->Get(0, id); + r.Roles->push_back(id); + } + s->Reset(); + } + + s->Reset(); + + return r; +} + +std::optional Store::GetGuild(Snowflake id) const { + auto &s = m_stmt_get_guild; + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching guild %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + // unfetched fields arent used anywhere + GuildData r; + r.ID = id; + s->Get(1, r.Name); + s->Get(2, r.Icon); + s->Get(5, r.OwnerID); + s->Get(20, r.IsUnavailable); + + s->Reset(); + + { + auto &s = m_stmt_get_guild_emojis; + + s->Bind(1, id); + r.Emojis.emplace(); + while (s->FetchOne()) { + auto &q = r.Emojis->emplace_back(); + s->Get(0, q.ID); + } + s->Reset(); + } + + { + auto &s = m_stmt_get_guild_features; + + s->Bind(1, id); + r.Features.emplace(); + while (s->FetchOne()) { + std::string feature; + s->Get(0, feature); + r.Features->insert(feature); + } + s->Reset(); + } + + { + auto &s = m_stmt_get_guild_chans; + s->Bind(1, id); + r.Channels.emplace(); + while (s->FetchOne()) { + auto &q = r.Channels->emplace_back(); + s->Get(0, q.ID); + } + s->Reset(); + } + + { + auto &s = m_stmt_get_threads; + s->Bind(1, id); + r.Threads.emplace(); + while (s->FetchOne()) { + auto &q = r.Threads->emplace_back(); + s->Get(0, q.ID); + } + s->Reset(); + } + + return r; +} + +std::optional Store::GetGuildMember(Snowflake guild_id, Snowflake user_id) const { + auto &s = m_stmt_get_member; + + s->Bind(1, user_id); + s->Bind(2, guild_id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching member %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(user_id), static_cast(guild_id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + GuildMember r; + r.User.emplace().ID = user_id; + s->Get(2, r.Nickname); + s->Get(3, r.JoinedAt); + s->Get(4, r.PremiumSince); + //s->Get(5, r.IsDeafened); + //s->Get(6, r.IsMuted); + s->Get(7, r.Avatar); + s->Get(8, r.IsPending); + + s->Reset(); + + { + auto &s = m_stmt_get_member_roles; + + s->Bind(1, user_id); + s->Bind(2, guild_id); + + while (s->FetchOne()) { + auto &f = r.Roles.emplace_back(); + s->Get(0, f); + } + + s->Reset(); + } + + return r; +} + +std::optional Store::GetMessage(Snowflake id) const { + auto &s = m_stmt_get_msg; + + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching message %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + auto top = GetMessageBound(s); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching message %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return top; + } + + auto ref = GetMessageBound(s); + top.ReferencedMessage = std::make_shared(std::move(ref)); + + s->Reset(); + + return top; +} + +Message Store::GetMessageBound(std::unique_ptr &s) const { + Message r; + + s->Get(0, r.ID); + s->Get(1, r.ChannelID); + s->Get(2, r.GuildID); + s->Get(3, r.Author.ID); + s->Get(4, r.Content); + s->Get(5, r.Timestamp); + s->Get(6, r.EditedTimestamp); + //s->Get(7, r.IsTTS); + //s->Get(8, r.DoesMentionEveryone); + s->GetJSON(9, r.Embeds); + s->Get(10, r.IsPinned); + s->Get(11, r.WebhookID); + s->Get(12, r.Type); + s->GetJSON(13, r.Application); + s->Get(14, r.Flags); + s->GetJSON(15, r.Stickers); + bool tmpb; + s->Get(16, tmpb); + if (tmpb) r.SetDeleted(); + s->Get(17, tmpb); + if (tmpb) r.SetEdited(); + s->Get(18, r.IsPending); + s->Get(19, r.Nonce); + s->GetJSON(20, r.StickerItems); + + if (!s->IsNull(21)) { + auto &i = r.Interaction.emplace(); + s->Get(21, i.ID); + s->Get(22, i.Name); + s->Get(23, i.Type); + s->Get(24, i.User.ID); + } + + if (!s->IsNull(25)) { + auto &a = r.Attachments.emplace_back(); + s->Get(25, a.ID); + s->Get(26, a.Filename); + s->Get(27, a.Bytes); + s->Get(28, a.URL); + s->Get(29, a.ProxyURL); + s->Get(30, a.Height); + s->Get(31, a.Width); + } + + if (!s->IsNull(32)) { + auto &q = r.MessageReference.emplace(); + s->Get(32, q.MessageID); + s->Get(33, q.ChannelID); + s->Get(34, q.GuildID); + } + + { + auto &s = m_stmt_get_mentions; + s->Bind(1, r.ID); + while (s->FetchOne()) { + Snowflake id; + s->Get(0, id); + auto user = GetUser(id); + if (user.has_value()) + r.Mentions.push_back(std::move(*user)); + } + s->Reset(); + } + + { + auto &s = m_stmt_get_reactions; + s->Bind(1, r.ID); + std::map tmp; + while (s->FetchOne()) { + size_t idx; + ReactionData q; + s->Get(0, q.Emoji.ID); + s->Get(1, q.Emoji.Name); + s->Get(2, q.Count); + s->Get(3, q.HasReactedWith); + s->Get(4, idx); + tmp[idx] = q; + } + s->Reset(); + + r.Reactions.emplace(); + for (const auto &[idx, reaction] : tmp) + r.Reactions->push_back(reaction); + } + + return r; +} + +std::optional Store::GetPermissionOverwrite(Snowflake channel_id, Snowflake id) const { + auto &s = m_stmt_get_perm; + + s->Bind(1, id); + s->Bind(2, channel_id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "failed while fetching permission %" PRIu64 "/%" PRIu64 ": %s\n", static_cast(channel_id), static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + PermissionOverwrite r; + r.ID = id; + s->Get(2, r.Type); + s->Get(3, r.Allow); + s->Get(4, r.Deny); + + s->Reset(); + + return r; +} + +std::optional Store::GetRole(Snowflake id) const { + auto &s = m_stmt_get_role; + + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching role %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + RoleData r; + + r.ID = id; + //s->Get(1, guild id); + s->Get(2, r.Name); + s->Get(3, r.Color); + s->Get(4, r.IsHoisted); + s->Get(5, r.Position); + s->Get(6, r.Permissions); + s->Get(7, r.IsManaged); + s->Get(8, r.IsMentionable); + + s->Reset(); + + return r; +} + +std::optional Store::GetUser(Snowflake id) const { + auto &s = m_stmt_get_user; + s->Bind(1, id); + if (!s->FetchOne()) { + if (m_db.Error() != SQLITE_DONE) + fprintf(stderr, "error while fetching user %" PRIu64 ": %s\n", static_cast(id), m_db.ErrStr()); + s->Reset(); + return {}; + } + + UserData r; + + r.ID = id; + s->Get(1, r.Username); + s->Get(2, r.Discriminator); + s->Get(3, r.Avatar); + s->Get(4, r.IsBot); + s->Get(5, r.IsSystem); + s->Get(6, r.IsMFAEnabled); + s->Get(7, r.PremiumType); + s->Get(8, r.PublicFlags); + + s->Reset(); + + return r; +} + +void Store::ClearGuild(Snowflake id) { + auto &s = m_stmt_clr_guild; + + s->Bind(1, id); + s->Step(); + s->Reset(); +} + +void Store::ClearChannel(Snowflake id) { + auto &s = m_stmt_clr_chan; + + s->Bind(1, id); + s->Step(); + s->Reset(); +} + +void Store::ClearBan(Snowflake guild_id, Snowflake user_id) { + auto &s = m_stmt_clr_ban; + + s->Bind(1, guild_id); + s->Bind(2, user_id); + s->Step(); + s->Reset(); +} + +void Store::ClearRecipient(Snowflake channel_id, Snowflake user_id) { + auto &s = m_stmt_clr_recipient; + + s->Bind(1, channel_id); + s->Bind(2, user_id); + s->Step(); + s->Reset(); +} + +std::unordered_set Store::GetChannels() const { + auto &s = m_stmt_get_chan_ids; + std::unordered_set r; + + while (s->FetchOne()) { + Snowflake id; + s->Get(0, id); + r.insert(id); + } + + s->Reset(); + + return r; +} + +std::unordered_set Store::GetGuilds() const { + auto &s = m_stmt_get_guild_ids; + std::unordered_set r; + + while (s->FetchOne()) { + Snowflake id; + s->Get(0, id); + r.insert(id); + } + + s->Reset(); + + return r; +} + +void Store::ClearAll() { + if (m_db.Execute(R"( + DELETE FROM attachments; + DELETE FROM bans; + DELETE FROM channels; + DELETE FROM emojis; + DELETE FROM emoji_roles; + DELETE FROM guild_emojis; + DELETE FROM guild_features; + DELETE FROM guilds; + DELETE FROM members; + DELETE FROM member_roles; + DELETE FROM mentions; + DELETE FROM message_interactions; + DELETE FROM message_references; + DELETE FROM messages; + DELETE FROM permissions; + DELETE FROM reactions; + DELETE FROM recipients; + DELETE FROM roles; + DELETE FROM threads; + DELETE FROM users; + )") != SQLITE_OK) { + fprintf(stderr, "failed to clear: %s\n", m_db.ErrStr()); + } +} + +void Store::BeginTransaction() { + m_db.StartTransaction(); +} + +void Store::EndTransaction() { + m_db.EndTransaction(); +} + +bool Store::CreateTables() { + const char *create_users = R"( + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + discriminator TEXT NOT NULL, + avatar TEXT, + bot BOOL, + system BOOL, + mfa BOOL, + premium INTEGER, + pubflags INTEGER + ) + )"; + + const char *create_permissions = R"( + CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + type INTEGER NOT NULL, + allow INTEGER NOT NULL, + deny INTEGER NOT NULL, + PRIMARY KEY(id, channel_id) + ) + )"; + + const char *create_messages = R"( + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL, + guild_id INTEGER, + author_id INTEGER NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + edited_timestamp TEXT, + tts BOOL NOT NULL, + everyone BOOL NOT NULL, + embeds TEXT NOT NULL, /* json */ + pinned BOOL, + webhook_id INTEGER, + type INTEGER, + application TEXT, /* json */ + flags INTEGER, + stickers TEXT, /* json */ + deleted BOOL, /* extra */ + edited BOOL, /* extra */ + pending BOOL, /* extra */ + nonce TEXT, + sticker_items TEXT /* json */ + ) + )"; + + const char *create_roles = R"( + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY, + guild INTEGER NOT NULL, + name TEXT NOT NULL, + color INTEGER NOT NULL, + hoisted BOOL NOT NULL, + position INTEGER NOT NULL, + permissions INTEGER NOT NULL, + managed BOOL NOT NULL, + mentionable BOOL NOT NULL + ) + )"; + + const char *create_emojis = R"( + CREATE TABLE IF NOT EXISTS emojis ( + id INTEGER PRIMARY KEY, /*though nullable, only custom emojis (with non-null ids) are stored*/ + name TEXT NOT NULL, /*same as id*/ + creator_id INTEGER, + colons BOOL, + managed BOOL, + animated BOOL, + available BOOL + ) + )"; + + const char *create_members = R"( + CREATE TABLE IF NOT EXISTS members ( + user_id INTEGER NOT NULL, + guild_id INTEGER NOT NULL, + nickname TEXT, + joined_at TEXT NOT NULL, + premium_since TEXT, + deaf BOOL NOT NULL, + mute BOOL NOT NULL, + avatar TEXT, + pending BOOL, + PRIMARY KEY(user_id, guild_id) + ) + )"; + + const char *create_guilds = R"( + CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT NOT NULL, + splash TEXT, + owner BOOL, + owner_id INTEGER NOT NULL, + permissions INTEGER, /* new */ + voice_region TEXT, + afk_id INTEGER, + afk_timeout INTEGER NOT NULL, + verification INTEGER NOT NULL, + notifications INTEGER NOT NULL, + mfa INTEGER NOT NULL, + application INTEGER, + widget BOOL, + widget_channel INTEGER, + system_flags INTEGER NOT NULL, + rules_channel INTEGER, + joined_at TEXT, + large BOOL, + unavailable BOOL, + member_count INTEGER, + max_presences INTEGER, + max_members INTEGER, + vanity TEXT, + description TEXT, + banner_hash TEXT, + premium_tier INTEGER NOT NULL, + premium_count INTEGER, + locale TEXT NOT NULL, + public_updates_id INTEGER, + max_video_users INTEGER, + approx_members INTEGER, + approx_presences INTEGER, + lazy BOOL + ) + )"; + + const char *create_channels = R"( + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY, + type INTEGER NOT NULL, + guild_id INTEGER, + position INTEGER, + name TEXT, + topic TEXT, + is_nsfw BOOL, + last_message_id INTEGER, + bitrate INTEGER, + user_limit INTEGER, + rate_limit INTEGER, + icon TEXT, + owner_id INTEGER, + application_id INTEGER, + parent_id INTEGER, + last_pin_timestamp TEXT, + archived BOOL, /* threads */ + auto_archive INTEGER, /* threads */ + archived_ts TEXT /* threads */ + ) + )"; + + const char *create_bans = R"( + CREATE TABLE IF NOT EXISTS bans ( + guild_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + reason TEXT, + PRIMARY KEY(user_id, guild_id) + ) + )"; + + const char *create_interactions = R"( + CREATE TABLE IF NOT EXISTS message_interactions ( + message_id INTEGER NOT NULL, + interaction_id INTEGER NOT NULL, + type INTEGER NOT NULL, + name STRING NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY(message_id) + ) + )"; + + const char *create_references = R"( + CREATE TABLE IF NOT EXISTS message_references ( + id INTEGER NOT NULL, + message INTEGER, + channel INTEGER, + guild INTEGER, + PRIMARY KEY(id) + ) + )"; + + const char *create_member_roles = R"( + CREATE TABLE IF NOT EXISTS member_roles ( + user INTEGER NOT NULL, + role INTEGER NOT NULL, + PRIMARY KEY(user, role) + ) + )"; + + const char *create_guild_emojis = R"( + CREATE TABLE IF NOT EXISTS guild_emojis ( + guild INTEGER NOT NULL, + emoji INTEGER NOT NULL, + PRIMARY KEY(guild, emoji) + ) + )"; + + const char *create_guild_features = R"( + CREATE TABLE IF NOT EXISTS guild_features ( + guild INTEGER NOT NULL, + feature CHAR(63) NOT NULL, + PRIMARY KEY(guild, feature) + ) + )"; + + const char *create_threads = R"( + CREATE TABLE IF NOT EXISTS threads ( + guild INTEGER NOT NULL, + id INTEGER NOT NULL, + PRIMARY KEY(guild, id) + ) + )"; + + const char *create_emoji_roles = R"( + CREATE TABLE IF NOT EXISTS emoji_roles ( + emoji INTEGER NOT NULL, + role INTEGER NOT NULL, + PRIMARY KEY(emoji, role) + ) + )"; + + const char *create_mentions = R"( + CREATE TABLE IF NOT EXISTS mentions ( + message INTEGER NOT NULL, + user INTEGER NOT NULL, + PRIMARY KEY(message, user) + ) + )"; + + const char *create_attachments = R"( + CREATE TABLE IF NOT EXISTS attachments ( + message INTEGER NOT NULL, + id INTEGER NOT NULL, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + url TEXT NOT NULL, + proxy TEXT NOT NULL, + height INTEGER, + width INTEGER, + PRIMARY KEY(message, id) + ) + )"; + + const char *create_recipients = R"( + CREATE TABLE IF NOT EXISTS recipients ( + channel INTEGER NOT NULL, + user INTEGER NOT NULL, + PRIMARY KEY(channel, user) + ) + )"; + + const char *create_reactions = R"( + CREATE TABLE IF NOT EXISTS reactions ( + message INTEGER NOT NULL, + emoji_id INTEGER, + name TEXT NOT NULL, + count INTEGER NOT NULL, + me BOOL NOT NULL, + idx INTEGER NOT NULL, + PRIMARY KEY(message, emoji_id, name) + ) + )"; + + if (m_db.Execute(create_users) != SQLITE_OK) { + fprintf(stderr, "failed to create user table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_permissions) != SQLITE_OK) { + fprintf(stderr, "failed to create permissions table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_messages) != SQLITE_OK) { + fprintf(stderr, "failed to create messages table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_roles) != SQLITE_OK) { + fprintf(stderr, "failed to create roles table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_emojis) != SQLITE_OK) { + fprintf(stderr, "failed to create emojis table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_members) != SQLITE_OK) { + fprintf(stderr, "failed to create members table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_guilds) != SQLITE_OK) { + fprintf(stderr, "failed to create guilds table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_channels) != SQLITE_OK) { + fprintf(stderr, "failed to create channels table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_bans) != SQLITE_OK) { + fprintf(stderr, "failed to create bans table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_interactions) != SQLITE_OK) { + fprintf(stderr, "failed to create interactions table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_references) != SQLITE_OK) { + fprintf(stderr, "failed to create references table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_member_roles) != SQLITE_OK) { + fprintf(stderr, "failed to create member roles table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_guild_emojis) != SQLITE_OK) { + fprintf(stderr, "failed to create guild emojis table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_guild_features) != SQLITE_OK) { + fprintf(stderr, "failed to create guild features table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_threads) != SQLITE_OK) { + fprintf(stderr, "failed to create threads table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_emoji_roles) != SQLITE_OK) { + fprintf(stderr, "failed to create emoji roles table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_mentions) != SQLITE_OK) { + fprintf(stderr, "failed to create mentions table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_attachments) != SQLITE_OK) { + fprintf(stderr, "failed to create attachments table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_recipients) != SQLITE_OK) { + fprintf(stderr, "failed to create recipients table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(create_reactions) != SQLITE_OK) { + fprintf(stderr, "failed to create reactions table: %s\n", m_db.ErrStr()); + return false; + } + + if (m_db.Execute(R"( + CREATE TRIGGER remove_zero_reactions AFTER UPDATE ON reactions WHEN new.count = 0 + BEGIN + DELETE FROM reactions WHERE message = new.message AND emoji_id = new.emoji_id AND name = new.name; + END + )") != SQLITE_OK) { + fprintf(stderr, "failed to create reactions trigger: %s\n", m_db.ErrStr()); + return false; + } + + return true; +} + +bool Store::CreateStatements() { + m_stmt_set_guild = std::make_unique(m_db, R"( + REPLACE INTO guilds VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_guild->OK()) { + fprintf(stderr, "failed to prepare set guild statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_guild = std::make_unique(m_db, R"( + SELECT * FROM guilds WHERE id = ? + )"); + if (!m_stmt_get_guild->OK()) { + fprintf(stderr, "failed to prepare get guild statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_guild_ids = std::make_unique(m_db, R"( + SELECT id FROM guilds + )"); + if (!m_stmt_get_guild_ids->OK()) { + fprintf(stderr, "failed to prepare get guild ids statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_clr_guild = std::make_unique(m_db, R"( + DELETE FROM guilds WHERE id = ? + )"); + if (!m_stmt_clr_guild->OK()) { + fprintf(stderr, "failed to prepare clear guild statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_chan = std::make_unique(m_db, R"( + REPLACE INTO channels VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_chan->OK()) { + fprintf(stderr, "failed to prepare set channel statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_chan = std::make_unique(m_db, R"( + SELECT * FROM channels WHERE id = ? + )"); + if (!m_stmt_get_chan->OK()) { + fprintf(stderr, "failed to prepare get channel statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_chan_ids = std::make_unique(m_db, R"( + SELECT id FROM channels + )"); + if (!m_stmt_get_chan_ids->OK()) { + fprintf(stderr, "failed to prepare get channel ids statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_clr_chan = std::make_unique(m_db, R"( + DELETE FROM channels WHERE id = ? + )"); + if (!m_stmt_clr_chan->OK()) { + fprintf(stderr, "failed to prepare clear channel statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_msg = std::make_unique(m_db, R"( + REPLACE INTO messages VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_msg->OK()) { + fprintf(stderr, "failed to prepare set message statement: %s\n", m_db.ErrStr()); + return false; + } + + // wew + m_stmt_get_msg = std::make_unique(m_db, R"( + SELECT messages.*, + message_interactions.interaction_id, + message_interactions.name, + message_interactions.type, + message_interactions.user_id, + attachments.id, + attachments.filename, + attachments.size, + attachments.url, + attachments.proxy, + attachments.height, + attachments.width, + message_references.message, + message_references.channel, + message_references.guild + FROM messages + LEFT OUTER JOIN + message_interactions + ON messages.id = message_interactions.message_id + LEFT OUTER JOIN + attachments + ON messages.id = attachments.message + LEFT OUTER JOIN + message_references + ON messages.id = message_references.id + WHERE messages.id = ?1 + UNION ALL + SELECT messages.*, + message_interactions.interaction_id, + message_interactions.name, + message_interactions.type, + message_interactions.user_id, + attachments.id, + attachments.filename, + attachments.size, + attachments.url, + attachments.proxy, + attachments.height, + attachments.width, + message_references.message, + message_references.channel, + message_references.guild + FROM messages + LEFT OUTER JOIN + message_interactions + ON messages.id = message_interactions.message_id + LEFT OUTER JOIN + attachments + ON messages.id = attachments.message + LEFT OUTER JOIN + message_references + ON messages.id = message_references.id + WHERE messages.id = (SELECT message FROM message_references WHERE id = ?1) + ORDER BY messages.id DESC + )"); + if (!m_stmt_get_msg->OK()) { + fprintf(stderr, "failed to prepare get message statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_msg_ref = std::make_unique(m_db, R"( + REPLACE INTO message_references VALUES ( + ?, ?, ?, ? + ); + )"); + if (!m_stmt_set_msg_ref->OK()) { + fprintf(stderr, "failed to prepare set message reference statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_last_msgs = std::make_unique(m_db, R"( + SELECT * FROM ( + SELECT messages.*, + message_interactions.interaction_id, + message_interactions.name, + message_interactions.type, + message_interactions.user_id, + attachments.id, + attachments.filename, + attachments.size, + attachments.url, + attachments.proxy, + attachments.height, + attachments.width, + message_references.message, + message_references.channel, + message_references.guild + FROM messages + LEFT OUTER JOIN + message_interactions + ON messages.id = message_interactions.message_id + LEFT OUTER JOIN + attachments + ON messages.id = attachments.message + LEFT OUTER JOIN + message_references + ON messages.id = message_references.id + WHERE channel_id = ? AND pending = 0 ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC + )"); + if (!m_stmt_get_last_msgs->OK()) { + fprintf(stderr, "failed to prepare get last messages statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_user = std::make_unique(m_db, R"( + REPLACE INTO users VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_user->OK()) { + fprintf(stderr, "failed to prepare set user statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_user = std::make_unique(m_db, R"( + SELECT * FROM users WHERE id = ? + )"); + if (!m_stmt_get_user->OK()) { + fprintf(stderr, "failed to prepare get user statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_member = std::make_unique(m_db, R"( + REPLACE INTO members VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_member->OK()) { + fprintf(stderr, "failed to prepare set member statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_member = std::make_unique(m_db, R"( + SELECT * FROM members WHERE user_id = ? AND guild_id = ? + )"); + if (!m_stmt_get_member->OK()) { + fprintf(stderr, "failed to prepare get member statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_role = std::make_unique(m_db, R"( + REPLACE INTO roles VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_role->OK()) { + fprintf(stderr, "failed to prepare set role statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_role = std::make_unique(m_db, R"( + SELECT * FROM roles WHERE id = ? + )"); + if (!m_stmt_get_role->OK()) { + fprintf(stderr, "failed to prepare get role statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_emoji = std::make_unique(m_db, R"( + REPLACE INTO emojis VALUES ( + ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_emoji->OK()) { + fprintf(stderr, "failed to prepare set emoji statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_emoji = std::make_unique(m_db, R"( + SELECT * FROM emojis WHERE id = ? + )"); + if (!m_stmt_get_emoji->OK()) { + fprintf(stderr, "failed to prepare get emoji statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_perm = std::make_unique(m_db, R"( + REPLACE INTO permissions VALUES ( + ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_perm->OK()) { + fprintf(stderr, "failed to prepare set permission statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_perm = std::make_unique(m_db, R"( + SELECT * FROM permissions WHERE id = ? AND channel_id = ? + )"); + if (!m_stmt_get_perm->OK()) { + fprintf(stderr, "failed to prepare get permission statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_ban = std::make_unique(m_db, R"( + REPLACE INTO bans VALUES ( + ?, ?, ? + ) + )"); + if (!m_stmt_set_ban->OK()) { + fprintf(stderr, "failed to prepare set ban statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_ban = std::make_unique(m_db, R"( + SELECT * FROM bans WHERE guild_id = ? AND user_id = ? + )"); + if (!m_stmt_get_ban->OK()) { + fprintf(stderr, "failed to prepare get ban statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_bans = std::make_unique(m_db, R"( + SELECT * FROM bans WHERE guild_id = ? + )"); + if (!m_stmt_get_bans->OK()) { + fprintf(stderr, "failed to prepare get bans statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_clr_ban = std::make_unique(m_db, R"( + DELETE FROM bans WHERE guild_id = ? AND user_id = ? + )"); + if (!m_stmt_clr_ban->OK()) { + fprintf(stderr, "failed to prepare clear ban statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_interaction = std::make_unique(m_db, R"( + REPLACE INTO message_interactions VALUES ( + ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_interaction->OK()) { + fprintf(stderr, "failed to prepare set interaction statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_member_roles = std::make_unique(m_db, R"( + REPLACE INTO member_roles VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_member_roles->OK()) { + fprintf(stderr, "faile to prepare set member roles statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_member_roles = std::make_unique(m_db, R"( + SELECT id FROM roles, member_roles + WHERE roles.id = member_roles.role + AND member_roles.user = ? + AND roles.guild = ? + )"); + if (!m_stmt_get_member_roles->OK()) { + fprintf(stderr, "failed to prepare get member role statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_guild_emoji = std::make_unique(m_db, R"( + REPLACE INTO guild_emojis VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_guild_emoji->OK()) { + fprintf(stderr, "failed to prepare set guild emoji statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_guild_emojis = std::make_unique(m_db, R"( + SELECT emoji FROM guild_emojis WHERE guild = ? + )"); + if (!m_stmt_get_guild_emojis->OK()) { + fprintf(stderr, "failed to prepare get guild emojis statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_clr_guild_emoji = std::make_unique(m_db, R"( + DELETE FROM guild_emojis WHERE guild = ? AND emoji = ? + )"); + if (!m_stmt_clr_guild_emoji->OK()) { + fprintf(stderr, "failed to prepare clear guild emoji statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_guild_feature = std::make_unique(m_db, R"( + REPLACE INTO guild_features VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_guild_feature->OK()) { + fprintf(stderr, "failed to prepare set guild feature statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_guild_features = std::make_unique(m_db, R"( + SELECT feature FROM guild_features WHERE guild = ? + )"); + if (!m_stmt_get_guild_features->OK()) { + fprintf(stderr, "failed to prepare get guild features statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_guild_chans = std::make_unique(m_db, R"( + SELECT id FROM channels WHERE guild_id = ? + )"); + if (!m_stmt_get_guild_chans->OK()) { + fprintf(stderr, "failed to prepare get guild channels statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_thread = std::make_unique(m_db, R"( + REPLACE INTO threads VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_thread->OK()) { + fprintf(stderr, "failed to prepare set thread statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_threads = std::make_unique(m_db, R"( + SELECT id FROM threads WHERE guild = ? + )"); + if (!m_stmt_get_threads->OK()) { + fprintf(stderr, "failed to prepare get threads statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_active_threads = std::make_unique(m_db, R"( + SELECT id FROM channels WHERE parent_id = ? AND (type = 10 OR type = 11 OR type = 12) AND archived = FALSE + )"); + if (!m_stmt_get_active_threads->OK()) { + fprintf(stderr, "faile to prepare get active threads statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_messages_before = std::make_unique(m_db, R"( + SELECT * FROM ( + SELECT messages.*, + message_interactions.interaction_id, + message_interactions.name, + message_interactions.type, + message_interactions.user_id, + attachments.id, + attachments.filename, + attachments.size, + attachments.url, + attachments.proxy, + attachments.height, + attachments.width, + message_references.message + FROM messages + LEFT OUTER JOIN + message_interactions + ON messages.id = message_interactions.message_id + LEFT OUTER JOIN + attachments + ON messages.id = attachments.message + LEFT OUTER JOIN + message_references + ON messages.id = message_references.id + WHERE channel_id = ? AND pending = 0 AND messages.id < ? ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC + )"); + if (!m_stmt_get_messages_before->OK()) { + fprintf(stderr, "failed to prepare get messages before statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_pins = std::make_unique(m_db, R"( + SELECT messages.*, + message_interactions.interaction_id, + message_interactions.name, + message_interactions.type, + message_interactions.user_id, + attachments.id, + attachments.filename, + attachments.size, + attachments.url, + attachments.proxy, + attachments.height, + attachments.width, + message_references.message, + message_references.channel, + message_references.guild + FROM messages + LEFT OUTER JOIN + message_interactions + ON messages.id = message_interactions.message_id + LEFT OUTER JOIN + attachments + ON messages.id = attachments.message + LEFT OUTER JOIN + message_references + ON messages.id = message_references.id + WHERE channel_id = ? AND pinned = 1 ORDER BY id ASC + )"); + if (!m_stmt_get_pins->OK()) { + fprintf(stderr, "failed to prepare get pins statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_emoji_role = std::make_unique(m_db, R"( + REPLACE INTO emoji_roles VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_emoji_role->OK()) { + fprintf(stderr, "failed to prepare set emoji role statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_emoji_roles = std::make_unique(m_db, R"( + SELECT role FROM emoji_roles WHERE emoji = ? + )"); + if (!m_stmt_get_emoji_roles->OK()) { + fprintf(stderr, "failed to prepare get emoji role statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_mention = std::make_unique(m_db, R"( + REPLACE INTO mentions VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_mention->OK()) { + fprintf(stderr, "failed to prepare set mention statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_mentions = std::make_unique(m_db, R"( + SELECT user FROM mentions WHERE message = ? + )"); + if (!m_stmt_get_mentions->OK()) { + fprintf(stderr, "failed to prepare get mentions statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_attachment = std::make_unique(m_db, R"( + REPLACE INTO attachments VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? + ) + )"); + if (!m_stmt_set_attachment->OK()) { + fprintf(stderr, "failed to prepare set attachment statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_attachments = std::make_unique(m_db, R"( + SELECT * FROM attachments WHERE message = ? + )"); + if (!m_stmt_get_attachments->OK()) { + fprintf(stderr, "failed to prepare get attachments statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_set_recipient = std::make_unique(m_db, R"( + REPLACE INTO recipients VALUES ( + ?, ? + ) + )"); + if (!m_stmt_set_recipient->OK()) { + fprintf(stderr, "failed to prepare set recipient statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_recipients = std::make_unique(m_db, R"( + SELECT user FROM recipients WHERE channel = ? + )"); + if (!m_stmt_get_recipients->OK()) { + fprintf(stderr, "failed to prepare get recipients statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_clr_recipient = std::make_unique(m_db, R"( + DELETE FROM recipients WHERE channel = ? AND user = ? + )"); + if (!m_stmt_clr_recipient->OK()) { + fprintf(stderr, "failed to prepare clear recipient statement: %s\n", m_db.ErrStr()); + return false; + } + + // probably not the best way to do this lol but i just want one statement i guess + m_stmt_add_reaction = std::make_unique(m_db, R"( + INSERT OR REPLACE INTO reactions VALUES ( + ?1, ?2, ?3, + COALESCE( + (SELECT count FROM reactions WHERE message = ?1 AND emoji_id = ?2 AND name = ?3), + 0 + ) + ?4, + COALESCE( + ?5, + (SELECT me FROM reactions WHERE message = ?1 AND emoji_id = ?2 AND name = ?3), + false + ), + COALESCE( + ?6, + (SELECT idx FROM reactions WHERE message = ?1 AND emoji_id = ?2 AND name = ?3), + (SELECT MAX(idx) + 1 FROM reactions WHERE message = ?1), + 0 + ) + ) + )"); + if (!m_stmt_add_reaction->OK()) { + fprintf(stderr, "failed to prepare add reaction statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_sub_reaction = std::make_unique(m_db, R"( + UPDATE reactions + SET count = count - 1, + me = COALESCE(?4, me) + WHERE message = ?1 AND emoji_id = ?2 AND name = ?3 + )"); + if (!m_stmt_sub_reaction->OK()) { + fprintf(stderr, "failed to prepare sub reaction statement: %s\n", m_db.ErrStr()); + return false; + } + + m_stmt_get_reactions = std::make_unique(m_db, R"( + SELECT emoji_id, name, count, me, idx FROM reactions WHERE message = ? + )"); + if (!m_stmt_get_reactions->OK()) { + fprintf(stderr, "failed to prepare get reactions statement: %s\n", m_db.ErrStr()); + return false; + } + + return true; +} + +Store::Database::Database(const char *path) { + m_err = sqlite3_open(path, &m_db); +} + +Store::Database::~Database() { + Close(); +} + +int Store::Database::Close() { + if (m_db == nullptr) return m_err; + m_signal_close.emit(); + m_err = sqlite3_close(m_db); + m_db = nullptr; + return m_err; +} + +int Store::Database::StartTransaction() { + return m_err = Execute("BEGIN TRANSACTION"); +} + +int Store::Database::EndTransaction() { + return m_err = Execute("COMMIT"); +} + +int Store::Database::Execute(const char *command) { + return m_err = sqlite3_exec(m_db, command, nullptr, nullptr, nullptr); +} + +int Store::Database::Error() const { + return m_err; +} + +bool Store::Database::OK() const { + return Error() == SQLITE_OK; +} + +const char *Store::Database::ErrStr() const { + const char *errstr = sqlite3_errstr(m_err); + const char *errmsg = sqlite3_errmsg(m_db); + std::string tmp = errstr + std::string("\n\t") + errmsg; + tmp.copy(m_err_scratch, sizeof(m_err_scratch) - 1); + m_err_scratch[std::min(tmp.size(), sizeof(m_err_scratch) - 1)] = '\0'; + return m_err_scratch; +} + +int Store::Database::SetError(int err) { + return m_err = err; +} + +sqlite3 *Store::Database::obj() { + return m_db; +} + +Store::Database::type_signal_close Store::Database::signal_close() { + return m_signal_close; +} + +Store::Statement::Statement(Database &db, const char *command) + : m_db(&db) { + if (m_db->SetError(sqlite3_prepare_v2(m_db->obj(), command, -1, &m_stmt, nullptr)) != SQLITE_OK) return; + std::string tmp = command; + m_db->signal_close().connect([tmp, this] { + sqlite3_finalize(m_stmt); + m_stmt = nullptr; + }); +} + +Store::Statement::~Statement() { + sqlite3_finalize(m_stmt); +} + +bool Store::Statement::OK() const { + return m_stmt != nullptr; +} + +int Store::Statement::Bind(int index, Snowflake id) { + return Bind(index, static_cast(id)); +} + +int Store::Statement::Bind(int index, const char *str, size_t len) { + if (len == -1) len = strlen(str); + return m_db->SetError(sqlite3_bind_blob(m_stmt, index, str, len, SQLITE_TRANSIENT)); +} + +int Store::Statement::Bind(int index, const std::string &str) { + return m_db->SetError(sqlite3_bind_blob(m_stmt, index, str.c_str(), str.size(), SQLITE_TRANSIENT)); +} + +int Store::Statement::Bind(int index) { + return m_db->SetError(sqlite3_bind_null(m_stmt, index)); +} + +void Store::Statement::Get(int index, Snowflake &out) const { + out = static_cast(sqlite3_column_int64(m_stmt, index)); +} + +void Store::Statement::Get(int index, std::string &out) const { + const unsigned char *ptr = sqlite3_column_text(m_stmt, index); + if (ptr == nullptr) + out = ""; + else + out = reinterpret_cast(ptr); +} + +bool Store::Statement::IsNull(int index) const { + return sqlite3_column_type(m_stmt, index) == SQLITE_NULL; +} + +int Store::Statement::Step() { + return m_db->SetError(sqlite3_step(m_stmt)); +} + +bool Store::Statement::Insert() { + return m_db->SetError(sqlite3_step(m_stmt)) == SQLITE_DONE; +} + +bool Store::Statement::FetchOne() { + return m_db->SetError(sqlite3_step(m_stmt)) == SQLITE_ROW; +} + +int Store::Statement::Reset() { + if (m_db->SetError(sqlite3_reset(m_stmt)) != SQLITE_OK) + return m_db->Error(); + if (m_db->SetError(sqlite3_clear_bindings(m_stmt)) != SQLITE_OK) + return m_db->Error(); + return m_db->Error(); +} + +sqlite3_stmt *Store::Statement::obj() { + return m_stmt; +} diff --git a/src/discord/store.hpp b/src/discord/store.hpp new file mode 100644 index 0000000..80e2407 --- /dev/null +++ b/src/discord/store.hpp @@ -0,0 +1,302 @@ +#pragma once +#include "util.hpp" +#include "objects.hpp" +#include +#include +#include +#include +#include + +#ifdef GetMessage // fuck you windows.h + #undef GetMessage +#endif + +class Store { +public: + Store(bool mem_store = false); + ~Store(); + + bool IsValid() const; + + void SetUser(Snowflake id, const UserData &user); + void SetChannel(Snowflake id, const ChannelData &chan); + void SetGuild(Snowflake id, const GuildData &guild); + void SetRole(Snowflake guild_id, const RoleData &role); + void SetMessage(Snowflake id, const Message &message); + void SetGuildMember(Snowflake guild_id, Snowflake user_id, const GuildMember &data); + void SetPermissionOverwrite(Snowflake channel_id, Snowflake id, const PermissionOverwrite &perm); + void SetEmoji(Snowflake id, const EmojiData &emoji); + void SetBan(Snowflake guild_id, Snowflake user_id, const BanData &ban); + + std::optional GetChannel(Snowflake id) const; + std::optional GetEmoji(Snowflake id) const; + std::optional GetGuild(Snowflake id) const; + std::optional GetGuildMember(Snowflake guild_id, Snowflake user_id) const; + std::optional GetMessage(Snowflake id) const; + std::optional GetPermissionOverwrite(Snowflake channel_id, Snowflake id) const; + std::optional GetRole(Snowflake id) const; + std::optional GetUser(Snowflake id) const; + std::optional GetBan(Snowflake guild_id, Snowflake user_id) const; + std::vector GetBans(Snowflake guild_id) const; + + std::vector GetLastMessages(Snowflake id, size_t num) const; + std::vector GetMessagesBefore(Snowflake channel_id, Snowflake message_id, size_t limit) const; + std::vector GetPinnedMessages(Snowflake channel_id) const; + std::vector GetActiveThreads(Snowflake channel_id) const; // public + + void AddReaction(const MessageReactionAddObject &data, bool byself); + void RemoveReaction(const MessageReactionRemoveObject &data, bool byself); + + void ClearGuild(Snowflake id); + void ClearChannel(Snowflake id); + void ClearBan(Snowflake guild_id, Snowflake user_id); + void ClearRecipient(Snowflake channel_id, Snowflake user_id); + + std::unordered_set GetChannels() const; + std::unordered_set GetGuilds() const; + + void ClearAll(); + + void BeginTransaction(); + void EndTransaction(); + +private: + class Statement; + class Database { + public: + Database(const char *path); + ~Database(); + + int Close(); + int StartTransaction(); + int EndTransaction(); + int Execute(const char *command); + int Error() const; + bool OK() const; + const char *ErrStr() const; + int SetError(int err); + sqlite3 *obj(); + + private: + sqlite3 *m_db; + int m_err = SQLITE_OK; + mutable char m_err_scratch[256] { 0 }; + + // stupid shit i dont like to allow closing properly + using type_signal_close = sigc::signal; + type_signal_close m_signal_close; + + public: + type_signal_close signal_close(); + }; + + class Statement { + public: + Statement() = delete; + Statement(const Statement &other) = delete; + Statement(Database &db, const char *command); + ~Statement(); + Statement &operator=(Statement &other) = delete; + + bool OK() const; + + int Bind(int index, Snowflake id); + int Bind(int index, const char *str, size_t len = -1); + int Bind(int index, const std::string &str); + int Bind(int index); + + template + int Bind(int index, std::optional opt) { + if (opt.has_value()) + return Bind(index, opt.value()); + else + return Bind(index); + } + + template + int BindIDsAsJSON(int index, Iter start, Iter end) { + std::vector x; + for (Iter it = start; it != end; it++) { + x.push_back((*it).ID); + } + return Bind(index, nlohmann::json(x).dump()); + } + + template + int BindAsJSONArray(int index, const std::optional &obj) { + if (obj.has_value()) + return Bind(index, nlohmann::json(obj.value()).dump()); + else + return Bind(index, std::string("[]")); + } + + template + int BindAsJSON(int index, const T &obj) { + return Bind(index, nlohmann::json(obj).dump()); + } + + template + inline typename std::enable_if::value, int>::type + Bind(int index, T val) { + return Bind(index, static_cast::type>(val)); + } + + template + typename std::enable_if::value, int>::type + Bind(int index, T val) { + return m_db->SetError(sqlite3_bind_int64(m_stmt, index, val)); + } + + template + int BindAsJSON(int index, const std::optional &obj) { + if (obj.has_value()) + return Bind(index, nlohmann::json(obj.value()).dump()); + else + return Bind(index); + } + + template + typename std::enable_if::value>::type + Get(int index, T &out) const { + out = static_cast(sqlite3_column_int64(m_stmt, index)); + } + + void Get(int index, Snowflake &out) const; + void Get(int index, std::string &out) const; + + template + void GetJSON(int index, std::optional &out) const { + if (IsNull(index)) + out = std::nullopt; + else { + std::string stuff; + Get(index, stuff); + if (stuff == "") + out = std::nullopt; + else + out = nlohmann::json::parse(stuff).get(); + } + } + + template + void GetJSON(int index, T &out) const { + std::string stuff; + Get(index, stuff); + nlohmann::json::parse(stuff).get_to(out); + } + + template + void Get(int index, std::optional &out) const { + if (IsNull(index)) + out = std::nullopt; + else { + T tmp; + Get(index, tmp); + out = std::optional(std::move(tmp)); + } + } + + template + inline typename std::enable_if::value, void>::type + Get(int index, T &val) const { + typename std::underlying_type::type tmp; + Get(index, tmp); + val = static_cast(tmp); + } + + template + void GetIDOnlyStructs(int index, std::optional> &out) const { + out.emplace(); + std::string str; + Get(index, str); + for (const auto &id : nlohmann::json::parse(str)) + out->emplace_back().ID = id.get(); + } + + template + void GetArray(int index, OutputIt first) const { + std::string str; + Get(index, str); + for (const auto &id : nlohmann::json::parse(str)) + *first++ = id.get(); + } + + bool IsNull(int index) const; + int Step(); + bool Insert(); + bool FetchOne(); + int Reset(); + + sqlite3_stmt *obj(); + + private: + Database *m_db; + sqlite3_stmt *m_stmt; + }; + + Message GetMessageBound(std::unique_ptr &stmt) const; + + void SetMessageInteractionPair(Snowflake message_id, const MessageInteractionData &interaction); + + bool CreateTables(); + bool CreateStatements(); + + bool m_ok = true; + + std::filesystem::path m_db_path; + Database m_db; +#define STMT(x) mutable std::unique_ptr m_stmt_##x + STMT(set_guild); + STMT(get_guild); + STMT(get_guild_ids); + STMT(clr_guild); + STMT(set_chan); + STMT(get_chan); + STMT(get_chan_ids); + STMT(clr_chan); + STMT(set_msg); + STMT(get_msg); + STMT(set_msg_ref); + STMT(get_last_msgs); + STMT(set_user); + STMT(get_user); + STMT(set_member); + STMT(get_member); + STMT(set_role); + STMT(get_role); + STMT(set_emoji); + STMT(get_emoji); + STMT(set_perm); + STMT(get_perm); + STMT(set_ban); + STMT(get_ban); + STMT(get_bans); + STMT(clr_ban); + STMT(set_interaction); + STMT(set_member_roles); + STMT(get_member_roles); + STMT(set_guild_emoji); + STMT(get_guild_emojis); + STMT(clr_guild_emoji); + STMT(set_guild_feature); + STMT(get_guild_features); + STMT(get_guild_chans); + STMT(set_thread); + STMT(get_threads); + STMT(get_active_threads); + STMT(get_messages_before); + STMT(get_pins); + STMT(set_emoji_role); + STMT(get_emoji_roles); + STMT(set_mention); + STMT(get_mentions); + STMT(set_attachment); + STMT(get_attachments); + STMT(set_recipient); + STMT(get_recipients); + STMT(clr_recipient); + STMT(add_reaction); + STMT(sub_reaction); + STMT(get_reactions); +#undef STMT +}; diff --git a/src/discord/user.cpp b/src/discord/user.cpp new file mode 100644 index 0000000..fae212d --- /dev/null +++ b/src/discord/user.cpp @@ -0,0 +1,197 @@ +#include "user.hpp" +#include "abaddon.hpp" + +bool UserData::IsDeleted() const { + return Discriminator == "0000"; +} + +bool UserData::HasAvatar() const { + return Avatar.size() > 0; +} + +bool UserData::HasAnimatedAvatar() const { + return Avatar.size() > 0 && Avatar[0] == 'a' && Avatar[1] == '_'; +} + +std::string UserData::GetAvatarURL(Snowflake guild_id, std::string ext, std::string size) const { + const auto member = Abaddon::Get().GetDiscordClient().GetMember(ID, guild_id); + if (member.has_value() && member->Avatar.has_value()) + return "https://cdn.discordapp.com/guilds/" + + std::to_string(guild_id) + "/users/" + std::to_string(ID) + + "/avatars/" + *member->Avatar + "." + + ext + "?" + "size=" + size; + else + return GetAvatarURL(ext, size); +} + +std::string UserData::GetAvatarURL(const std::optional &guild_id, std::string ext, std::string size) const { + if (guild_id.has_value()) + return GetAvatarURL(*guild_id, ext, size); + else + return GetAvatarURL(ext, size); +} + +std::string UserData::GetAvatarURL(std::string ext, std::string size) const { + if (HasAvatar()) + return "https://cdn.discordapp.com/avatars/" + std::to_string(ID) + "/" + Avatar + "." + ext + "?size=" + size; + else + return GetDefaultAvatarURL(); +} + +std::string UserData::GetDefaultAvatarURL() const { + return "https://cdn.discordapp.com/embed/avatars/" + std::to_string(std::stoul(Discriminator) % 5) + ".png"; // size isn't respected by the cdn +} + +Snowflake UserData::GetHoistedRole(Snowflake guild_id, bool with_color) const { + return Abaddon::Get().GetDiscordClient().GetMemberHoistedRole(guild_id, ID, with_color); +} + +std::string UserData::GetMention() const { + return "<@" + std::to_string(ID) + ">"; +} + +std::string UserData::GetEscapedName() const { + return Glib::Markup::escape_text(Username); +} + +std::string UserData::GetEscapedBoldName() const { + return "" + Glib::Markup::escape_text(Username) + ""; +} + +std::string UserData::GetEscapedString() const { + return Glib::Markup::escape_text(Username) + "#" + Discriminator; +} + +void from_json(const nlohmann::json &j, UserData &m) { + JS_D("id", m.ID); + JS_D("username", m.Username); + JS_D("discriminator", m.Discriminator); + JS_N("avatar", m.Avatar); + JS_O("bot", m.IsBot); + JS_O("system", m.IsSystem); + JS_O("mfa_enabled", m.IsMFAEnabled); + JS_O("locale", m.Locale); + JS_O("verified", m.IsVerified); + JS_O("email", m.Email); + JS_O("flags", m.Flags); + JS_ON("premium_type", m.PremiumType); + JS_O("public_flags", m.PublicFlags); + JS_O("desktop", m.IsDesktop); + JS_O("mobile", m.IsMobile); + JS_ON("nsfw_allowed", m.IsNSFWAllowed); + JS_ON("phone", m.Phone); + JS_ON("bio", m.Bio); + JS_ON("banner", m.BannerHash); +} + +void to_json(nlohmann::json &j, const UserData &m) { + j["id"] = m.ID; + j["username"] = m.Username; + j["discriminator"] = m.Discriminator; + if (m.Avatar == "") + j["avatar"] = nullptr; + else + j["avatar"] = m.Avatar; + JS_IF("bot", m.IsBot); + JS_IF("system", m.IsSystem); + JS_IF("mfa_enabled", m.IsMFAEnabled); + JS_IF("locale", m.Locale); + JS_IF("verified", m.IsVerified); + JS_IF("email", m.Email); + JS_IF("flags", m.Flags); + JS_IF("premium_type", m.PremiumType); + JS_IF("public_flags", m.PublicFlags); + JS_IF("desktop", m.IsDesktop); + JS_IF("mobile", m.IsMobile); + JS_IF("nsfw_allowed", m.IsNSFWAllowed); + JS_IF("phone", m.Phone); +} + +void UserData::update_from_json(const nlohmann::json &j) { + JS_RD("username", Username); + JS_RD("discriminator", Discriminator); + JS_RD("avatar", Avatar); + JS_RD("bot", IsBot); + JS_RD("system", IsSystem); + JS_RD("mfa_enabled", IsMFAEnabled); + JS_RD("locale", Locale); + JS_RD("verified", IsVerified); + JS_RD("email", Email); + JS_RD("flags", Flags); + JS_RD("premium_type", PremiumType); + JS_RD("public_flags", PublicFlags); + JS_RD("desktop", IsDesktop); + JS_RD("mobile", IsMobile); + JS_RD("nsfw_allowed", IsNSFWAllowed); + JS_RD("phone", Phone); +} + +const char *UserData::GetFlagName(uint64_t flag) { + switch (flag) { + case DiscordEmployee: + return "discordstaff"; + case PartneredServerOwner: + return "partneredowner"; + case HypeSquadEvents: + return "hypesquadevents"; + case BugHunterLevel1: + return "discordbughunter"; + case HouseBravery: + return "hypesquadbravery"; + case HouseBrilliance: + return "hypesquadbrilliance"; + case HouseBalance: + return "hypesquadbalance"; + case EarlySupporter: + return "earlysupporter"; + case TeamUser: + return "teamuser"; + case System: + return "system"; + case BugHunterLevel2: + return "discordbughunter2"; + case VerifiedBot: + return "verifiedbot"; + case EarlyVerifiedBotDeveloper: + return "earlyverifiedbotdeveloper"; + case CertifiedModerator: + return "certifiedmoderator"; + default: + return "unknown"; + } +} + +const char *UserData::GetFlagReadableName(uint64_t flag) { + switch (flag) { + case DiscordEmployee: + return "Discord Staff"; + case PartneredServerOwner: + return "Partnered Server Owner"; + case HypeSquadEvents: + return "HypeSquad Events"; + case BugHunterLevel1: + return "Discord Bug Hunter"; + case HouseBravery: + return "HypeSquad Bravery"; + case HouseBrilliance: + return "HypeSquad Brilliance"; + case HouseBalance: + return "HypeSquad Balance"; + case EarlySupporter: + return "Early Supporter"; + case TeamUser: + return "Team User"; // ??? + case System: + return "System"; + case BugHunterLevel2: + return "Discord Bug Hunter Level 2"; + case VerifiedBot: + return "Verified Bot"; + case EarlyVerifiedBotDeveloper: + return "Early Verified Bot Developer"; + case CertifiedModerator: + return "Discord Certified Moderator"; + default: + return ""; + } +} diff --git a/src/discord/user.hpp b/src/discord/user.hpp new file mode 100644 index 0000000..d4711fa --- /dev/null +++ b/src/discord/user.hpp @@ -0,0 +1,82 @@ +#pragma once +#include "snowflake.hpp" +#include "json.hpp" +#include + +enum class EPremiumType { + None = 0, + NitroClassic = 1, + Nitro = 2, +}; + +struct UserData { + enum { + DiscordEmployee = 1 << 0, + PartneredServerOwner = 1 << 1, + HypeSquadEvents = 1 << 2, + BugHunterLevel1 = 1 << 3, + HouseBravery = 1 << 6, + HouseBrilliance = 1 << 7, + HouseBalance = 1 << 8, + EarlySupporter = 1 << 9, + TeamUser = 1 << 10, // no idea what this is + System = 1 << 12, + BugHunterLevel2 = 1 << 14, + VerifiedBot = 1 << 16, + EarlyVerifiedBotDeveloper = 1 << 17, + CertifiedModerator = 1 << 18, + + MaxFlag_PlusOne, + MaxFlag = MaxFlag_PlusOne - 1, + }; + + static const char *GetFlagName(uint64_t flag); + static const char *GetFlagReadableName(uint64_t flag); + + Snowflake ID; + std::string Username; + std::string Discriminator; + std::string Avatar; // null + std::optional IsBot; + std::optional IsSystem; + std::optional IsMFAEnabled; + std::optional Locale; + std::optional IsVerified; + std::optional Email; // null + std::optional Flags; + std::optional PremiumType; // null + std::optional PublicFlags; + + // undocumented (opt) + std::optional IsDesktop; + std::optional IsMobile; + std::optional IsNSFWAllowed; // null + std::optional Phone; // null? + // for now (unserialized) + std::optional BannerHash; // null + std::optional Bio; // null + + friend void from_json(const nlohmann::json &j, UserData &m); + friend void to_json(nlohmann::json &j, const UserData &m); + void update_from_json(const nlohmann::json &j); + + bool IsDeleted() const; + bool HasAvatar() const; + bool HasAnimatedAvatar() const; + std::string GetAvatarURL(Snowflake guild_id, std::string ext = "png", std::string size = "32") const; + std::string GetAvatarURL(const std::optional &guild_id, std::string ext = "png", std::string size = "32") const; + std::string GetAvatarURL(std::string ext = "png", std::string size = "32") const; + std::string GetDefaultAvatarURL() const; + Snowflake GetHoistedRole(Snowflake guild_id, bool with_color = false) const; + std::string GetMention() const; + std::string GetEscapedName() const; + std::string GetEscapedBoldName() const; + std::string GetEscapedString() const; + template + inline std::string GetEscapedBoldString() const { + if constexpr (with_at) + return "@" + Glib::Markup::escape_text(Username) + "#" + Discriminator; + else + return "" + Glib::Markup::escape_text(Username) + "#" + Discriminator; + } +}; diff --git a/src/discord/usersettings.cpp b/src/discord/usersettings.cpp new file mode 100644 index 0000000..e4ab41a --- /dev/null +++ b/src/discord/usersettings.cpp @@ -0,0 +1,40 @@ +#include "usersettings.hpp" + +void from_json(const nlohmann::json &j, UserSettingsGuildFoldersEntry &m) { + JS_N("color", m.Color); + JS_D("guild_ids", m.GuildIDs); + JS_N("id", m.ID); + JS_N("name", m.Name); +} + +void from_json(const nlohmann::json &j, UserSettings &m) { + JS_D("timezone_offset", m.TimezoneOffset); + JS_D("theme", m.Theme); + JS_D("stream_notifications_enabled", m.AreStreamNotificationsEnabled); + JS_D("status", m.Status); + JS_D("show_current_game", m.ShouldShowCurrentGame); + // JS_D("restricted_guilds", m.RestrictedGuilds); + JS_D("render_reactions", m.ShouldRenderReactions); + JS_D("render_embeds", m.ShouldRenderEmbeds); + JS_D("native_phone_integration_enabled", m.IsNativePhoneIntegrationEnabled); + JS_D("message_display_compact", m.ShouldMessageDisplayCompact); + JS_D("locale", m.Locale); + JS_D("inline_embed_media", m.ShouldInlineEmbedMedia); + JS_D("inline_attachment_media", m.ShouldInlineAttachmentMedia); + JS_D("guild_positions", m.GuildPositions); + JS_D("guild_folders", m.GuildFolders); + JS_D("gif_auto_play", m.ShouldGIFAutoplay); + // JS_D("friend_source_flags", m.FriendSourceFlags); + JS_D("explicit_content_filter", m.ExplicitContentFilter); + JS_D("enable_tts_command", m.IsTTSCommandEnabled); + JS_D("disable_games_tab", m.ShouldDisableGamesTab); + JS_D("developer_mode", m.DeveloperMode); + JS_D("detect_platform_accounts", m.ShouldDetectPlatformAccounts); + JS_D("default_guilds_restricted", m.AreDefaultGuildsRestricted); + // JS_N("custom_status", m.CustomStatus); + JS_D("convert_emoticons", m.ShouldConvertEmoticons); + JS_D("contact_sync_enabled", m.IsContactSyncEnabled); + JS_D("animate_emoji", m.ShouldAnimateEmojis); + JS_D("allow_accessibility_detection", m.IsAccessibilityDetectionAllowed); + JS_D("afk_timeout", m.AFKTimeout); +} diff --git a/src/discord/usersettings.hpp b/src/discord/usersettings.hpp new file mode 100644 index 0000000..6d37b3c --- /dev/null +++ b/src/discord/usersettings.hpp @@ -0,0 +1,47 @@ +#pragma once +#include "json.hpp" +#include "snowflake.hpp" +#include + +struct UserSettingsGuildFoldersEntry { + int Color = -1; // null + std::vector GuildIDs; + Snowflake ID; // null (this can be a snowflake as a string or an int that isnt a snowflake lol) + std::string Name; // null + + friend void from_json(const nlohmann::json &j, UserSettingsGuildFoldersEntry &m); +}; + +struct UserSettings { + int TimezoneOffset; // + std::string Theme; // + bool AreStreamNotificationsEnabled; // + std::string Status; // + bool ShouldShowCurrentGame; // + // std::vector RestrictedGuilds; // + bool ShouldRenderReactions; // + bool ShouldRenderEmbeds; // + bool IsNativePhoneIntegrationEnabled; // + bool ShouldMessageDisplayCompact; // + std::string Locale; // + bool ShouldInlineEmbedMedia; // + bool ShouldInlineAttachmentMedia; // + std::vector GuildPositions; // deprecated? + std::vector GuildFolders; // + bool ShouldGIFAutoplay; // + // Unknown FriendSourceFlags; // + int ExplicitContentFilter; // + bool IsTTSCommandEnabled; // + bool ShouldDisableGamesTab; // + bool DeveloperMode; // + bool ShouldDetectPlatformAccounts; // + bool AreDefaultGuildsRestricted; // + // Unknown CustomStatus; // null + bool ShouldConvertEmoticons; // + bool IsContactSyncEnabled; // + bool ShouldAnimateEmojis; // + bool IsAccessibilityDetectionAllowed; // + int AFKTimeout; + + friend void from_json(const nlohmann::json &j, UserSettings &m); +}; diff --git a/src/discord/webhook.cpp b/src/discord/webhook.cpp new file mode 100644 index 0000000..4e8b422 --- /dev/null +++ b/src/discord/webhook.cpp @@ -0,0 +1,13 @@ +#include "webhook.hpp" + +void from_json(const nlohmann::json &j, WebhookData &m) { + JS_D("id", m.ID); + JS_D("type", m.Type); + JS_O("guild_id", m.GuildID); + JS_D("channel_id", m.ChannelID); + JS_O("user", m.User); + JS_N("name", m.Name); + JS_N("avatar", m.Avatar); + JS_O("token", m.Token); + JS_N("application_id", m.ApplicationID); +} diff --git a/src/discord/webhook.hpp b/src/discord/webhook.hpp new file mode 100644 index 0000000..f0214df --- /dev/null +++ b/src/discord/webhook.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include "json.hpp" +#include "snowflake.hpp" +#include "user.hpp" + +enum class WebhookType { + Incoming = 1, + ChannelFollower = 2, +}; + +struct WebhookData { + Snowflake ID; + WebhookType Type; + std::optional GuildID; + Snowflake ChannelID; + std::optional User; + std::string Name; // null + std::string Avatar; // null + std::optional Token; + Snowflake ApplicationID; // null + + friend void from_json(const nlohmann::json &j, WebhookData &m); +}; diff --git a/src/discord/websocket.cpp b/src/discord/websocket.cpp new file mode 100644 index 0000000..ff50cd3 --- /dev/null +++ b/src/discord/websocket.cpp @@ -0,0 +1,66 @@ +#include "websocket.hpp" +#include + +Websocket::Websocket() {} + +void Websocket::StartConnection(std::string url) { + m_websocket.disableAutomaticReconnection(); + m_websocket.setUrl(url); + m_websocket.setOnMessageCallback(std::bind(&Websocket::OnMessage, this, std::placeholders::_1)); + m_websocket.setExtraHeaders(ix::WebSocketHttpHeaders { { "User-Agent", m_agent } }); // idk if this actually works + m_websocket.start(); +} + +void Websocket::SetUserAgent(std::string agent) { + m_agent = agent; +} + +void Websocket::Stop() { + Stop(ix::WebSocketCloseConstants::kNormalClosureCode); +} + +void Websocket::Stop(uint16_t code) { + m_websocket.stop(code); +} + +bool Websocket::IsOpen() const { + auto state = m_websocket.getReadyState(); + return state == ix::ReadyState::Open; +} + +void Websocket::Send(const std::string &str) { + printf("sending %s\n", str.c_str()); + m_websocket.sendText(str); +} + +void Websocket::Send(const nlohmann::json &j) { + Send(j.dump()); +} + +void Websocket::OnMessage(const ix::WebSocketMessagePtr &msg) { + switch (msg->type) { + case ix::WebSocketMessageType::Open: { + m_signal_open.emit(); + } break; + case ix::WebSocketMessageType::Close: { + m_signal_close.emit(msg->closeInfo.code); + } break; + case ix::WebSocketMessageType::Message: { + m_signal_message.emit(msg->str); + } break; + default: + break; + } +} + +Websocket::type_signal_open Websocket::signal_open() { + return m_signal_open; +} + +Websocket::type_signal_close Websocket::signal_close() { + return m_signal_close; +} + +Websocket::type_signal_message Websocket::signal_message() { + return m_signal_message; +} diff --git a/src/discord/websocket.hpp b/src/discord/websocket.hpp new file mode 100644 index 0000000..e6a6489 --- /dev/null +++ b/src/discord/websocket.hpp @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class Websocket { +public: + Websocket(); + void StartConnection(std::string url); + + void SetUserAgent(std::string agent); + + void Send(const std::string &str); + void Send(const nlohmann::json &j); + void Stop(); + void Stop(uint16_t code); + bool IsOpen() const; + +private: + void OnMessage(const ix::WebSocketMessagePtr &msg); + + ix::WebSocket m_websocket; + std::string m_agent; + +public: + typedef sigc::signal type_signal_open; + typedef sigc::signal type_signal_close; + typedef sigc::signal type_signal_message; + + type_signal_open signal_open(); + type_signal_close signal_close(); + type_signal_message signal_message(); + +private: + type_signal_open m_signal_open; + type_signal_close m_signal_close; + type_signal_message m_signal_message; +}; diff --git a/src/emojis.cpp b/src/emojis.cpp new file mode 100644 index 0000000..dd7193a --- /dev/null +++ b/src/emojis.cpp @@ -0,0 +1,118 @@ +#include "emojis.hpp" +#include + +EmojiResource::EmojiResource(std::string filepath) + : m_filepath(filepath) {} + +bool EmojiResource::Load() { + m_fp = std::fopen(m_filepath.c_str(), "rb"); + if (m_fp == nullptr) return false; + + int index_offset; + std::fread(&index_offset, 4, 1, m_fp); + std::fseek(m_fp, index_offset, SEEK_SET); + + int emojis_count; + std::fread(&emojis_count, 4, 1, m_fp); + for (int i = 0; i < emojis_count; i++) { + std::vector shortcodes; + + int shortcodes_count; + std::fread(&shortcodes_count, 4, 1, m_fp); + for (int j = 0; j < shortcodes_count; j++) { + int shortcode_length; + std::fread(&shortcode_length, 4, 1, m_fp); + std::string shortcode(shortcode_length, '\0'); + std::fread(shortcode.data(), shortcode_length, 1, m_fp); + shortcodes.push_back(std::move(shortcode)); + } + + int surrogates_count; + std::fread(&surrogates_count, 4, 1, m_fp); + std::string surrogates(surrogates_count, '\0'); + std::fread(surrogates.data(), surrogates_count, 1, m_fp); + m_patterns.push_back(surrogates); + + int data_size, data_offset; + std::fread(&data_size, 4, 1, m_fp); + std::fread(&data_offset, 4, 1, m_fp); + m_index[surrogates] = { data_offset, data_size }; + + for (const auto &shortcode : shortcodes) + m_shortcode_index[shortcode] = surrogates; + + m_pattern_shortcode_index[surrogates] = std::move(shortcodes); + } + + std::sort(m_patterns.begin(), m_patterns.end(), [](const Glib::ustring &a, const Glib::ustring &b) { + return a.size() > b.size(); + }); + return true; +} + +Glib::RefPtr EmojiResource::GetPixBuf(const Glib::ustring &pattern) { + const auto it = m_index.find(pattern); + if (it == m_index.end()) return Glib::RefPtr(); + const int pos = it->second.first; + const int len = it->second.second; + std::fseek(m_fp, pos, SEEK_SET); + std::vector data(len); + std::fread(data.data(), len, 1, m_fp); + auto loader = Gdk::PixbufLoader::create(); + loader->write(static_cast(data.data()), data.size()); + loader->close(); + return loader->get_pixbuf(); +} + +void EmojiResource::ReplaceEmojis(Glib::RefPtr buf, int size) { + auto get_text = [&]() -> auto { + Gtk::TextBuffer::iterator a, b; + buf->get_bounds(a, b); + return buf->get_slice(a, b, true); + }; + auto text = get_text(); + + int searchpos; + for (const auto &pattern : m_patterns) { + searchpos = 0; + Glib::RefPtr pixbuf; + while (true) { + size_t r = text.find(pattern, searchpos); + if (r == Glib::ustring::npos) break; + if (!pixbuf) { + pixbuf = GetPixBuf(pattern); + if (pixbuf) + pixbuf = pixbuf->scale_simple(size, size, Gdk::INTERP_BILINEAR); + else + break; + } + searchpos = r + pattern.size(); + + const auto start_it = buf->get_iter_at_offset(r); + const auto end_it = buf->get_iter_at_offset(r + pattern.size()); + + auto it = buf->erase(start_it, end_it); + buf->insert_pixbuf(it, pixbuf); + + int alen = text.size(); + text = get_text(); + int blen = text.size(); + searchpos -= (alen - blen); + } + } +} + +std::string EmojiResource::GetShortCodeForPattern(const Glib::ustring &pattern) { + auto it = m_pattern_shortcode_index.find(pattern); + if (it != m_pattern_shortcode_index.end()) + return it->second.front(); + return ""; +} + +const std::vector &EmojiResource::GetPatterns() const { + return m_patterns; +} + +const std::map &EmojiResource::GetShortCodes() const { + return m_shortcode_index; +} diff --git a/src/emojis.hpp b/src/emojis.hpp new file mode 100644 index 0000000..61b8a71 --- /dev/null +++ b/src/emojis.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +#include + +// shoutout to gtk for only supporting .svg's sometimes + +class EmojiResource { +public: + EmojiResource(std::string filepath); + bool Load(); + Glib::RefPtr GetPixBuf(const Glib::ustring &pattern); + const std::vector &GetPatterns() const; + const std::map &GetShortCodes() const; + void ReplaceEmojis(Glib::RefPtr buf, int size = 24); + std::string GetShortCodeForPattern(const Glib::ustring &pattern); + +private: + std::unordered_map> m_pattern_shortcode_index; + std::map m_shortcode_index; // shortcode -> pattern + std::unordered_map> m_index; // pattern -> [pos, len] + FILE *m_fp = nullptr; + std::string m_filepath; + std::vector m_patterns; +}; diff --git a/src/filecache.cpp b/src/filecache.cpp new file mode 100644 index 0000000..a731750 --- /dev/null +++ b/src/filecache.cpp @@ -0,0 +1,226 @@ +#include "abaddon.hpp" +#include "filecache.hpp" +#include "MurmurHash3.h" + +std::string GetCachedName(std::string str) { + uint32_t out; + MurmurHash3_x86_32(str.c_str(), str.size(), 0, &out); + return std::to_string(out); +} + +Cache::Cache() { + m_tmp_path = std::filesystem::temp_directory_path() / "abaddon-cache"; + std::filesystem::create_directories(m_tmp_path); + m_worker.set_file_path(m_tmp_path); +} + +Cache::~Cache() { + m_worker.stop(); + + for (auto &future : m_futures) { + if (future.valid()) { + try { // dont care about stored exceptions + future.get(); + } catch (...) {} + } + } + + std::error_code err; + if (!std::filesystem::remove_all(m_tmp_path, err)) + fprintf(stderr, "error removing tmp dir\n"); +} + +void Cache::ClearCache() { + for (const auto &path : std::filesystem::directory_iterator(m_tmp_path)) + std::filesystem::remove_all(path); +} + +void Cache::RespondFromPath(std::filesystem::path path, callback_type cb) { + cb(path.string()); +} + +void Cache::GetFileFromURL(std::string url, callback_type cb) { + auto cache_path = m_tmp_path / GetCachedName(url); + if (std::filesystem::exists(cache_path)) { + m_mutex.lock(); + m_futures.push_back(std::async(std::launch::async, [this, cache_path, cb]() { RespondFromPath(cache_path, cb); })); + m_mutex.unlock(); + return; + } + + if (m_callbacks.find(url) != m_callbacks.end()) { + m_callbacks[url].push_back(cb); + } else { + m_callbacks[url].push_back(cb); + m_worker.add_image(url, [this, url](const std::string &path) { + OnFetchComplete(url); + }); + } +} + +std::string Cache::GetPathIfCached(std::string url) { + auto cache_path = m_tmp_path / GetCachedName(url); + if (std::filesystem::exists(cache_path)) { + return cache_path.string(); + } + + return ""; +} + +// this just seems really yucky +void Cache::CleanupFutures() { + std::lock_guard l(m_mutex); + for (auto it = m_futures.begin(); it != m_futures.end();) { + if (it->valid() && it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) + it = m_futures.erase(it); + else + it++; + } +} + +void Cache::OnResponse(const std::string &url) { + CleanupFutures(); // see above comment + + auto path = m_tmp_path / GetCachedName(url); + + m_mutex.lock(); + const auto key = static_cast(url); + auto callbacks = std::move(m_callbacks[key]); + m_callbacks.erase(key); + m_mutex.unlock(); + for (const auto &cb : callbacks) + cb(path.string()); +} + +void Cache::OnFetchComplete(const std::string &url) { + m_mutex.lock(); + m_futures.push_back(std::async(std::launch::async, std::bind(&Cache::OnResponse, this, url))); + m_mutex.unlock(); +} + +FileCacheWorkerThread::FileCacheWorkerThread() { + m_multi_handle = curl_multi_init(); + m_thread = std::thread(std::bind(&FileCacheWorkerThread::loop, this)); +} + +FileCacheWorkerThread::~FileCacheWorkerThread() { + if (!m_stop) stop(); + for (const auto handle : m_handles) + curl_easy_cleanup(handle); + curl_multi_cleanup(m_multi_handle); +} + +void FileCacheWorkerThread::set_file_path(const std::filesystem::path &path) { + m_data_path = path; +} + +void FileCacheWorkerThread::add_image(const std::string &string, callback_type callback) { + m_queue_mutex.lock(); + m_queue.push({ string, callback }); + m_cv.notify_one(); + m_queue_mutex.unlock(); +} + +void FileCacheWorkerThread::stop() { + m_stop = true; + if (m_thread.joinable()) { + m_cv.notify_all(); + m_thread.join(); + } +} + +void FileCacheWorkerThread::loop() { + timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + while (!m_stop) { + if (m_handles.size() == 0) { + std::unique_lock lock(m_queue_mutex); + int s = m_queue.size(); + if (s == 0) + m_cv.wait(lock); + } + + static const auto concurrency = static_cast(Abaddon::Get().GetSettings().GetCacheHTTPConcurrency()); + if (m_handles.size() < concurrency) { + std::optional entry; + m_queue_mutex.lock(); + if (m_queue.size() > 0) { + entry = std::move(m_queue.front()); + m_queue.pop(); + } + m_queue_mutex.unlock(); + + if (entry.has_value()) { + if (m_callbacks.find(entry->URL) != m_callbacks.end()) { + printf("url is being requested twice :(\n"); + continue; + } + + // add the ! and rename after so the image loader thing doesnt pick it up if its not done yet + auto path = m_data_path / (GetCachedName(entry->URL) + "!"); + FILE *fp = std::fopen(path.string().c_str(), "wb"); + if (fp == nullptr) { + printf("couldn't open fp\n"); + continue; + } + + CURL *handle = curl_easy_init(); + m_handles.insert(handle); + curl_easy_setopt(handle, CURLOPT_URL, entry->URL.c_str()); + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_WRITEDATA, fp); + + m_handle_urls[handle] = entry->URL; + m_curl_file_handles[handle] = fp; + m_callbacks[entry->URL] = entry->Callback; + m_paths[entry->URL] = std::move(path); + + curl_multi_add_handle(m_multi_handle, handle); + } + } + + fd_set fdread; + fd_set fdwrite; + fd_set fdexcep; + int maxfd = -1; + FD_ZERO(&fdread); + FD_ZERO(&fdwrite); + FD_ZERO(&fdexcep); + curl_multi_fdset(m_multi_handle, &fdread, &fdwrite, &fdexcep, &maxfd); + if (maxfd == -1) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } else { + select(maxfd + 1, &fdread, &fdwrite, &fdexcep, &timeout); + } + + curl_multi_perform(m_multi_handle, &m_running_handles); + + int num_msgs; + while (auto msg = curl_multi_info_read(m_multi_handle, &num_msgs)) { + if (msg->msg == CURLMSG_DONE) { + auto url = m_handle_urls.at(msg->easy_handle); + auto fp = m_curl_file_handles.find(msg->easy_handle); + std::fclose(fp->second); + + m_handles.erase(msg->easy_handle); + m_handle_urls.erase(msg->easy_handle); + + curl_multi_remove_handle(m_multi_handle, msg->easy_handle); + curl_easy_cleanup(msg->easy_handle); + + auto path = m_paths.at(url).string(); + auto cb = m_callbacks.at(url); + m_callbacks.erase(url); + m_paths.erase(url); + m_curl_file_handles.erase(fp); + // chop off the ! + auto old = path; + path.pop_back(); + std::filesystem::rename(old, path); + cb(path); + } + } + } +} diff --git a/src/filecache.hpp b/src/filecache.hpp new file mode 100644 index 0000000..d25fdb3 --- /dev/null +++ b/src/filecache.hpp @@ -0,0 +1,79 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "util.hpp" +#include "http.hpp" + +class FileCacheWorkerThread { +public: + using callback_type = sigc::slot; + + FileCacheWorkerThread(); + ~FileCacheWorkerThread(); + + void set_file_path(const std::filesystem::path &path); + + void add_image(const std::string &string, callback_type callback); + + void stop(); + +private: + void loop(); + + bool m_stop = false; + std::thread m_thread; + + struct QueueEntry { + std::string URL; + callback_type Callback; + }; + + std::condition_variable m_cv; + + mutable std::mutex m_queue_mutex; + std::queue m_queue; + + std::unordered_map m_curl_file_handles; + std::unordered_map m_handle_urls; + std::unordered_map m_paths; + std::unordered_map m_callbacks; + + int m_running_handles = 0; + + std::unordered_set m_handles; + CURLM *m_multi_handle; + + std::filesystem::path m_data_path; +}; + +class Cache { +public: + Cache(); + ~Cache(); + + using callback_type = std::function; + void GetFileFromURL(std::string url, callback_type cb); + std::string GetPathIfCached(std::string url); + void ClearCache(); + +private: + void CleanupFutures(); + void RespondFromPath(std::filesystem::path path, callback_type cb); + void OnResponse(const std::string &url); + void OnFetchComplete(const std::string &url); + + std::unordered_map> m_callbacks; + std::vector> m_futures; + std::filesystem::path m_tmp_path; + + mutable std::mutex m_mutex; + + FileCacheWorkerThread m_worker; +}; diff --git a/src/http.cpp b/src/http.cpp new file mode 100644 index 0000000..790add7 --- /dev/null +++ b/src/http.cpp @@ -0,0 +1,123 @@ +#include "http.hpp" + +namespace http { +request::request(EMethod method, const std::string &url) + : m_url(url) { + switch (method) { + case REQUEST_GET: + m_method = "GET"; + break; + case REQUEST_POST: + m_method = "POST"; + break; + case REQUEST_PATCH: + m_method = "PATCH"; + break; + case REQUEST_PUT: + m_method = "PUT"; + break; + case REQUEST_DELETE: + m_method = "DELETE"; + break; + default: + m_method = "GET"; + break; + } + + prepare(); +} + +request::~request() { + if (m_curl != nullptr) + curl_easy_cleanup(m_curl); + + if (m_header_list != nullptr) + curl_slist_free_all(m_header_list); +} + +void request::set_verify_ssl(bool verify) { + curl_easy_setopt(m_curl, CURLOPT_SSL_VERIFYPEER, verify ? 1L : 0L); +} + +void request::set_proxy(const std::string &proxy) { + curl_easy_setopt(m_curl, CURLOPT_PROXY, proxy.c_str()); +} + +void request::set_header(const std::string &name, const std::string &value) { + m_header_list = curl_slist_append(m_header_list, (name + ": " + value).c_str()); +} + +void request::set_body(const std::string &data) { + curl_easy_setopt(m_curl, CURLOPT_COPYPOSTFIELDS, data.c_str()); +} + +void request::set_user_agent(const std::string &data) { + curl_easy_setopt(m_curl, CURLOPT_USERAGENT, data.c_str()); +} + +response request::execute() { + if (m_curl == nullptr) { + auto response = detail::make_response(m_url, EStatusCode::ClientErrorCURLInit); + response.error_string = "curl pointer is null"; + } + + detail::check_init(); + + std::string str; + curl_easy_setopt(m_curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(m_curl, CURLOPT_CUSTOMREQUEST, m_method); + curl_easy_setopt(m_curl, CURLOPT_URL, m_url.c_str()); + curl_easy_setopt(m_curl, CURLOPT_FOLLOWLOCATION, 1); + curl_easy_setopt(m_curl, CURLOPT_WRITEFUNCTION, detail::curl_write_data_callback); + curl_easy_setopt(m_curl, CURLOPT_WRITEDATA, &str); + curl_easy_setopt(m_curl, CURLOPT_ERRORBUFFER, m_error_buf); + m_error_buf[0] = '\0'; + if (m_header_list != nullptr) + curl_easy_setopt(m_curl, CURLOPT_HTTPHEADER, m_header_list); + + CURLcode result = curl_easy_perform(m_curl); + if (result != CURLE_OK) { + auto response = detail::make_response(m_url, EStatusCode::ClientErrorCURLPerform); + response.error_string = curl_easy_strerror(result); + response.error_string += " " + std::string(m_error_buf); + return response; + } + + long response_code = 0; + curl_easy_getinfo(m_curl, CURLINFO_RESPONSE_CODE, &response_code); + + auto response = detail::make_response(m_url, response_code); + response.text = str; + + return response; +} + +void request::prepare() { + m_curl = curl_easy_init(); +} + +namespace detail { + size_t curl_write_data_callback(void *ptr, size_t size, size_t nmemb, void *userdata) { + const size_t n = size * nmemb; + static_cast(userdata)->append(static_cast(ptr), n); + return n; + } + + response make_response(const std::string &url, int code) { + response r; + r.url = url; + r.status_code = static_cast(code); + if (code < http::EStatusCode::ClientErrorMax) + r.error = true; + return r; + } + + void check_init() { + static bool initialized = false; + if (!initialized) { + curl_global_init(CURL_GLOBAL_ALL); + initialized = true; + } + } +} // namespace detail +} // namespace http diff --git a/src/http.hpp b/src/http.hpp new file mode 100644 index 0000000..c2e2765 --- /dev/null +++ b/src/http.hpp @@ -0,0 +1,129 @@ +#pragma once +#include +#include + +// i regret not using snake case for everything oh well + +namespace http { +enum EStatusCode : int { + Continue = 100, + SwitchingProtocols = 101, + Processing = 102, + EarlyHints = 103, + + OK = 200, + Created = 201, + Accepted = 202, + NonAuthoritativeInformation = 203, + NoContent = 204, + ResetContent = 205, + PartialContent = 206, + MultiStatus = 207, + AlreadyReported = 208, + IMUsed = 226, + + MultipleChoices = 300, + MovedPermanently = 301, + Found = 302, + SeeOther = 303, + NotModified = 304, + UseProxy = 305, + SwitchProxy = 306, + TemporaryRedirect = 307, + PermanentRedirect = 308, + + BadRequest = 400, + Unauthorized = 401, + PaymentRequired = 402, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + NotAcceptable = 406, + ProxyAuthorizationRequired = 407, + RequestTimeout = 408, + Conflict = 409, + Gone = 410, + LengthRequired = 411, + PreconditionFailed = 412, + PayloadTooLarge = 413, + URITooLong = 414, + UnsupportedMediaType = 415, + RangeNotSatisfiable = 416, + ExpectationFailed = 417, + ImATeapot = 418, + MisdirectedRequest = 421, + UnprocessableEntity = 422, + Locked = 423, + FailedDependency = 424, + TooEarly = 425, + UpgradeRequired = 426, + PreconditionRequired = 428, + TooManyRequests = 429, + RequestHeaderFieldsTooLarge = 431, + UnavailableForLegalReasons = 451, + + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504, + HTTPVersionNotSupported = 505, + VariantAlsoNegotiates = 506, + InsufficientStorage = 507, + LoopDetected = 508, + NotExtended = 510, + NetworkAuthenticationRequired = 511, + + ClientError = 1, + ClientErrorCURLInit, + ClientErrorCURLPerform, + ClientErrorMax = 99, +}; + +enum EMethod { + REQUEST_GET, + REQUEST_POST, + REQUEST_PATCH, + REQUEST_PUT, + REQUEST_DELETE, +}; + +struct response { + EStatusCode status_code; + std::string text; + std::string url; + bool error = false; + std::string error_string; +}; + +struct request { + request(EMethod method, const std::string &url); + ~request(); + + void set_verify_ssl(bool verify); + void set_proxy(const std::string &proxy); + void set_header(const std::string &name, const std::string &value); + void set_body(const std::string &data); + void set_user_agent(const std::string &data); + + response execute(); + +private: + void prepare(); + + CURL *m_curl; + std::string m_url; + const char *m_method; + curl_slist *m_header_list = nullptr; + char m_error_buf[CURL_ERROR_SIZE] = { 0 }; +}; + +using response_type = response; + +namespace detail { + size_t curl_write_data_callback(void *ptr, size_t size, size_t nmemb, void *userdata); + + response make_response(const std::string &url, int code); + void check_init(); +} // namespace detail +} // namespace http diff --git a/src/imgmanager.cpp b/src/imgmanager.cpp new file mode 100644 index 0000000..b97083c --- /dev/null +++ b/src/imgmanager.cpp @@ -0,0 +1,123 @@ +#include "imgmanager.hpp" +#include "util.hpp" +#include "abaddon.hpp" + +ImageManager::ImageManager() { + m_cb_dispatcher.connect(sigc::mem_fun(*this, &ImageManager::RunCallbacks)); +} + +Cache &ImageManager::GetCache() { + return m_cache; +} + +void ImageManager::ClearCache() { + m_cache.ClearCache(); +} + +Glib::RefPtr ImageManager::ReadFileToPixbuf(std::string path) { + const auto &data = ReadWholeFile(path); + if (data.size() == 0) return Glib::RefPtr(nullptr); + auto loader = Gdk::PixbufLoader::create(); + loader->signal_size_prepared().connect([&loader](int w, int h) { + int cw, ch; + GetImageDimensions(w, h, cw, ch); // what could go wrong + loader->set_size(cw, ch); + }); + loader->write(static_cast(data.data()), data.size()); + loader->close(); + return loader->get_pixbuf(); +} + +Glib::RefPtr ImageManager::ReadFileToPixbufAnimation(std::string path, int w, int h) { + const auto &data = ReadWholeFile(path); + if (data.size() == 0) return Glib::RefPtr(nullptr); + auto loader = Gdk::PixbufLoader::create(); + loader->signal_size_prepared().connect([&loader, w, h](int, int) { + loader->set_size(w, h); + }); + loader->write(static_cast(data.data()), data.size()); + loader->close(); + return loader->get_animation(); +} + +void ImageManager::LoadFromURL(std::string url, callback_type cb) { + sigc::signal)> signal; + signal.connect(cb); + m_cache.GetFileFromURL(url, [this, url, signal](std::string path) { + try { + auto buf = ReadFileToPixbuf(path); + if (!buf) + printf("%s (%s) is null\n", url.c_str(), path.c_str()); + else { + m_cb_mutex.lock(); + m_cb_queue.push([signal, buf]() { signal.emit(buf); }); + m_cb_dispatcher.emit(); + m_cb_mutex.unlock(); + } + } catch (const std::exception &e) { + fprintf(stderr, "err loading pixbuf from %s: %s\n", path.c_str(), e.what()); + } + }); +} + +void ImageManager::LoadAnimationFromURL(std::string url, int w, int h, callback_anim_type cb) { + sigc::signal)> signal; + signal.connect(cb); + m_cache.GetFileFromURL(url, [this, url, signal, w, h](std::string path) { + try { + auto buf = ReadFileToPixbufAnimation(path, w, h); + if (!buf) + printf("%s (%s) is null\n", url.c_str(), path.c_str()); + else { + m_cb_mutex.lock(); + m_cb_queue.push([signal, buf]() { signal.emit(buf); }); + m_cb_dispatcher.emit(); + m_cb_mutex.unlock(); + } + } catch (const std::exception &e) { + fprintf(stderr, "err loading pixbuf animation from %s: %s\n", path.c_str(), e.what()); + } + }); +} + +void ImageManager::Prefetch(std::string url) { + m_cache.GetFileFromURL(url, [](const auto &) {}); +} + +void ImageManager::RunCallbacks() { + m_cb_mutex.lock(); + m_cb_queue.front()(); + m_cb_queue.pop(); + m_cb_mutex.unlock(); +} + +Glib::RefPtr ImageManager::GetFromURLIfCached(std::string url) { + std::string path = m_cache.GetPathIfCached(url); + if (path != "") + return ReadFileToPixbuf(path); + + return Glib::RefPtr(nullptr); +} + +Glib::RefPtr ImageManager::GetAnimationFromURLIfCached(std::string url, int w, int h) { + std::string path = m_cache.GetPathIfCached(url); + if (path != "") + return ReadFileToPixbufAnimation(path, w, h); + + return Glib::RefPtr(nullptr); +} + +Glib::RefPtr ImageManager::GetPlaceholder(int size) { + std::string name = "/placeholder" + std::to_string(size); + if (m_pixs.find(name) != m_pixs.end()) + return m_pixs.at(name); + + try { + auto buf = Gdk::Pixbuf::create_from_file(Abaddon::Get().GetResPath() + "/decamarks.png", size, size); + m_pixs[name] = buf; + return buf; + } catch (std::exception &e) { + fprintf(stderr, "error loading placeholder\n"); + return Glib::RefPtr(nullptr); + } +} diff --git a/src/imgmanager.hpp b/src/imgmanager.hpp new file mode 100644 index 0000000..eb8a590 --- /dev/null +++ b/src/imgmanager.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include +#include +#include "filecache.hpp" + +class ImageManager { +public: + ImageManager(); + + using callback_anim_type = sigc::slot)>; + using callback_type = sigc::slot)>; + + Cache &GetCache(); + void ClearCache(); + void LoadFromURL(std::string url, callback_type cb); + // animations need dimensions before loading since there is no (easy) way to scale a PixbufAnimation + void LoadAnimationFromURL(std::string url, int w, int h, callback_anim_type cb); + void Prefetch(std::string url); + Glib::RefPtr GetFromURLIfCached(std::string url); + Glib::RefPtr GetAnimationFromURLIfCached(std::string url, int w, int h); + Glib::RefPtr GetPlaceholder(int size); + +private: + Glib::RefPtr ReadFileToPixbuf(std::string path); + Glib::RefPtr ReadFileToPixbufAnimation(std::string path, int w, int h); + + mutable std::mutex m_load_mutex; + void RunCallbacks(); + Glib::Dispatcher m_cb_dispatcher; + mutable std::mutex m_cb_mutex; + std::queue> m_cb_queue; + + std::unordered_map> m_pixs; + Cache m_cache; +}; diff --git a/src/platform.cpp b/src/platform.cpp new file mode 100644 index 0000000..ce744d7 --- /dev/null +++ b/src/platform.cpp @@ -0,0 +1,157 @@ +#include "platform.hpp" +#include "util.hpp" +#include +#include +#include +#include + +using namespace std::literals::string_literals; + +#if defined(_WIN32) && defined(_MSC_VER) + #include + #include + #include + #include + #include + #pragma comment(lib, "Shlwapi.lib") +bool Platform::SetupFonts() { + using namespace std::string_literals; + + char buf[MAX_PATH] { 0 }; + GetCurrentDirectoryA(MAX_PATH, buf); + { + // thanks @WorkingRobot for da help :^)) + + std::ifstream template_stream(buf + "\\fonts\\fonts.template.conf"s); + std::ofstream conf_stream(buf + "\\fonts\\fonts.conf"s); + if (!template_stream.good()) { + printf("can't open fonts/fonts.template.conf\n"); + return false; + } + if (!conf_stream.good()) { + printf("can't open write to fonts.conf\n"); + return false; + } + + std::string line; + while (std::getline(template_stream, line)) { + if (line == "") + conf_stream << "" << (buf + "\\fonts\\conf.d"s) << ""; + else + conf_stream << line; + conf_stream << '\n'; + } + } + + auto fc = FcConfigCreate(); + FcConfigSetCurrent(fc); + FcConfigParseAndLoad(fc, const_cast(reinterpret_cast((buf + "\\fonts\\fonts.conf"s).c_str())), true); + FcConfigAppFontAddDir(fc, const_cast(reinterpret_cast((buf + "\\fonts"s).c_str()))); + + char fonts_path[MAX_PATH]; + if (SHGetFolderPathA(NULL, CSIDL_FONTS, NULL, SHGFP_TYPE_CURRENT, fonts_path) == S_OK) { + FcConfigAppFontAddDir(fc, reinterpret_cast(fonts_path)); + } + + auto map = pango_cairo_font_map_new_for_font_type(CAIRO_FONT_TYPE_FT); + pango_fc_font_map_set_config(reinterpret_cast(map), fc); + pango_cairo_font_map_set_default(reinterpret_cast(map)); + + return true; +} +#else +bool Platform::SetupFonts() { + return true; +} +#endif + +#if defined(_WIN32) +std::string Platform::FindResourceFolder() { + return "."; +} + +std::string Platform::FindConfigFile() { + const auto x = std::getenv("ABADDON_CONFIG"); + if (x != nullptr) + return x; + return "./abaddon.ini"; +} + +std::string Platform::FindStateCacheFolder() { + return "."; +} + +#elif defined(__linux__) +std::string Platform::FindResourceFolder() { + static std::string found_path; + static bool found = false; + if (found) return found_path; + + const auto home_env = std::getenv("HOME"); + if (home_env != nullptr) { + const static std::string home_path = home_env + "/.local/share/abaddon"s; + + for (const auto &path : { "."s, home_path, std::string(ABADDON_DEFAULT_RESOURCE_DIR) }) { + if (util::IsFolder(path + "/res") && util::IsFolder(path + "/css")) { + found_path = path; + found = true; + return found_path; + } + } + } + + puts("cant find a resources folder, will try to load from cwd"); + found_path = "."; + found = true; + return found_path; +} + +std::string Platform::FindConfigFile() { + const auto x = std::getenv("ABADDON_CONFIG"); + if (x != nullptr) + return x; + + const auto home_env = std::getenv("HOME"); + if (home_env != nullptr) { + const auto home_path = home_env + "/.config/abaddon/abaddon.ini"s; + for (auto path : { "./abaddon.ini"s, home_path }) { + if (util::IsFile(path)) return path; + } + } + puts("can't find configuration file!"); + return "./abaddon.ini"; +} + +std::string Platform::FindStateCacheFolder() { + const auto home_env = std::getenv("HOME"); + if (home_env != nullptr) { + auto home_path = home_env + "/.cache/abaddon"s; + std::error_code ec; + if (!util::IsFolder(home_path)) + std::filesystem::create_directories(home_path, ec); + if (util::IsFolder(home_path)) + return home_path; + } + puts("can't find cache folder!"); + return "."; +} + +#else +std::string Platform::FindResourceFolder() { + puts("unknown OS, trying to load resources from cwd"); + return "."; +} + +std::string Platform::FindConfigFile() { + const auto x = std::getenv("ABADDON_CONFIG"); + if (x != nullptr) + return x; + puts("unknown OS, trying to load config from cwd"); + return "./abaddon.ini"; +} + +std::string Platform::FindStateCacheFolder() { + puts("unknown OS, setting state cache folder to cwd"); + return "."; +} +#endif diff --git a/src/platform.hpp b/src/platform.hpp new file mode 100644 index 0000000..e321d4e --- /dev/null +++ b/src/platform.hpp @@ -0,0 +1,9 @@ +#pragma once +#include + +namespace Platform { +bool SetupFonts(); +std::string FindResourceFolder(); +std::string FindConfigFile(); +std::string FindStateCacheFolder(); +} diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..0a7dbb7 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,115 @@ +#include "settings.hpp" +#include +#include + +SettingsManager::SettingsManager(std::string filename) + : m_filename(filename) { + if (!std::filesystem::exists(filename)) { + std::fstream fs; + fs.open(filename, std::ios::out); + fs.close(); + } + + auto rc = m_ini.LoadFile(filename.c_str()); + m_ok = rc == SI_OK; +} + +void SettingsManager::Reload() { + m_ok = m_ini.LoadFile(m_filename.c_str()) == SI_OK; +} + +std::string SettingsManager::GetSettingString(const std::string §ion, const std::string &key, std::string fallback) const { + return m_ini.GetValue(section.c_str(), key.c_str(), fallback.c_str()); +} + +int SettingsManager::GetSettingInt(const std::string §ion, const std::string &key, int fallback) const { + return std::stoul(GetSettingString(section, key, std::to_string(fallback))); +} + +bool SettingsManager::GetSettingBool(const std::string §ion, const std::string &key, bool fallback) const { + return GetSettingString(section, key, fallback ? "true" : "false") != "false"; +} + +bool SettingsManager::IsValid() const { + return m_ok; +} + +void SettingsManager::Close() { + m_ini.SaveFile(m_filename.c_str()); +} + +bool SettingsManager::GetUseMemoryDB() const { + return GetSettingBool("discord", "memory_db", false); +} + +std::string SettingsManager::GetUserAgent() const { + return GetSettingString("http", "user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"); +} + +std::string SettingsManager::GetDiscordToken() const { + return GetSettingString("discord", "token"); +} + +bool SettingsManager::GetShowMemberListDiscriminators() const { + return GetSettingBool("gui", "member_list_discriminator", true); +} + +bool SettingsManager::GetShowStockEmojis() const { +#ifdef _WIN32 + return GetSettingBool("gui", "stock_emojis", false); +#else + return GetSettingBool("gui", "stock_emojis", true); +#endif +} + +bool SettingsManager::GetShowCustomEmojis() const { + return GetSettingBool("gui", "custom_emojis", true); +} + +std::string SettingsManager::GetLinkColor() const { + return GetSettingString("style", "linkcolor", "rgba(40, 200, 180, 255)"); +} + +std::string SettingsManager::GetChannelsExpanderColor() const { + return GetSettingString("style", "expandercolor", "rgba(255, 83, 112, 255)"); +} + +std::string SettingsManager::GetNSFWChannelColor() const { + return GetSettingString("style", "nsfwchannelcolor", "#ed6666"); +} + +int SettingsManager::GetCacheHTTPConcurrency() const { + return GetSettingInt("http", "concurrent", 20); +} + +bool SettingsManager::GetPrefetch() const { + return GetSettingBool("discord", "prefetch", false); +} + +std::string SettingsManager::GetMainCSS() const { + return GetSettingString("gui", "css", "main.css"); +} + +bool SettingsManager::GetShowAnimations() const { + return GetSettingBool("gui", "animations", true); +} + +bool SettingsManager::GetShowOwnerCrown() const { + return GetSettingBool("gui", "owner_crown", true); +} + +std::string SettingsManager::GetGatewayURL() const { + return GetSettingString("discord", "gateway", "wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream"); +} + +std::string SettingsManager::GetAPIBaseURL() const { + return GetSettingString("discord", "api_base", "https://discord.com/api/v9"); +} + +bool SettingsManager::GetAnimatedGuildHoverOnly() const { + return GetSettingBool("gui", "animated_guild_hover_only", true); +} + +bool SettingsManager::GetSaveState() const { + return GetSettingBool("gui", "save_state", true); +} diff --git a/src/settings.hpp b/src/settings.hpp new file mode 100644 index 0000000..3fff593 --- /dev/null +++ b/src/settings.hpp @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include + +class SettingsManager { +public: + SettingsManager(std::string filename); + void Reload(); + + void Close(); + bool GetUseMemoryDB() const; + std::string GetUserAgent() const; + std::string GetDiscordToken() const; + bool GetShowMemberListDiscriminators() const; + bool GetShowStockEmojis() const; + bool GetShowCustomEmojis() const; + int GetCacheHTTPConcurrency() const; + bool GetPrefetch() const; + std::string GetMainCSS() const; + bool GetShowAnimations() const; + bool GetShowOwnerCrown() const; + std::string GetGatewayURL() const; + std::string GetAPIBaseURL() const; + bool GetAnimatedGuildHoverOnly() const; + bool GetSaveState() const; + + // i would like to use Gtk::StyleProperty for this, but it will not work on windows + // #1 it's missing from the project files for the version used by vcpkg + // #2 it's still broken and doesn't function even when added to the solution + // #3 it's a massive pain in the ass to try and bump the version to a functioning version + // because they switch build systems to nmake/meson (took months to get merged in vcpkg) + // #4 c++ build systems sucks + // three options are: use gtk4 with updated vcpkg, try and port it myself, or use msys2 instead of vcpkg + // im leaning towards msys + std::string GetLinkColor() const; + std::string GetChannelsExpanderColor() const; + std::string GetNSFWChannelColor() const; + + bool IsValid() const; + + template + void SetSetting(std::string section, std::string key, T value) { + m_ini.SetValue(section.c_str(), key.c_str(), std::to_string(value).c_str()); + m_ini.SaveFile(m_filename.c_str()); + } + + void SetSetting(std::string section, std::string key, std::string value) { + m_ini.SetValue(section.c_str(), key.c_str(), value.c_str()); + m_ini.SaveFile(m_filename.c_str()); + } + +private: + std::string GetSettingString(const std::string §ion, const std::string &key, std::string fallback = "") const; + int GetSettingInt(const std::string §ion, const std::string &key, int fallback) const; + bool GetSettingBool(const std::string §ion, const std::string &key, bool fallback) const; + +private: + bool m_ok; + std::string m_filename; + CSimpleIniA m_ini; +}; diff --git a/src/state.cpp b/src/state.cpp new file mode 100644 index 0000000..043d181 --- /dev/null +++ b/src/state.cpp @@ -0,0 +1,37 @@ +#include "state.hpp" + +void to_json(nlohmann::json &j, const ExpansionStateRoot &m) { + if (m.Children.empty()) { + j = nlohmann::json::object(); + } else { + for (const auto &[id, state] : m.Children) + j[std::to_string(id)] = state; + } +} + +void from_json(const nlohmann::json &j, ExpansionStateRoot &m) { + for (const auto &[key, value] : j.items()) + m.Children[key] = value; +} + +void to_json(nlohmann::json &j, const ExpansionState &m) { + j["e"] = m.IsExpanded; + j["c"] = m.Children; +} + +void from_json(const nlohmann::json &j, ExpansionState &m) { + j.at("e").get_to(m.IsExpanded); + j.at("c").get_to(m.Children); +} + +void to_json(nlohmann::json &j, const AbaddonApplicationState &m) { + j["active_channel"] = m.ActiveChannel; + j["expansion"] = m.Expansion; +} + +void from_json(const nlohmann::json &j, AbaddonApplicationState &m) { + if (j.contains("active_channel")) + j.at("active_channel").get_to(m.ActiveChannel); + if (j.contains("expansion")) + j.at("expansion").get_to(m.Expansion); +} diff --git a/src/state.hpp b/src/state.hpp new file mode 100644 index 0000000..230808f --- /dev/null +++ b/src/state.hpp @@ -0,0 +1,27 @@ +#include +#include +#include "discord/snowflake.hpp" + +struct ExpansionState; +struct ExpansionStateRoot { + std::map Children; + + friend void to_json(nlohmann::json &j, const ExpansionStateRoot &m); + friend void from_json(const nlohmann::json &j, ExpansionStateRoot &m); +}; + +struct ExpansionState { + bool IsExpanded; + ExpansionStateRoot Children; + + friend void to_json(nlohmann::json &j, const ExpansionState &m); + friend void from_json(const nlohmann::json &j, ExpansionState &m); +}; + +struct AbaddonApplicationState { + Snowflake ActiveChannel; + ExpansionStateRoot Expansion; + + friend void to_json(nlohmann::json &j, const AbaddonApplicationState &m); + friend void from_json(const nlohmann::json &j, AbaddonApplicationState &m); +}; diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..34ca6d4 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,217 @@ +#include "util.hpp" +#include + +Semaphore::Semaphore(int count) + : m_count(count) {} + +void Semaphore::notify() { + std::unique_lock lock(m_mutex); + m_count++; + lock.unlock(); + m_cv.notify_one(); +} + +void Semaphore::wait() { + std::unique_lock lock(m_mutex); + while (m_count == 0) + m_cv.wait(lock); + m_count--; +} + +void LaunchBrowser(Glib::ustring url) { + GError *err = nullptr; + if (!gtk_show_uri_on_window(nullptr, url.c_str(), GDK_CURRENT_TIME, &err)) + printf("failed to open uri: %s\n", err->message); +} + +void GetImageDimensions(int inw, int inh, int &outw, int &outh, int clampw, int clamph) { + const auto frac = static_cast(inw) / inh; + + outw = inw; + outh = inh; + + if (outw > clampw) { + outw = clampw; + outh = clampw / frac; + } + + if (outh > clamph) { + outh = clamph; + outw = clamph * frac; + } +} + +std::vector ReadWholeFile(std::string path) { + std::vector ret; + FILE *fp = std::fopen(path.c_str(), "rb"); + if (fp == nullptr) + return ret; + std::fseek(fp, 0, SEEK_END); + int len = std::ftell(fp); + std::rewind(fp); + ret.resize(len); + std::fread(ret.data(), 1, ret.size(), fp); + std::fclose(fp); + return ret; +} + +std::string HumanReadableBytes(uint64_t bytes) { + constexpr static const char *x[] = { "B", "KB", "MB", "GB", "TB" }; + int order = 0; + while (bytes >= 1000 && order < 4) { // 4=len(x)-1 + order++; + bytes /= 1000; + } + return std::to_string(bytes) + x[order]; +} + +int GetTimezoneOffset() { + std::time_t secs; + std::time(&secs); + std::tm *tptr = std::localtime(&secs); + std::time_t local_secs = std::mktime(tptr); + tptr = std::gmtime(&secs); + std::time_t gmt_secs = std::mktime(tptr); + return 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); + 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_wday = 0; + tm.tm_yday = 0; + tm.tm_isdst = -1; + int offset = GetTimezoneOffset(); + tm.tm_sec += offset + extra_offset; + mktime(&tm); + std::stringstream ss; + const static std::locale locale(""); + ss.imbue(locale); + ss << std::put_time(&tm, fmt.c_str()); + return ss.str(); +} + +void ScrollListBoxToSelected(Gtk::ListBox &list) { + auto cb = [&list]() -> bool { + const auto selected = list.get_selected_row(); + if (selected == nullptr) return false; + int x, y; + selected->translate_coordinates(list, 0, 0, x, y); + if (y < 0) return false; + const auto adj = list.get_adjustment(); + if (!adj) return false; + int min, nat; + selected->get_preferred_height(min, nat); + adj->set_value(y - (adj->get_page_size() - nat) / 2.0); + + return false; + }; + Glib::signal_idle().connect(sigc::track_obj(cb, list)); +} + +// surely theres a better way to do this +bool StringContainsCaseless(const Glib::ustring &str, const Glib::ustring &sub) { + const auto regex = Glib::Regex::create(Glib::Regex::escape_string(sub), Glib::REGEX_CASELESS); + return regex->match(str); +} + +std::string IntToCSSColor(int color) { + int r = (color & 0xFF0000) >> 16; + int g = (color & 0x00FF00) >> 8; + int b = (color & 0x0000FF) >> 0; + std::stringstream ss; + ss << std::hex << std::setw(2) << std::setfill('0') << r + << std::hex << std::setw(2) << std::setfill('0') << g + << std::hex << std::setw(2) << std::setfill('0') << b; + return ss.str(); +} + +Gdk::RGBA IntToRGBA(int color) { + Gdk::RGBA ret; + ret.set_red(((color & 0xFF0000) >> 16) / 255.0); + ret.set_green(((color & 0x00FF00) >> 8) / 255.0); + ret.set_blue(((color & 0x0000FF) >> 0) / 255.0); + ret.set_alpha(255.0); + return ret; +} + +void AddWidgetMenuHandler(Gtk::Widget *widget, Gtk::Menu &menu) { + AddWidgetMenuHandler(widget, menu, []() {}); +} + +// so widgets can modify the menu before it is displayed +// maybe theres a better way to do this idk +void AddWidgetMenuHandler(Gtk::Widget *widget, Gtk::Menu &menu, sigc::slot pre_callback) { + sigc::signal signal; + signal.connect(pre_callback); + widget->signal_button_press_event().connect([&menu, signal](GdkEventButton *ev) -> bool { + if (ev->type == GDK_BUTTON_PRESS && ev->button == GDK_BUTTON_SECONDARY) { + signal.emit(); + menu.popup_at_pointer(reinterpret_cast(ev)); + return true; + } + return false; + // clang-format off + }, false); + // clang-format on +} + +std::vector StringSplit(const std::string &str, const char *delim) { + std::vector parts; + char *token = std::strtok(const_cast(str.c_str()), delim); + while (token != nullptr) { + parts.push_back(token); + token = std::strtok(nullptr, delim); + } + return parts; +} + +std::string GetExtension(std::string url) { + url = StringSplit(url, "?")[0]; + url = StringSplit(url, "/").back(); + return url.find(".") != std::string::npos ? url.substr(url.find_last_of(".")) : ""; +} + +bool IsURLViewableImage(const std::string &url) { + const auto ext = GetExtension(url); + static const char *exts[] = { ".jpeg", + ".jpg", + ".png", nullptr }; + const char *str = ext.c_str(); + for (int i = 0; exts[i] != nullptr; i++) + if (strcmp(str, exts[i]) == 0) + return true; + return false; +} + +void AddPointerCursor(Gtk::Widget &widget) { + widget.signal_realize().connect([&widget]() { + auto window = widget.get_window(); + auto display = window->get_display(); + auto cursor = Gdk::Cursor::create(display, "pointer"); + window->set_cursor(cursor); + }); +} + +bool util::IsFolder(std::string_view path) { + std::error_code ec; + const auto status = std::filesystem::status(path, ec); + if (ec) return false; + return status.type() == std::filesystem::file_type::directory; +} + +bool util::IsFile(std::string_view path) { + std::error_code ec; + const auto status = std::filesystem::status(path, ec); + if (ec) return false; + return status.type() == std::filesystem::file_type::regular; +} diff --git a/src/util.hpp b/src/util.hpp new file mode 100644 index 0000000..feaf08d --- /dev/null +++ b/src/util.hpp @@ -0,0 +1,119 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace util { +template +struct is_optional : ::std::false_type {}; + +template +struct is_optional<::std::optional> : ::std::true_type {}; + +bool IsFolder(std::string_view path); + +bool IsFile(std::string_view path); +} // namespace util + +class Semaphore { +public: + Semaphore(int count = 0); + void notify(); + void wait(); + +private: + std::mutex m_mutex; + std::condition_variable m_cv; + int m_count; +}; + +void LaunchBrowser(Glib::ustring url); +void GetImageDimensions(int inw, int inh, int &outw, int &outh, int clampw = 400, int clamph = 300); +std::string IntToCSSColor(int color); +Gdk::RGBA IntToRGBA(int color); +void AddWidgetMenuHandler(Gtk::Widget *widget, Gtk::Menu &menu); +void AddWidgetMenuHandler(Gtk::Widget *widget, Gtk::Menu &menu, sigc::slot pre_callback); +std::vector StringSplit(const std::string &str, const char *delim); +std::string GetExtension(std::string url); +bool IsURLViewableImage(const std::string &url); +std::vector ReadWholeFile(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"); +void AddPointerCursor(Gtk::Widget &widget); + +template +struct Bitwise { + static const bool enable = false; +}; + +template +typename std::enable_if::enable, T>::type operator|(T a, T b) { + using x = typename std::underlying_type::type; + return static_cast(static_cast(a) | static_cast(b)); +} + +template +typename std::enable_if::enable, T>::type operator|=(T &a, T b) { + using x = typename std::underlying_type::type; + a = static_cast(static_cast(a) | static_cast(b)); + return a; +} + +template +typename std::enable_if::enable, T>::type operator&(T a, T b) { + using x = typename std::underlying_type::type; + return static_cast(static_cast(a) & static_cast(b)); +} + +template +typename std::enable_if::enable, T>::type operator&=(T &a, T b) { + using x = typename std::underlying_type::type; + a = static_cast(static_cast(a) & static_cast(b)); + return a; +} + +template +typename std::enable_if::enable, T>::type operator~(T a) { + return static_cast(~static_cast::type>(a)); +} + +template +inline void AlphabeticalSort(T start, T end, std::function::value_type &)> get_string) { + std::sort(start, end, [&](const auto &a, const auto &b) -> bool { + const std::string &s1 = get_string(a); + const std::string &s2 = get_string(b); + + if (s1.empty() || s2.empty()) + return s1 < s2; + + bool ac[] = { + !isalnum(s1[0]), + !isalnum(s2[0]), + !!isdigit(s1[0]), + !!isdigit(s2[0]), + !!isalpha(s1[0]), + !!isalpha(s2[0]), + }; + + if ((ac[0] && ac[1]) || (ac[2] && ac[3]) || (ac[4] && ac[5])) + return s1 < s2; + + return ac[0] || ac[5]; + }); +} + +void ScrollListBoxToSelected(Gtk::ListBox &list); + +bool StringContainsCaseless(const Glib::ustring &str, const Glib::ustring &sub); diff --git a/src/windows/guildsettings/auditlogpane.cpp b/src/windows/guildsettings/auditlogpane.cpp new file mode 100644 index 0000000..08f99da --- /dev/null +++ b/src/windows/guildsettings/auditlogpane.cpp @@ -0,0 +1,636 @@ +#include "auditlogpane.hpp" +#include "abaddon.hpp" + +using namespace std::string_literals; + +GuildSettingsAuditLogPane::GuildSettingsAuditLogPane(Snowflake id) + : GuildID(id) { + signal_map().connect(sigc::mem_fun(*this, &GuildSettingsAuditLogPane::OnMap)); + set_name("guild-audit-log-pane"); + set_hexpand(true); + set_vexpand(true); + + m_list.set_selection_mode(Gtk::SELECTION_NONE); + m_list.show(); + add(m_list); +} + +void GuildSettingsAuditLogPane::OnMap() { + if (m_requested) return; + m_requested = true; + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + + if (discord.HasGuildPermission(self_id, GuildID, Permission::VIEW_AUDIT_LOG)) + discord.FetchAuditLog(GuildID, sigc::mem_fun(*this, &GuildSettingsAuditLogPane::OnAuditLogFetch)); +} + +void GuildSettingsAuditLogPane::OnAuditLogFetch(const AuditLogData &data) { + auto &discord = Abaddon::Get().GetDiscordClient(); + auto guild = *discord.GetGuild(GuildID); + for (const auto &entry : data.Entries) { + if (entry.TargetID == "") continue; + + auto expander = Gtk::manage(new Gtk::Expander); + auto label = Gtk::manage(new Gtk::Label); + label->set_ellipsize(Pango::ELLIPSIZE_END); + + Glib::ustring user_markup = "Unknown User"; + if (entry.UserID.has_value()) { + if (auto user = discord.GetUser(*entry.UserID); user.has_value()) + user_markup = discord.GetUser(*entry.UserID)->GetEscapedBoldString(); + } + + // spaghetti moment + Glib::ustring markup; + std::vector extra_markup; + switch (entry.Type) { + case AuditLogActionType::GUILD_UPDATE: { + markup = + user_markup + + " made changes to " + + Glib::Markup::escape_text(guild.Name) + + ""; + + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "icon_hash") { + extra_markup.push_back("Set the server icon"); + } else if (change.Key == "name") { + auto new_name = change.NewValue; + if (new_name.has_value()) + extra_markup.push_back("Set the server name to " + + Glib::Markup::escape_text(new_name->get()) + + ""); + else + extra_markup.push_back("Set the server name"); + } + } + } break; + case AuditLogActionType::CHANNEL_CREATE: { + const auto type = *entry.GetNewFromKey("type"); + markup = user_markup + + " created a " + (type == ChannelType::GUILD_VOICE ? "voice" : "text") + + " channel #" + + Glib::Markup::escape_text(*entry.GetNewFromKey("name")) + + ""; + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "name" && change.NewValue.has_value()) + extra_markup.push_back("Set the name to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + else if (change.Key == "nsfw" && change.NewValue.has_value()) + extra_markup.push_back((*change.NewValue ? "Marked" : "Unmarked") + + " the channel as NSFW"s); + } + + } break; + case AuditLogActionType::CHANNEL_UPDATE: { + const auto target_channel = discord.GetChannel(entry.TargetID); + if (target_channel.has_value()) { + markup = user_markup + + " made changes to #" + + Glib::Markup::escape_text(*target_channel->Name) + + ""; + } else { + markup = user_markup + + " made changes to <#" + + entry.TargetID + + ">"; + } + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "name" && change.NewValue.has_value()) { + if (change.OldValue.has_value()) + extra_markup.push_back("Changed the name from " + + Glib::Markup::escape_text(change.OldValue->get()) + + " to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + else + extra_markup.push_back("Changed the name to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } else if (change.Key == "topic") { + if (change.NewValue.has_value()) + extra_markup.push_back("Changed the topic to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + else + extra_markup.push_back("Cleared the topic"); + } else if (change.Key == "nsfw" && change.NewValue.has_value()) { + extra_markup.push_back((*change.NewValue ? "Marked" : "Unmarked") + " the channel as NSFW"s); + } else if (change.Key == "rate_limit_per_user" && change.NewValue.has_value()) { + const int secs = change.NewValue->get(); + if (secs == 0) + extra_markup.push_back("Disabled slowmode"); + else + extra_markup.push_back("Set slowmode to " + + std::to_string(secs) + " seconds"); + } + } + } break; + case AuditLogActionType::CHANNEL_DELETE: { + markup = user_markup + + " removed #" + + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + + ""; + } break; + case AuditLogActionType::CHANNEL_OVERWRITE_CREATE: { + const auto channel = discord.GetChannel(entry.TargetID); + if (channel.has_value()) { + markup = user_markup + + " created channel overrides for #" + + Glib::Markup::escape_text(*channel->Name) + ""; + } else { + markup = user_markup + + " created channel overrides for <#" + + entry.TargetID + ">"; + } + } break; + case AuditLogActionType::CHANNEL_OVERWRITE_UPDATE: { + const auto channel = discord.GetChannel(entry.TargetID); + if (channel.has_value()) { + markup = user_markup + + " updated channel overrides for #" + + Glib::Markup::escape_text(*channel->Name) + ""; + } else { + markup = user_markup + + " updated channel overrides for <#" + + entry.TargetID + ">"; + } + } break; + case AuditLogActionType::CHANNEL_OVERWRITE_DELETE: { + const auto channel = discord.GetChannel(entry.TargetID); + if (channel.has_value()) { + markup = user_markup + + " removed channel overrides for #" + + Glib::Markup::escape_text(*channel->Name) + ""; + } else { + markup = user_markup + + " removed channel overrides for <#" + + entry.TargetID + ">"; + } + } break; + case AuditLogActionType::MEMBER_KICK: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " kicked " + + target_user->GetEscapedString() + + ""; + } break; + case AuditLogActionType::MEMBER_PRUNE: { + markup = user_markup + + " pruned " + + *entry.Options->MembersRemoved + + " members"; + extra_markup.push_back("For " + + *entry.Options->DeleteMemberDays + + " days of inactivity"); + } break; + case AuditLogActionType::MEMBER_BAN_ADD: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " banned " + + target_user->GetEscapedString() + + ""; + } break; + case AuditLogActionType::MEMBER_BAN_REMOVE: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " removed the ban for " + + target_user->GetEscapedString() + + ""; + } break; + case AuditLogActionType::MEMBER_UPDATE: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " updated " + + target_user->GetEscapedString() + + ""; + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "deaf" && change.NewValue.has_value()) + extra_markup.push_back( + (change.NewValue->get() ? "Deafened"s : "Undeafened"s) + + " them"); + else if (change.Key == "mute" && change.NewValue.has_value()) + extra_markup.push_back( + (change.NewValue->get() ? "Muted"s : "Unmuted"s) + + " them"); + else if (change.Key == "nick" && change.NewValue.has_value()) + extra_markup.push_back("Set their nickname to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } + } break; + case AuditLogActionType::MEMBER_ROLE_UPDATE: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " updated roles for " + + target_user->GetEscapedString() + ""; + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "$remove" && change.NewValue.has_value()) { + extra_markup.push_back("Removed a role " + + Glib::Markup::escape_text(change.NewValue.value()[0].at("name").get()) + + ""); + } else if (change.Key == "$add" && change.NewValue.has_value()) { + extra_markup.push_back("Added a role " + + Glib::Markup::escape_text(change.NewValue.value()[0].at("name").get()) + + ""); + } + } + } break; + case AuditLogActionType::MEMBER_MOVE: { + const auto channel = discord.GetChannel(*entry.Options->ChannelID); + markup = user_markup + + " moved " + + *entry.Options->Count + + " user" + + (*entry.Options->Count == "1" ? ""s : "s"s) + + " to " + + Glib::Markup::escape_text(*channel->Name) + + ""; + } break; + case AuditLogActionType::MEMBER_DISCONNECT: { + markup = user_markup + + " disconnected " + + *entry.Options->Count + + " users from voice"; + } break; + case AuditLogActionType::BOT_ADD: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " added " + + target_user->GetEscapedString() + + " to the server"; + } break; + case AuditLogActionType::ROLE_CREATE: { + markup = user_markup + + " created the role " + + *entry.GetNewFromKey("name") + + ""; + } break; + case AuditLogActionType::ROLE_UPDATE: { + const auto role = discord.GetRole(entry.TargetID); + markup = user_markup + + " updated the role " + + (role.has_value() ? Glib::Markup::escape_text(role->Name) : Glib::ustring(entry.TargetID)) + + ""; + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "name" && change.NewValue.has_value()) { + extra_markup.push_back("Changed the name to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } else if (change.Key == "color" && change.NewValue.has_value()) { + const auto col = change.NewValue->get(); + if (col == 0) + extra_markup.push_back("Removed the color"); + else + extra_markup.push_back("Set the color to " + + IntToCSSColor(col) + + ""); + } else if (change.Key == "permissions") { + extra_markup.push_back("Updated the permissions"); + } else if (change.Key == "mentionable" && change.NewValue.has_value()) { + extra_markup.push_back(change.NewValue->get() ? "Mentionable" : "Not mentionable"); + } else if (change.Key == "hoist" && change.NewValue.has_value()) { + extra_markup.push_back(change.NewValue->get() ? "Not hoisted" : "Hoisted"); + } + } + } break; + case AuditLogActionType::ROLE_DELETE: { + markup = user_markup + + " deleted the role " + + *entry.GetOldFromKey("name") + + ""; + } break; + case AuditLogActionType::INVITE_CREATE: { + const auto code = *entry.GetNewFromKey("code"); + markup = user_markup + + " created an invite " + code + ""; + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "channel_id" && change.NewValue.has_value()) { + const auto channel = discord.GetChannel(change.NewValue->get()); + if (!channel.has_value()) continue; + extra_markup.push_back("For channel #" + + Glib::Markup::escape_text(*channel->Name) + + ""); + } else if (change.Key == "max_uses" && change.NewValue.has_value()) { + const auto uses = change.NewValue->get(); + if (uses == 0) + extra_markup.push_back("Which has unlimited uses"); + else + extra_markup.push_back("Which has " + std::to_string(uses) + " uses"); + } else if (change.Key == "temporary" && change.NewValue.has_value()) { + extra_markup.push_back("With temporary "s + + (change.NewValue->get() ? "on" : "off") + + ""); + } // no max_age cuz fuck time + } + } break; + case AuditLogActionType::INVITE_DELETE: { + markup = user_markup + + " deleted an invite " + + *entry.GetOldFromKey("code") + + ""; + } break; + case AuditLogActionType::WEBHOOK_CREATE: { + markup = user_markup + + " created the webhook " + + Glib::Markup::escape_text(*entry.GetNewFromKey("name")) + + ""; + for (const auto &change : *entry.Changes) { + if (change.Key == "channel_id" && change.NewValue.has_value()) { + const auto channel = discord.GetChannel(change.NewValue->get()); + if (channel.has_value()) { + extra_markup.push_back("With channel #" + + Glib::Markup::escape_text(*channel->Name) + + ""); + } + } + } + } break; + case AuditLogActionType::WEBHOOK_UPDATE: { + const WebhookData *webhookptr = nullptr; + for (const auto &webhook : data.Webhooks) { + if (webhook.ID == entry.TargetID) + webhookptr = &webhook; + } + if (webhookptr != nullptr) { + markup = user_markup + + " updated the webhook " + + Glib::Markup::escape_text(webhookptr->Name) + + ""; + } else { + markup = user_markup + + " updated a webhook"; + } + if (entry.Changes.has_value()) + for (const auto &change : *entry.Changes) { + if (change.Key == "name" && change.NewValue.has_value()) { + extra_markup.push_back("Changed the name to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } else if (change.Key == "avatar_hash") { + extra_markup.push_back("Changed the avatar"); + } else if (change.Key == "channel_id" && change.NewValue.has_value()) { + const auto channel = discord.GetChannel(change.NewValue->get()); + if (channel.has_value()) { + extra_markup.push_back("Changed the channel to #" + + Glib::Markup::escape_text(*channel->Name) + + ""); + } else { + extra_markup.push_back("Changed the channel"); + } + } + } + } break; + case AuditLogActionType::WEBHOOK_DELETE: { + markup = user_markup + + " deleted the webhook " + + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + + ""; + } break; + case AuditLogActionType::EMOJI_CREATE: { + markup = user_markup + + " created the emoji " + + Glib::Markup::escape_text(*entry.GetNewFromKey("name")) + + ""; + } break; + case AuditLogActionType::EMOJI_UPDATE: { + markup = user_markup + + " updated the emoji " + + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + + ""; + extra_markup.push_back("Changed the name from " + + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + + " to " + + Glib::Markup::escape_text(*entry.GetNewFromKey("name")) + + ""); + } break; + case AuditLogActionType::EMOJI_DELETE: { + markup = user_markup + + " deleted the emoji " + + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + + ""; + } break; + case AuditLogActionType::MESSAGE_DELETE: { + const auto channel = discord.GetChannel(*entry.Options->ChannelID); + const auto count = *entry.Options->Count; + if (channel.has_value()) { + markup = user_markup + + " deleted " + count + " messages in #" + + Glib::Markup::escape_text(*channel->Name) + + ""; + } else { + markup = user_markup + + " deleted " + count + " messages"; + } + } break; + case AuditLogActionType::MESSAGE_BULK_DELETE: { + const auto channel = discord.GetChannel(entry.TargetID); + if (channel.has_value()) { + markup = user_markup + + " deleted " + + *entry.Options->Count + + " messages in #" + + Glib::Markup::escape_text(*channel->Name) + + ""; + } else { + markup = user_markup + + " deleted " + + *entry.Options->Count + + " messages"; + } + } break; + case AuditLogActionType::MESSAGE_PIN: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " pinned a message by " + + target_user->GetEscapedString() + + ""; + } break; + case AuditLogActionType::MESSAGE_UNPIN: { + const auto target_user = discord.GetUser(entry.TargetID); + markup = user_markup + + " unpinned a message by " + + target_user->GetEscapedString() + + ""; + } break; + case AuditLogActionType::STAGE_INSTANCE_CREATE: { + const auto channel = discord.GetChannel(*entry.Options->ChannelID); + if (channel.has_value()) { + markup = user_markup + + " started the stage for " + + Glib::Markup::escape_text(*channel->Name) + + ""; + } else { + markup = user_markup + + " started the stage for " + + std::to_string(*entry.Options->ChannelID) + + ""; + } + + if (entry.Changes.has_value()) { + for (const auto &change : *entry.Changes) { + if (change.Key == "topic" && change.NewValue.has_value()) { + extra_markup.push_back( + "Set the topic to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } else if (change.Key == "privacy_level" && change.NewValue.has_value()) { + Glib::ustring str = Glib::Markup::escape_text(GetStagePrivacyDisplayString(change.NewValue->get())); + extra_markup.push_back( + "Set the privacy level to " + + str + + ""); + } + } + } + } break; + case AuditLogActionType::STAGE_INSTANCE_UPDATE: { + const auto channel = discord.GetChannel(*entry.Options->ChannelID); + if (channel.has_value()) { + markup = user_markup + + " updated the stage for " + + Glib::Markup::escape_text(*channel->Name) + + ""; + } else { + markup = user_markup + + " updated the stage for " + + std::to_string(*entry.Options->ChannelID) + + ""; + } + + if (entry.Changes.has_value()) { + for (const auto &change : *entry.Changes) { + if (change.Key == "topic" && change.NewValue.has_value()) { + extra_markup.push_back( + "Set the topic to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + } else if (change.Key == "privacy_level" && change.NewValue.has_value()) { + Glib::ustring str = Glib::Markup::escape_text(GetStagePrivacyDisplayString(change.NewValue->get())); + extra_markup.push_back( + "Set the privacy level to " + + str + + ""); + } + } + } + } break; + case AuditLogActionType::STAGE_INSTANCE_DELETE: { + const auto channel = discord.GetChannel(*entry.Options->ChannelID); + if (channel.has_value()) { + markup = user_markup + + " ended the stage for " + + Glib::Markup::escape_text(*channel->Name) + + ""; + } else { + markup = user_markup + + " ended the stage for " + + std::to_string(*entry.Options->ChannelID) + + ""; + } + } break; + case AuditLogActionType::THREAD_CREATE: { + const auto channel = discord.GetChannel(entry.TargetID); + markup = user_markup + + " created a thread " + + (channel.has_value() + ? Glib::Markup::escape_text(*channel->Name) + : Glib::ustring(*entry.GetNewFromKey("name"))) + + ""; + if (entry.Changes.has_value()) { + for (const auto &change : *entry.Changes) { + if (change.Key == "name") + extra_markup.push_back("Set the name to " + Glib::Markup::escape_text(change.NewValue->get()) + ""); + else if (change.Key == "archived") + extra_markup.push_back(change.NewValue->get() ? "Archived the thread" : "Unarchived the thread"); + else if (change.Key == "auto_archive_duration") + extra_markup.push_back("Set auto archive duration to "s + std::to_string(change.NewValue->get()) + " minutes"s); + else if (change.Key == "rate_limit_per_user" && change.NewValue.has_value()) { + const int secs = change.NewValue->get(); + if (secs == 0) + extra_markup.push_back("Disabled slowmode"); + else + extra_markup.push_back("Set slowmode to " + + std::to_string(secs) + " seconds"); + } else if (change.Key == "locked") + extra_markup.push_back(change.NewValue->get() ? "Locked the thread, restricting it to only be unarchived by moderators" : "Unlocked the thread, allowing it to be unarchived by non-moderators"); + } + } + } break; + case AuditLogActionType::THREAD_UPDATE: { + const auto channel = discord.GetChannel(entry.TargetID); + markup = user_markup + + " made changes to the thread " + + (channel.has_value() + ? Glib::Markup::escape_text(*channel->Name) + : Glib::ustring(entry.TargetID)) + + ""; + for (const auto &change : *entry.Changes) { + if (change.Key == "name") + extra_markup.push_back( + "Changed the name from " + + Glib::Markup::escape_text(change.OldValue->get()) + + " to " + + Glib::Markup::escape_text(change.NewValue->get()) + + ""); + else if (change.Key == "auto_archive_duration") + extra_markup.push_back("Set auto archive duration to "s + std::to_string(change.NewValue->get()) + " minutes"s); + else if (change.Key == "rate_limit_per_user" && change.NewValue.has_value()) { + const int secs = change.NewValue->get(); + if (secs == 0) + extra_markup.push_back("Disabled slowmode"); + else + extra_markup.push_back("Set slowmode to " + + std::to_string(secs) + + " seconds"); + } else if (change.Key == "locked") + extra_markup.push_back(change.NewValue->get() ? "Locked the thread, restricting it to only be unarchived by moderators" : "Unlocked the thread, allowing it to be unarchived by non-moderators"); + else if (change.Key == "archived") + extra_markup.push_back(change.NewValue->get() ? "Archived the thread" : "Unarchived the thread"); + } + } break; + case AuditLogActionType::THREAD_DELETE: { + markup = user_markup + + " deleted the thread " + Glib::Markup::escape_text(*entry.GetOldFromKey("name")) + ""; + } break; + default: + markup = "Unknown action"; + break; + } + + label->set_markup(markup); + expander->set_label_widget(*label); + + if (entry.Reason.has_value()) { + extra_markup.push_back("With reason " + + Glib::Markup::escape_text(*entry.Reason) + + ""); + } + + expander->set_expanded(true); + + auto contents = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL)); + for (const auto &extra : extra_markup) { + auto extra_label = Gtk::manage(new Gtk::Label); + extra_label->set_markup(extra); + extra_label->set_halign(Gtk::ALIGN_START); + extra_label->set_margin_start(25); + extra_label->set_ellipsize(Pango::ELLIPSIZE_END); + contents->add(*extra_label); + } + expander->add(*contents); + expander->set_margin_bottom(5); + expander->show_all(); + m_list.add(*expander); + } +} diff --git a/src/windows/guildsettings/auditlogpane.hpp b/src/windows/guildsettings/auditlogpane.hpp new file mode 100644 index 0000000..ac12321 --- /dev/null +++ b/src/windows/guildsettings/auditlogpane.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class GuildSettingsAuditLogPane : public Gtk::ScrolledWindow { +public: + GuildSettingsAuditLogPane(Snowflake id); + +private: + void OnMap(); + + bool m_requested = false; + + Gtk::ListBox m_list; + + void OnAuditLogFetch(const AuditLogData &data); + + Snowflake GuildID; +}; diff --git a/src/windows/guildsettings/banspane.cpp b/src/windows/guildsettings/banspane.cpp new file mode 100644 index 0000000..97a70c4 --- /dev/null +++ b/src/windows/guildsettings/banspane.cpp @@ -0,0 +1,161 @@ +#include "banspane.hpp" +#include "abaddon.hpp" + +// gtk_list_store_set_value: assertion 'column >= 0 && column < priv->n_columns' failed +// dont care to figure out why this happens cuz it doesnt seem to break anything + +GuildSettingsBansPane::GuildSettingsBansPane(Snowflake id) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , GuildID(id) + , m_model(Gtk::ListStore::create(m_columns)) + , m_menu_unban("Unban") + , m_menu_copy_id("Copy ID") { + signal_map().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnMap)); + set_name("guild-bans-pane"); + set_hexpand(true); + set_vexpand(true); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + discord.signal_guild_ban_add().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnBanAdd)); + discord.signal_guild_ban_remove().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnBanRemove)); + + const auto self_id = discord.GetUserData().ID; + const auto can_ban = discord.HasGuildPermission(self_id, GuildID, Permission::BAN_MEMBERS); + + if (!can_ban) { + for (const auto &ban : discord.GetBansInGuild(id)) + OnGuildBanFetch(ban); + + m_no_perms_note = Gtk::manage(new Gtk::Label("You do not have permission to see bans. However, bans made while you are connected will appear here")); + m_no_perms_note->set_single_line_mode(true); + m_no_perms_note->set_ellipsize(Pango::ELLIPSIZE_END); + m_no_perms_note->set_halign(Gtk::ALIGN_START); + add(*m_no_perms_note); + } + + m_menu_unban.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnMenuUnban)); + m_menu_copy_id.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnMenuCopyID)); + m_menu_unban.show(); + m_menu_copy_id.show(); + m_menu.append(m_menu_unban); + m_menu.append(m_menu_copy_id); + + m_view.signal_button_press_event().connect(sigc::mem_fun(*this, &GuildSettingsBansPane::OnTreeButtonPress), false); + m_view.show(); + + m_scroll.set_propagate_natural_height(true); + m_scroll.add(m_view); + add(m_scroll); + show_all_children(); + + m_view.set_enable_search(false); + m_view.set_model(m_model); + m_view.append_column("User", m_columns.m_col_user); + m_view.append_column("Reason", m_columns.m_col_reason); +} + +void GuildSettingsBansPane::OnMap() { + if (m_requested) return; + m_requested = true; + + auto &discord = Abaddon::Get().GetDiscordClient(); + + const auto self_id = discord.GetUserData().ID; + const auto can_ban = discord.HasGuildPermission(self_id, GuildID, Permission::BAN_MEMBERS); + + if (can_ban) + discord.FetchGuildBans(GuildID, sigc::mem_fun(*this, &GuildSettingsBansPane::OnGuildBansFetch)); +} + +void GuildSettingsBansPane::OnGuildBanFetch(const BanData &ban) { + const auto user = Abaddon::Get().GetDiscordClient().GetUser(ban.User.ID); + auto row = *m_model->append(); + row[m_columns.m_col_id] = ban.User.ID; + if (user.has_value()) + row[m_columns.m_col_user] = user->Username + "#" + user->Discriminator; + else + row[m_columns.m_col_user] = "<@" + std::to_string(ban.User.ID) + ">"; + + row[m_columns.m_col_reason] = ban.Reason; +} + +void GuildSettingsBansPane::OnGuildBansFetch(const std::vector &bans) { + for (const auto &ban : bans) { + const auto user = Abaddon::Get().GetDiscordClient().GetUser(ban.User.ID); + auto row = *m_model->append(); + row[m_columns.m_col_id] = user->ID; + row[m_columns.m_col_user] = user->Username + "#" + user->Discriminator; + row[m_columns.m_col_reason] = ban.Reason; + } +} + +void GuildSettingsBansPane::OnMenuUnban() { + auto selected_row = *m_view.get_selection()->get_selected(); + if (selected_row) { + Snowflake id = selected_row[m_columns.m_col_id]; + auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to unban user", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().UnbanUser(GuildID, id, sigc::track_obj(cb, *this)); + } +} + +void GuildSettingsBansPane::OnMenuCopyID() { + auto selected_row = *m_view.get_selection()->get_selected(); + if (selected_row) + Gtk::Clipboard::get()->set_text(std::to_string(static_cast(selected_row[m_columns.m_col_id]))); +} + +bool GuildSettingsBansPane::OnTreeButtonPress(GdkEventButton *event) { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_SECONDARY) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const auto can_ban = discord.HasGuildPermission(self_id, GuildID, Permission::BAN_MEMBERS); + m_menu_unban.set_sensitive(can_ban); + auto selection = m_view.get_selection(); + Gtk::TreeModel::Path path; + if (m_view.get_path_at_pos(event->x, event->y, path)) { + m_view.get_selection()->select(path); + m_menu.popup_at_pointer(reinterpret_cast(event)); + } + + return true; + } + + return false; +} + +void GuildSettingsBansPane::OnBanRemove(Snowflake guild_id, Snowflake user_id) { + if (guild_id != GuildID) return; + for (auto &child : m_model->children()) { + if (static_cast(child[m_columns.m_col_id]) == user_id) { + m_model->erase(child); + break; + } + } +} + +void GuildSettingsBansPane::OnBanAdd(Snowflake guild_id, Snowflake user_id) { + if (guild_id != GuildID) return; + auto &discord = Abaddon::Get().GetDiscordClient(); + if (discord.HasGuildPermission(discord.GetUserData().ID, guild_id, Permission::BAN_MEMBERS)) { + discord.FetchGuildBan(guild_id, user_id, sigc::mem_fun(*this, &GuildSettingsBansPane::OnGuildBanFetch)); + } else { + auto user = *discord.GetUser(user_id); + auto row = *m_model->append(); + row[m_columns.m_col_id] = user_id; + row[m_columns.m_col_user] = user.Username + "#" + user.Discriminator; + row[m_columns.m_col_reason] = ""; + } +} + +GuildSettingsBansPane::ModelColumns::ModelColumns() { + add(m_col_id); + add(m_col_user); + add(m_col_reason); +} diff --git a/src/windows/guildsettings/banspane.hpp b/src/windows/guildsettings/banspane.hpp new file mode 100644 index 0000000..b2420a9 --- /dev/null +++ b/src/windows/guildsettings/banspane.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include "discord/snowflake.hpp" +#include "discord/ban.hpp" + +class GuildSettingsBansPane : public Gtk::Box { +public: + GuildSettingsBansPane(Snowflake id); + +private: + void OnMap(); + + bool m_requested = false; + + void OnGuildBanFetch(const BanData &ban); + void OnGuildBansFetch(const std::vector &bans); + void OnMenuUnban(); + void OnMenuCopyID(); + bool OnTreeButtonPress(GdkEventButton *event); + void OnBanRemove(Snowflake guild_id, Snowflake user_id); + void OnBanAdd(Snowflake guild_id, Snowflake user_id); + + Gtk::Label *m_no_perms_note = nullptr; + + Gtk::ScrolledWindow m_scroll; + Gtk::TreeView m_view; + + Snowflake GuildID; + + class ModelColumns : public Gtk::TreeModel::ColumnRecord { + public: + ModelColumns(); + + Gtk::TreeModelColumn m_col_user; + Gtk::TreeModelColumn m_col_reason; + Gtk::TreeModelColumn m_col_id; + }; + + ModelColumns m_columns; + Glib::RefPtr m_model; + + Gtk::Menu m_menu; + Gtk::MenuItem m_menu_unban; + Gtk::MenuItem m_menu_copy_id; +}; diff --git a/src/windows/guildsettings/emojispane.cpp b/src/windows/guildsettings/emojispane.cpp new file mode 100644 index 0000000..1f4bfa9 --- /dev/null +++ b/src/windows/guildsettings/emojispane.cpp @@ -0,0 +1,257 @@ +#include "emojispane.hpp" +#include "abaddon.hpp" +#include "components/cellrendererpixbufanimation.hpp" + +GuildSettingsEmojisPane::GuildSettingsEmojisPane(Snowflake guild_id) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , GuildID(guild_id) + , m_model(Gtk::ListStore::create(m_columns)) + , m_filter(Gtk::TreeModelFilter::create(m_model)) + , m_menu_delete("Delete") + , m_menu_copy_id("Copy ID") + , m_menu_copy_emoji_url("Copy Emoji URL") + , m_menu_show_emoji("Open in Browser") { + signal_map().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnMap)); + set_name("guild-emojis-pane"); + + m_view_scroll.set_hexpand(true); + m_view_scroll.set_vexpand(true); + + m_view.signal_button_press_event().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnTreeButtonPress), false); + + m_menu_copy_id.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnMenuCopyID)); + m_menu_delete.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnMenuDelete)); + m_menu_copy_emoji_url.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnMenuCopyEmojiURL)); + m_menu_show_emoji.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnMenuShowEmoji)); + + m_menu.append(m_menu_delete); + m_menu.append(m_menu_copy_id); + m_menu.append(m_menu_copy_emoji_url); + m_menu.append(m_menu_show_emoji); + m_menu.show_all(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + discord.signal_guild_emojis_update().connect(sigc::hide<0>(sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnFetchEmojis))); + + const auto self_id = discord.GetUserData().ID; + const bool can_manage = discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_EMOJIS); + m_menu_delete.set_sensitive(can_manage); + + m_search.set_placeholder_text("Filter"); + m_search.signal_changed().connect([this]() { + m_filter->refilter(); + }); + + m_view_scroll.add(m_view); + add(m_search); + add(m_view_scroll); + m_search.show(); + m_view.show(); + m_view_scroll.show(); + + m_filter->set_visible_func([this](const Gtk::TreeModel::const_iterator &iter) -> bool { + const auto text = m_search.get_text(); + if (text == "") return true; + return StringContainsCaseless((*iter)[m_columns.m_col_name], text); + }); + m_view.set_enable_search(false); + m_view.set_model(m_filter); + + auto *column = Gtk::manage(new Gtk::TreeView::Column("Emoji")); + auto *renderer = Gtk::manage(new CellRendererPixbufAnimation); + column->pack_start(*renderer); + column->add_attribute(renderer->property_pixbuf(), m_columns.m_col_pixbuf); + column->add_attribute(renderer->property_pixbuf_animation(), m_columns.m_col_pixbuf_animation); + m_view.append_column(*column); + + if (can_manage) { + auto *column = Gtk::manage(new Gtk::TreeView::Column("Name")); + auto *renderer = Gtk::manage(new Gtk::CellRendererText); + column->pack_start(*renderer); + column->add_attribute(renderer->property_text(), m_columns.m_col_name); + renderer->property_editable() = true; + renderer->signal_edited().connect([this, renderer, column](const Glib::ustring &path, const Glib::ustring &text) { + std::string new_str; + int size = 0; + for (const auto ch : text) { + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') + new_str += ch; + else if (ch == ' ') + new_str += '_'; + if (++size == 32) break; + } + if (auto row = *m_model->get_iter(path)) { + row[m_columns.m_col_name] = new_str; + OnEditName(row[m_columns.m_col_id], new_str); + } + }); + m_view.append_column(*column); + } else + m_view.append_column("Name", m_columns.m_col_name); + if (can_manage) + m_view.append_column("Creator", m_columns.m_col_creator); + m_view.append_column("Is Animated?", m_columns.m_col_animated); + + for (const auto column : m_view.get_columns()) + column->set_resizable(true); +} + +void GuildSettingsEmojisPane::OnMap() { + m_view.grab_focus(); + + if (m_requested) return; + m_requested = true; + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const bool can_manage = discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_EMOJIS); + m_menu_delete.set_sensitive(can_manage); + + discord.FetchGuildEmojis(GuildID, sigc::mem_fun(*this, &GuildSettingsEmojisPane::OnFetchEmojis)); +} + +void GuildSettingsEmojisPane::AddEmojiRow(const EmojiData &emoji) { + auto &img = Abaddon::Get().GetImageManager(); + + auto row = *m_model->append(); + + row[m_columns.m_col_id] = emoji.ID; + row[m_columns.m_col_pixbuf] = img.GetPlaceholder(32); + row[m_columns.m_col_name] = emoji.Name; + if (emoji.Creator.has_value()) + row[m_columns.m_col_creator] = emoji.Creator->Username + "#" + emoji.Creator->Discriminator; + if (emoji.IsAnimated.has_value()) + row[m_columns.m_col_animated] = *emoji.IsAnimated ? "Yes" : "No"; + else + row[m_columns.m_col_animated] = "No"; + if (emoji.IsAvailable.has_value()) + row[m_columns.m_col_available] = *emoji.IsAvailable ? "Yes" : "No"; + else + row[m_columns.m_col_available] = "Yes"; + + static bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + if (show_animations && emoji.IsAnimated.has_value() && *emoji.IsAnimated) { + const auto cb = [this, id = emoji.ID](const Glib::RefPtr &pb) { + for (auto &row : m_model->children()) { + if (static_cast(row[m_columns.m_col_id]) == id) { + row[m_columns.m_col_pixbuf_animation] = pb; + return; + } + } + }; + img.LoadAnimationFromURL(emoji.GetURL("gif"), 32, 32, sigc::track_obj(cb, *this)); + } else { + const auto cb = [this, id = emoji.ID](const Glib::RefPtr &pb) { + for (auto &row : m_model->children()) { + if (static_cast(row[m_columns.m_col_id]) == id) { + row[m_columns.m_col_pixbuf] = pb->scale_simple(32, 32, Gdk::INTERP_BILINEAR); + return; + } + } + }; + img.LoadFromURL(emoji.GetURL(), sigc::track_obj(cb, *this)); + } +} + +void GuildSettingsEmojisPane::OnFetchEmojis(std::vector emojis) { + m_model->clear(); + + // put animated emojis at the end then sort alphabetically + std::sort(emojis.begin(), emojis.end(), [&](const EmojiData &a, const EmojiData &b) { + const bool a_is_animated = a.IsAnimated.has_value() && *a.IsAnimated; + const bool b_is_animated = b.IsAnimated.has_value() && *b.IsAnimated; + if (a_is_animated == b_is_animated) + return a.Name < b.Name; + else if (a_is_animated && !b_is_animated) + return false; + else if (!a_is_animated && b_is_animated) + return true; + return false; // this wont happen please be quiet compiler + }); + + for (const auto &emoji : emojis) + AddEmojiRow(emoji); +} + +void GuildSettingsEmojisPane::OnEditName(Snowflake id, const std::string &name) { + const auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to set emoji name", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().ModifyEmojiName(GuildID, id, name, cb); +} + +void GuildSettingsEmojisPane::OnMenuCopyID() { + if (auto selected_row = *m_view.get_selection()->get_selected()) { + const auto id = static_cast(selected_row[m_columns.m_col_id]); + Gtk::Clipboard::get()->set_text(std::to_string(id)); + } +} + +void GuildSettingsEmojisPane::OnMenuDelete() { + if (auto selected_row = *m_view.get_selection()->get_selected()) { + const auto name = static_cast(selected_row[m_columns.m_col_name]); + const auto id = static_cast(selected_row[m_columns.m_col_id]); + if (auto *window = dynamic_cast(get_toplevel())) + if (Abaddon::Get().ShowConfirm("Are you sure you want to delete " + name + "?", window)) { + const auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to delete emoji", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().DeleteEmoji(GuildID, id, cb); + } + } +} + +void GuildSettingsEmojisPane::OnMenuCopyEmojiURL() { + if (auto selected_row = *m_view.get_selection()->get_selected()) { + const auto id = static_cast(selected_row[m_columns.m_col_id]); + const bool is_animated = static_cast(selected_row[m_columns.m_col_animated]) == "Yes"; + Gtk::Clipboard::get()->set_text(EmojiData::URLFromID(id, is_animated ? "gif" : "png", "256")); + } +} + +void GuildSettingsEmojisPane::OnMenuShowEmoji() { + if (auto selected_row = *m_view.get_selection()->get_selected()) { + const auto id = static_cast(selected_row[m_columns.m_col_id]); + const bool is_animated = static_cast(selected_row[m_columns.m_col_animated]) == "Yes"; + LaunchBrowser(EmojiData::URLFromID(id, is_animated ? "gif" : "png", "256")); + } +} + +bool GuildSettingsEmojisPane::OnTreeButtonPress(GdkEventButton *event) { + if (event->button == GDK_BUTTON_SECONDARY) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const bool can_manage = discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_EMOJIS); + m_menu_delete.set_sensitive(can_manage); + + auto selection = m_view.get_selection(); + Gtk::TreeModel::Path path; + if (m_view.get_path_at_pos(event->x, event->y, path)) { + m_view.get_selection()->select(path); + m_menu.popup_at_pointer(reinterpret_cast(event)); + } + + return true; + } + + return false; +} + +GuildSettingsEmojisPane::ModelColumns::ModelColumns() { + add(m_col_id); + add(m_col_pixbuf); + add(m_col_pixbuf_animation); + add(m_col_name); + add(m_col_creator); + add(m_col_animated); + add(m_col_available); +} diff --git a/src/windows/guildsettings/emojispane.hpp b/src/windows/guildsettings/emojispane.hpp new file mode 100644 index 0000000..1c0edd1 --- /dev/null +++ b/src/windows/guildsettings/emojispane.hpp @@ -0,0 +1,53 @@ +#pragma once +#include +#include "discord/emoji.hpp" + +class GuildSettingsEmojisPane : public Gtk::Box { +public: + GuildSettingsEmojisPane(Snowflake guild_id); + +private: + void OnMap(); + + bool m_requested = false; + + void AddEmojiRow(const EmojiData &emoji); + + void OnFetchEmojis(std::vector emojis); + + void OnEditName(Snowflake id, const std::string &name); + void OnMenuCopyID(); + void OnMenuDelete(); + void OnMenuCopyEmojiURL(); + void OnMenuShowEmoji(); + bool OnTreeButtonPress(GdkEventButton *event); + + Snowflake GuildID; + + Gtk::Entry m_search; + Gtk::ScrolledWindow m_view_scroll; + Gtk::TreeView m_view; + + class ModelColumns : public Gtk::TreeModel::ColumnRecord { + public: + ModelColumns(); + + Gtk::TreeModelColumn> m_col_pixbuf; + Gtk::TreeModelColumn> m_col_pixbuf_animation; + Gtk::TreeModelColumn m_col_name; + Gtk::TreeModelColumn m_col_creator; + Gtk::TreeModelColumn m_col_animated; + Gtk::TreeModelColumn m_col_available; + Gtk::TreeModelColumn m_col_id; + }; + + ModelColumns m_columns; + Glib::RefPtr m_model; + Glib::RefPtr m_filter; + + Gtk::Menu m_menu; + Gtk::MenuItem m_menu_delete; + Gtk::MenuItem m_menu_copy_id; + Gtk::MenuItem m_menu_copy_emoji_url; + Gtk::MenuItem m_menu_show_emoji; +}; diff --git a/src/windows/guildsettings/infopane.cpp b/src/windows/guildsettings/infopane.cpp new file mode 100644 index 0000000..b4f75f3 --- /dev/null +++ b/src/windows/guildsettings/infopane.cpp @@ -0,0 +1,220 @@ +#include "infopane.hpp" +#include "abaddon.hpp" +#include + +GuildSettingsInfoPane::GuildSettingsInfoPane(Snowflake id) + : m_guild_icon_label("Guild icon") + , m_guild_name_label("Guild name") + , GuildID(id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto guild = *discord.GetGuild(id); + const auto self_id = discord.GetUserData().ID; + const auto can_modify = discord.HasGuildPermission(self_id, id, Permission::MANAGE_GUILD); + + set_name("guild-info-pane"); + + m_guild_name.set_sensitive(can_modify); + m_guild_name.set_text(guild.Name); + m_guild_name.signal_focus_out_event().connect([this](GdkEventFocus *e) -> bool { + UpdateGuildName(); + return false; + }); + m_guild_name.signal_key_press_event().connect([this](GdkEventKey *e) -> bool { + if (e->keyval == GDK_KEY_Return) + UpdateGuildName(); + return false; + // clang-format off + }, false); + // clang-format on + m_guild_name.set_tooltip_text("Press enter or lose focus to submit"); + m_guild_name.show(); + m_guild_name_label.show(); + + auto guild_update_cb = [this](Snowflake id) { + if (id != GuildID) return; + const auto guild = *Abaddon::Get().GetDiscordClient().GetGuild(id); + FetchGuildIcon(guild); + }; + discord.signal_guild_update().connect(sigc::track_obj(guild_update_cb, *this)); + FetchGuildIcon(guild); + + AddPointerCursor(m_guild_icon_ev); + + m_guild_icon.set_margin_bottom(10); + if (can_modify) { + m_guild_icon_ev.set_tooltip_text("Click to choose a file, right click to paste"); + + m_guild_icon_ev.signal_button_press_event().connect([this](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS) { + if (event->button == GDK_BUTTON_PRIMARY) + UpdateGuildIconPicker(); + else if (event->button == GDK_BUTTON_SECONDARY) + UpdateGuildIconClipboard(); + } + + return false; + }); + } else if (guild.HasIcon()) { + std::string guild_icon_url; + if (guild.HasAnimatedIcon()) + guild_icon_url = guild.GetIconURL("gif", "512"); + else + guild_icon_url = guild.GetIconURL("png", "512"); + m_guild_icon_ev.signal_button_press_event().connect([this, guild_icon_url](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS) + if (event->button == GDK_BUTTON_PRIMARY) + LaunchBrowser(guild_icon_url); + + return false; + }); + } + + m_guild_icon.show(); + m_guild_icon_ev.show(); + + m_guild_icon_ev.add(m_guild_icon); + attach(m_guild_icon_ev, 0, 0, 1, 1); + attach(m_guild_name_label, 0, 1, 1, 1); + attach_next_to(m_guild_name, m_guild_name_label, Gtk::POS_RIGHT, 1, 1); +} + +void GuildSettingsInfoPane::FetchGuildIcon(const GuildData &guild) { + m_guild_icon.property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(32); + if (guild.HasIcon()) { + if (Abaddon::Get().GetSettings().GetShowAnimations() && guild.HasAnimatedIcon()) { + auto cb = [this](const Glib::RefPtr &pixbuf) { + m_guild_icon.property_pixbuf_animation() = pixbuf; + }; + Abaddon::Get().GetImageManager().LoadAnimationFromURL(guild.GetIconURL("gif", "64"), 64, 64, sigc::track_obj(cb, *this)); + } + + auto cb = [this](const Glib::RefPtr &pixbuf) { + m_guild_icon.property_pixbuf() = pixbuf->scale_simple(64, 64, Gdk::INTERP_BILINEAR); + }; + Abaddon::Get().GetImageManager().LoadFromURL(guild.GetIconURL("png", "64"), sigc::track_obj(cb, *this)); + } +} + +void GuildSettingsInfoPane::UpdateGuildName() { + auto &discord = Abaddon::Get().GetDiscordClient(); + if (discord.GetGuild(GuildID)->Name == m_guild_name.get_text()) return; + + auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + m_guild_name.set_text(Abaddon::Get().GetDiscordClient().GetGuild(GuildID)->Name); + Gtk::MessageDialog dlg("Failed to set guild name", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + discord.SetGuildName(GuildID, m_guild_name.get_text(), sigc::track_obj(cb, *this)); +} + +void GuildSettingsInfoPane::UpdateGuildIconFromData(const std::vector &data, const std::string &mime) { + auto encoded = "data:" + mime + ";base64," + Glib::Base64::encode(std::string(data.begin(), data.end())); + auto &discord = Abaddon::Get().GetDiscordClient(); + + auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to set guild icon", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + discord.SetGuildIcon(GuildID, encoded, sigc::track_obj(cb, *this)); +} + +void GuildSettingsInfoPane::UpdateGuildIconFromPixbuf(Glib::RefPtr pixbuf) { + int w = pixbuf->get_width(); + int h = pixbuf->get_height(); + if (w > 1024 || h > 1024) { + GetImageDimensions(w, h, w, h, 1024, 1024); + pixbuf = pixbuf->scale_simple(w, h, Gdk::INTERP_BILINEAR); + } + gchar *buffer; + gsize buffer_size; + pixbuf->save_to_buffer(buffer, buffer_size, "png"); + std::vector data(buffer_size); + std::memcpy(data.data(), buffer, buffer_size); + UpdateGuildIconFromData(data, "image/png"); +} + +void GuildSettingsInfoPane::UpdateGuildIconPicker() { + // this picker fucking sucks + Gtk::FileChooserDialog dlg("Choose new guild icon", Gtk::FILE_CHOOSER_ACTION_OPEN); + dlg.get_style_context()->remove_provider(Abaddon::Get().GetStyleProvider()); + dlg.set_modal(true); + dlg.signal_response().connect([this, &dlg](int response) { + if (response == Gtk::RESPONSE_OK) { + auto data = ReadWholeFile(dlg.get_filename()); + if (GetExtension(dlg.get_filename()) == ".gif") + UpdateGuildIconFromData(data, "image/gif"); + else + try { + auto loader = Gdk::PixbufLoader::create(); + loader->signal_size_prepared().connect([&loader](int inw, int inh) { + int w, h; + GetImageDimensions(inw, inh, w, h, 1024, 1024); + loader->set_size(w, h); + }); + loader->write(data.data(), data.size()); + loader->close(); + UpdateGuildIconFromPixbuf(loader->get_pixbuf()); + } catch (const std::exception &) {}; + } + }); + + dlg.add_button(Gtk::Stock::SAVE, Gtk::RESPONSE_OK); + dlg.add_button(Gtk::Stock::CANCEL, Gtk::RESPONSE_CANCEL); + + auto filter_images = Gtk::FileFilter::create(); + if (Abaddon::Get().GetDiscordClient().GetGuild(GuildID)->HasFeature("ANIMATED_ICON")) { + filter_images->set_name("Supported images (*.jpg, *.jpeg, *.png, *.gif)"); + filter_images->add_pattern("*.gif"); + } else { + filter_images->set_name("Supported images (*.jpg, *.jpeg, *.png)"); + } + filter_images->add_pattern("*.jpg"); + filter_images->add_pattern("*.jpeg"); + filter_images->add_pattern("*.png"); + dlg.add_filter(filter_images); + + auto filter_all = Gtk::FileFilter::create(); + filter_all->set_name("All files (*.*)"); + filter_all->add_pattern("*.*"); + dlg.add_filter(filter_all); + + dlg.run(); +} + +void GuildSettingsInfoPane::UpdateGuildIconClipboard() { + std::vector icon_data; + + auto cb = Gtk::Clipboard::get(); + // query for file path then for actual image + if (cb->wait_is_text_available()) { + auto path = cb->wait_for_text(); + if (!std::filesystem::exists(path.c_str())) return; + auto data = ReadWholeFile(path); + try { + auto loader = Gdk::PixbufLoader::create(); + loader->signal_size_prepared().connect([&loader](int inw, int inh) { + int w, h; + GetImageDimensions(inw, inh, w, h, 1024, 1024); + loader->set_size(w, h); + }); + loader->write(data.data(), data.size()); + loader->close(); + auto pb = loader->get_pixbuf(); + UpdateGuildIconFromPixbuf(pb); + + return; + } catch (const std::exception &) {}; + } + + if (cb->wait_is_image_available()) { + auto pb = cb->wait_for_image(); + UpdateGuildIconFromPixbuf(pb); + return; + } +} diff --git a/src/windows/guildsettings/infopane.hpp b/src/windows/guildsettings/infopane.hpp new file mode 100644 index 0000000..8a7e6a2 --- /dev/null +++ b/src/windows/guildsettings/infopane.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include "discord/guild.hpp" + +class GuildSettingsInfoPane : public Gtk::Grid { +public: + GuildSettingsInfoPane(Snowflake id); + +private: + void FetchGuildIcon(const GuildData &guild); + + void UpdateGuildName(); + void UpdateGuildIconFromData(const std::vector &data, const std::string &mime); + void UpdateGuildIconFromPixbuf(Glib::RefPtr pixbuf); + void UpdateGuildIconPicker(); + void UpdateGuildIconClipboard(); + + Gtk::Label m_guild_icon_label; + Gtk::EventBox m_guild_icon_ev; // necessary to make custom cursor behave properly + Gtk::Image m_guild_icon; + + Gtk::Label m_guild_name_label; + Gtk::Entry m_guild_name; + + Snowflake GuildID; +}; diff --git a/src/windows/guildsettings/invitespane.cpp b/src/windows/guildsettings/invitespane.cpp new file mode 100644 index 0000000..bec4784 --- /dev/null +++ b/src/windows/guildsettings/invitespane.cpp @@ -0,0 +1,136 @@ +#include "invitespane.hpp" +#include "abaddon.hpp" + +GuildSettingsInvitesPane::GuildSettingsInvitesPane(Snowflake id) + : GuildID(id) + , m_model(Gtk::ListStore::create(m_columns)) + , m_menu_delete("Delete") { + signal_map().connect(sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnMap)); + set_name("guild-invites-pane"); + set_hexpand(true); + set_vexpand(true); + + m_view.signal_button_press_event().connect(sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnTreeButtonPress), false); + + m_menu_delete.signal_activate().connect(sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnMenuDelete)); + m_menu.append(m_menu_delete); + m_menu.show_all(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + discord.signal_invite_create().connect(sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnInviteCreate)); + discord.signal_invite_delete().connect(sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnInviteDelete)); + + m_view.show(); + add(m_view); + + m_view.set_enable_search(false); + m_view.set_model(m_model); + m_view.append_column("Code", m_columns.m_col_code); + m_view.append_column("Expires", m_columns.m_col_expires); + m_view.append_column("Created by", m_columns.m_col_inviter); + m_view.append_column("Uses", m_columns.m_col_uses); + m_view.append_column("Max uses", m_columns.m_col_max_uses); + m_view.append_column("Grants temporary membership", m_columns.m_col_temporary); + + for (const auto column : m_view.get_columns()) + column->set_resizable(true); +} + +void GuildSettingsInvitesPane::OnMap() { + if (m_requested) return; + m_requested = true; + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + + if (discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_GUILD)) + discord.FetchGuildInvites(GuildID, sigc::mem_fun(*this, &GuildSettingsInvitesPane::OnInvitesFetch)); +} + +void GuildSettingsInvitesPane::AppendInvite(const InviteData &invite) { + auto row = *m_model->append(); + row[m_columns.m_col_code] = invite.Code; + if (invite.Inviter.has_value()) + row[m_columns.m_col_inviter] = invite.Inviter->Username + "#" + invite.Inviter->Discriminator; + + if (invite.MaxAge.has_value()) { + if (*invite.MaxAge == 0) + row[m_columns.m_col_expires] = "Never"; + else + row[m_columns.m_col_expires] = FormatISO8601(*invite.CreatedAt, *invite.MaxAge); + } + + row[m_columns.m_col_uses] = *invite.Uses; + if (*invite.MaxUses == 0) + row[m_columns.m_col_max_uses] = "Unlimited"; + else + row[m_columns.m_col_max_uses] = std::to_string(*invite.MaxUses); + + row[m_columns.m_col_temporary] = *invite.IsTemporary ? "Yes" : "No"; +} + +void GuildSettingsInvitesPane::OnInviteFetch(const std::optional &invite) { + if (!invite.has_value()) return; + AppendInvite(*invite); +} + +void GuildSettingsInvitesPane::OnInvitesFetch(const std::vector &invites) { + for (const auto &invite : invites) + AppendInvite(invite); +} + +void GuildSettingsInvitesPane::OnInviteCreate(const InviteData &invite) { + if (invite.Guild->ID == GuildID) + OnInviteFetch(std::make_optional(invite)); +} + +void GuildSettingsInvitesPane::OnInviteDelete(const InviteDeleteObject &data) { + if (*data.GuildID == GuildID) + for (auto &row : m_model->children()) + if (row[m_columns.m_col_code] == data.Code) + m_model->erase(row); +} + +void GuildSettingsInvitesPane::OnMenuDelete() { + auto selected_row = *m_view.get_selection()->get_selected(); + if (selected_row) { + auto code = static_cast(selected_row[m_columns.m_col_code]); + auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to delete invite", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().DeleteInvite(code, sigc::track_obj(cb, *this)); + } +} + +bool GuildSettingsInvitesPane::OnTreeButtonPress(GdkEventButton *event) { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_SECONDARY) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const auto can_manage = discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_GUILD); + m_menu_delete.set_sensitive(can_manage); + auto selection = m_view.get_selection(); + Gtk::TreeModel::Path path; + if (m_view.get_path_at_pos(event->x, event->y, path)) { + m_view.get_selection()->select(path); + m_menu.popup_at_pointer(reinterpret_cast(event)); + } + + return true; + } + + return false; +} + +GuildSettingsInvitesPane::ModelColumns::ModelColumns() { + add(m_col_code); + add(m_col_expires); + add(m_col_inviter); + add(m_col_temporary); + add(m_col_uses); + add(m_col_max_uses); +} diff --git a/src/windows/guildsettings/invitespane.hpp b/src/windows/guildsettings/invitespane.hpp new file mode 100644 index 0000000..5268d68 --- /dev/null +++ b/src/windows/guildsettings/invitespane.hpp @@ -0,0 +1,43 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class GuildSettingsInvitesPane : public Gtk::ScrolledWindow { +public: + GuildSettingsInvitesPane(Snowflake id); + +private: + void OnMap(); + + bool m_requested = false; + + void AppendInvite(const InviteData &invite); + void OnInviteFetch(const std::optional &invite); + void OnInvitesFetch(const std::vector &invites); + void OnInviteCreate(const InviteData &invite); + void OnInviteDelete(const InviteDeleteObject &data); + void OnMenuDelete(); + bool OnTreeButtonPress(GdkEventButton *event); + + Gtk::TreeView m_view; + + Snowflake GuildID; + + class ModelColumns : public Gtk::TreeModel::ColumnRecord { + public: + ModelColumns(); + + Gtk::TreeModelColumn m_col_code; + Gtk::TreeModelColumn m_col_expires; + Gtk::TreeModelColumn m_col_inviter; + Gtk::TreeModelColumn m_col_temporary; + Gtk::TreeModelColumn m_col_uses; + Gtk::TreeModelColumn m_col_max_uses; + }; + + ModelColumns m_columns; + Glib::RefPtr m_model; + + Gtk::Menu m_menu; + Gtk::MenuItem m_menu_delete; +}; diff --git a/src/windows/guildsettings/memberspane.cpp b/src/windows/guildsettings/memberspane.cpp new file mode 100644 index 0000000..36c5c0b --- /dev/null +++ b/src/windows/guildsettings/memberspane.cpp @@ -0,0 +1,410 @@ +#include "memberspane.hpp" +#include "abaddon.hpp" + +GuildSettingsMembersPane::GuildSettingsMembersPane(Snowflake id) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , GuildID(id) + , m_layout(Gtk::ORIENTATION_HORIZONTAL) + , m_member_list(id) + , m_member_info(id) { + set_name("guild-members-pane"); + set_hexpand(true); + set_vexpand(true); + + m_member_list.signal_member_select().connect(sigc::mem_fun(m_member_info, &GuildSettingsMembersPaneInfo::SetUser)); + + m_note.set_label("Some members may not be shown if the client is not aware of them"); + m_note.set_single_line_mode(true); + m_note.set_ellipsize(Pango::ELLIPSIZE_END); + + m_layout.set_homogeneous(true); + m_layout.add(m_member_list); + m_layout.add(m_member_info); + add(m_note); + add(m_layout); + + m_member_list.show(); + m_member_info.show(); + m_note.show(); + m_layout.show(); +} + +GuildSettingsMembersPaneMembers::GuildSettingsMembersPaneMembers(Snowflake id) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , GuildID(id) { + m_list_scroll.get_style_context()->add_class("guild-members-pane-list"); + + m_list_scroll.set_hexpand(true); + m_list_scroll.set_vexpand(true); + m_list_scroll.set_propagate_natural_height(true); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto members = discord.GetUsersInGuild(id); + const auto guild = *discord.GetGuild(GuildID); + for (const auto member_id : members) { + auto member = discord.GetMember(member_id, GuildID); + if (!member.has_value()) continue; // fixme this should not be necessary + member->User = discord.GetUser(member_id); + if (member->User->IsDeleted()) continue; + auto *row = Gtk::manage(new GuildSettingsMembersListItem(guild, *member)); + row->show(); + m_list.add(*row); + } + + m_list.set_selection_mode(Gtk::SELECTION_SINGLE); + m_list.signal_row_selected().connect([this](Gtk::ListBoxRow *selected_) { + if (auto *selected = dynamic_cast(selected_)) + m_signal_member_select.emit(selected->UserID); + }); + + m_search.set_placeholder_text("Filter"); + m_search.signal_changed().connect([this] { + m_list.invalidate_filter(); + }); + + m_list.set_filter_func([this](Gtk::ListBoxRow *row_) -> bool { + const auto search_term = m_search.get_text(); + if (search_term.size() == 0) return true; + if (auto *row = dynamic_cast(row_)) + return StringContainsCaseless(row->DisplayTerm, m_search.get_text()); + return true; + }); + + m_list_scroll.add(m_list); + add(m_search); + add(m_list_scroll); + + m_search.show(); + m_list.show(); + m_list_scroll.show(); +} + +GuildSettingsMembersPaneMembers::type_signal_member_select GuildSettingsMembersPaneMembers::signal_member_select() { + return m_signal_member_select; +} + +GuildSettingsMembersListItem::GuildSettingsMembersListItem(const GuildData &guild, const GuildMember &member) + : UserID(member.User->ID) + , GuildID(guild.ID) + , m_avatar(32, 32) { + m_avatar.SetAnimated(true); + + m_ev.signal_button_press_event().connect([this](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_SECONDARY) { + Abaddon::Get().ShowUserMenu(reinterpret_cast(event), UserID, GuildID); + return true; + } + return false; + }); + + auto &discord = Abaddon::Get().GetDiscordClient(); + + if (member.User->HasAnimatedAvatar() && Abaddon::Get().GetSettings().GetShowAnimations()) + m_avatar.SetURL(member.User->GetAvatarURL("gif", "32")); + else + m_avatar.SetURL(member.User->GetAvatarURL("png", "32")); + + DisplayTerm = member.User->Username + "#" + member.User->Discriminator; + + const auto member_update_cb = [this](Snowflake guild_id, Snowflake user_id) { + if (user_id == UserID) + UpdateColor(); + }; + discord.signal_guild_member_update().connect(sigc::track_obj(member_update_cb, *this)); + UpdateColor(); + + static bool crown = Abaddon::Get().GetSettings().GetShowOwnerCrown(); + if (crown && guild.OwnerID == member.User->ID) { + try { + const static auto crown_path = Abaddon::GetResPath("/crown.png"); + auto pixbuf = Gdk::Pixbuf::create_from_file(crown_path, 12, 12); + m_crown = Gtk::manage(new Gtk::Image(pixbuf)); + m_crown->set_valign(Gtk::ALIGN_CENTER); + m_crown->set_margin_start(10); + m_crown->show(); + } catch (...) {} + } + + m_avatar.set_margin_end(5); + m_avatar.set_halign(Gtk::ALIGN_START); + m_avatar.set_valign(Gtk::ALIGN_CENTER); + m_name.set_halign(Gtk::ALIGN_START); + m_name.set_valign(Gtk::ALIGN_CENTER); + + m_main.set_hexpand(true); + + m_main.add(m_avatar); + m_main.add(m_name); + if (m_crown != nullptr) + m_main.add(*m_crown); + + m_ev.add(m_main); + add(m_ev); + + m_avatar.show(); + m_name.show(); + m_main.show(); + m_ev.show(); +} + +void GuildSettingsMembersListItem::UpdateColor() { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = *discord.GetUser(UserID); + if (auto color_id = discord.GetMemberHoistedRole(GuildID, UserID, true); color_id.IsValid()) { + auto role = *discord.GetRole(color_id); + m_name.set_markup("" + user.GetEscapedBoldString() + ""); + } else + m_name.set_markup(user.GetEscapedBoldString()); +} + +GuildSettingsMembersPaneInfo::GuildSettingsMembersPaneInfo(Snowflake guild_id) + : GuildID(guild_id) + , m_roles(guild_id) + , m_box(Gtk::ORIENTATION_VERTICAL) { + get_style_context()->add_class("guild-members-pane-info"); + + const auto label = [](Gtk::Label &lbl) { + lbl.set_single_line_mode(true); + lbl.set_halign(Gtk::ALIGN_START); + lbl.set_valign(Gtk::ALIGN_START); + lbl.set_ellipsize(Pango::ELLIPSIZE_END); + lbl.set_margin_bottom(5); + lbl.show(); + }; + + m_bot.set_text("User is a bot"); + + label(m_bot); + label(m_id); + label(m_created); + label(m_joined); + label(m_nickname); + label(m_boosting); + + m_box.set_halign(Gtk::ALIGN_FILL); + m_box.set_valign(Gtk::ALIGN_START); + m_box.set_hexpand(true); + m_box.set_vexpand(true); + m_box.add(m_bot); + m_box.add(m_id); + m_box.add(m_created); + m_box.add(m_joined); + m_box.add(m_nickname); + m_box.add(m_boosting); + m_box.add(m_roles); + + m_bot.hide(); + m_box.show(); + + add(m_box); +} + +void GuildSettingsMembersPaneInfo::SetUser(Snowflake user_id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto guild = *discord.GetGuild(GuildID); + auto member = *discord.GetMember(user_id, GuildID); + member.User = discord.GetUser(user_id); + + m_bot.set_visible(member.User->IsBot.has_value() && *member.User->IsBot); + + m_id.set_text("User ID: " + std::to_string(user_id)); + m_created.set_text("Account created: " + user_id.GetLocalTimestamp()); + if (member.JoinedAt != "") + m_joined.set_text("Joined server: " + FormatISO8601(member.JoinedAt)); + else + m_joined.set_text("Joined server: Unknown"); + m_nickname.set_text("Nickname: " + member.Nickname); + m_nickname.set_visible(member.Nickname != ""); + if (member.PremiumSince.has_value()) { + m_boosting.set_text("Boosting since " + FormatISO8601(*member.PremiumSince)); + m_boosting.show(); + } else + m_boosting.hide(); + + m_roles.show(); + m_roles.SetRoles(user_id, member.Roles, guild.OwnerID == discord.GetUserData().ID); +} + +GuildSettingsMembersPaneRoles::GuildSettingsMembersPaneRoles(Snowflake guild_id) + : GuildID(guild_id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const bool can_modify = discord.HasGuildPermission(self_id, guild_id, Permission::MANAGE_ROLES); + const auto highest = discord.GetMemberHighestRole(GuildID, self_id); + if (highest.has_value()) + m_hoisted_position = highest->Position; + + discord.signal_role_create().connect(sigc::mem_fun(*this, &GuildSettingsMembersPaneRoles::OnRoleCreate)); + discord.signal_role_update().connect(sigc::mem_fun(*this, &GuildSettingsMembersPaneRoles::OnRoleUpdate)); + discord.signal_role_delete().connect(sigc::mem_fun(*this, &GuildSettingsMembersPaneRoles::OnRoleDelete)); + + const auto guild = *discord.GetGuild(guild_id); + const auto roles = guild.FetchRoles(); + for (const auto &role : roles) { + CreateRow(can_modify, role, guild.OwnerID == self_id); + } + + m_list.set_sort_func([this](Gtk::ListBoxRow *a, Gtk::ListBoxRow *b) -> int { + auto *rowa = dynamic_cast(a); + auto *rowb = dynamic_cast(b); + return rowb->Position - rowa->Position; + }); + + set_propagate_natural_height(true); + set_propagate_natural_width(true); + set_hexpand(true); + set_vexpand(true); + set_halign(Gtk::ALIGN_FILL); + set_valign(Gtk::ALIGN_START); + + m_list.show(); + + add(m_list); +} + +void GuildSettingsMembersPaneRoles::SetRoles(Snowflake user_id, const std::vector &roles, bool is_owner) { + UserID = user_id; + + for (auto it = m_update_connection.begin(); it != m_update_connection.end();) { + it->disconnect(); + it = m_update_connection.erase(it); + } + + m_set_role_ids = { roles.begin(), roles.end() }; + for (const auto &[role_id, row] : m_rows) { + auto role = *Abaddon::Get().GetDiscordClient().GetRole(role_id); + if (role.ID == GuildID) { + row->SetChecked(true); + row->SetToggleable(false); + } else { + row->SetToggleable(role.Position < m_hoisted_position || is_owner); + row->SetChecked(m_set_role_ids.find(role_id) != m_set_role_ids.end()); + } + } +} + +void GuildSettingsMembersPaneRoles::CreateRow(bool has_manage_roles, const RoleData &role, bool is_owner) { + auto *row = Gtk::manage(new GuildSettingsMembersPaneRolesItem(has_manage_roles, role)); + if (role.ID == GuildID) { + row->SetChecked(true); + row->SetToggleable(false); + } else + row->SetToggleable(role.Position < m_hoisted_position || is_owner); + row->signal_role_click().connect(sigc::mem_fun(*this, &GuildSettingsMembersPaneRoles::OnRoleToggle)); + row->show(); + m_rows[role.ID] = row; + m_list.add(*row); +} + +void GuildSettingsMembersPaneRoles::OnRoleToggle(Snowflake role_id, bool new_set) { + auto row = m_rows.at(role_id); + row->SetToggleable(false); + auto &discord = Abaddon::Get().GetDiscordClient(); + auto cb = [this, new_set, role_id, row](bool success) { + if (!success) { // undo + if (new_set) + m_set_role_ids.erase(role_id); + else + m_set_role_ids.insert(role_id); + } else + row->SetChecked(new_set); + + row->SetToggleable(true); + }; + + if (new_set) + m_set_role_ids.insert(role_id); + else + m_set_role_ids.erase(role_id); + + // hack to prevent cb from being called if SetRoles is called before callback completion + sigc::signal tmp; + m_update_connection.push_back(tmp.connect(std::move(cb))); + const auto tmp_cb = [this, tmp = std::move(tmp)](DiscordError code) { tmp.emit(code == DiscordError::NONE); }; + discord.SetMemberRoles(GuildID, UserID, m_set_role_ids.begin(), m_set_role_ids.end(), sigc::track_obj(tmp_cb, *this)); +} + +void GuildSettingsMembersPaneRoles::OnRoleCreate(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID) return; + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto self_id = discord.GetUserData().ID; + const bool can_modify = discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_ROLES); + const auto role = *discord.GetRole(role_id); + CreateRow(can_modify, role, discord.GetGuild(guild_id)->OwnerID == self_id); +} + +void GuildSettingsMembersPaneRoles::OnRoleUpdate(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID) return; + auto role = *Abaddon::Get().GetDiscordClient().GetRole(role_id); + m_rows.at(role_id)->UpdateRoleData(role); + m_list.invalidate_sort(); +} + +void GuildSettingsMembersPaneRoles::OnRoleDelete(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID) return; + delete m_rows.at(role_id); +} + +GuildSettingsMembersPaneRolesItem::GuildSettingsMembersPaneRolesItem(bool sensitive, const RoleData &role) + : RoleID(role.ID) { + UpdateRoleData(role); + + m_main.set_hexpand(true); + m_main.set_vexpand(true); + + const auto cb = [this](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + m_signal_role_click.emit(RoleID, !m_check.get_active()); + return true; + } + return false; + }; + m_check.signal_button_press_event().connect(cb, false); + + m_desired_sensitivity = sensitive; + ComputeSensitivity(); + + m_check.set_margin_start(5); + m_label.set_margin_start(5); + + m_main.add(m_check); + m_main.add(m_label); + add(m_main); + m_check.show(); + m_label.show(); + m_main.show(); +} + +void GuildSettingsMembersPaneRolesItem::SetChecked(bool checked) { + m_check.set_active(checked); +} + +void GuildSettingsMembersPaneRolesItem::SetToggleable(bool toggleable) { + m_desired_sensitivity = toggleable; + ComputeSensitivity(); +} + +void GuildSettingsMembersPaneRolesItem::UpdateRoleData(const RoleData &role) { + m_role = role; + Position = role.Position; + UpdateLabel(); +} + +void GuildSettingsMembersPaneRolesItem::UpdateLabel() { + if (m_role.Color) + m_label.set_markup("" + Glib::Markup::escape_text(m_role.Name) + ""); + else + m_label.set_text(m_role.Name); +} + +void GuildSettingsMembersPaneRolesItem::ComputeSensitivity() { + if (m_role.IsManaged) { + m_check.set_sensitive(false); + return; + } + m_check.set_sensitive(m_desired_sensitivity); +} + +GuildSettingsMembersPaneRolesItem::type_signal_role_click GuildSettingsMembersPaneRolesItem::signal_role_click() { + return m_signal_role_click; +} diff --git a/src/windows/guildsettings/memberspane.hpp b/src/windows/guildsettings/memberspane.hpp new file mode 100644 index 0000000..01398da --- /dev/null +++ b/src/windows/guildsettings/memberspane.hpp @@ -0,0 +1,135 @@ +#pragma once +#include +#include +#include "discord/member.hpp" +#include "discord/guild.hpp" +#include "components/lazyimage.hpp" + +class GuildSettingsMembersPaneRolesItem : public Gtk::ListBoxRow { +public: + GuildSettingsMembersPaneRolesItem(bool sensitive, const RoleData &role); + void SetChecked(bool checked); + void SetToggleable(bool toggleable); + void UpdateRoleData(const RoleData &role); + + Snowflake RoleID; + int Position; + +private: + void UpdateLabel(); + void ComputeSensitivity(); + bool m_desired_sensitivity = true; + + RoleData m_role; + + Gtk::Box m_main; + Gtk::CheckButton m_check; + Gtk::Label m_label; + + // own thing so we can stop it from actually changing + typedef sigc::signal type_signal_role_click; + + type_signal_role_click m_signal_role_click; + +public: + type_signal_role_click signal_role_click(); +}; + +class GuildSettingsMembersPaneRoles : public Gtk::ScrolledWindow { +public: + GuildSettingsMembersPaneRoles(Snowflake guild_id); + + void SetRoles(Snowflake user_id, const std::vector &roles, bool is_owner); + +private: + void CreateRow(bool has_manage_roles, const RoleData &role, bool is_owner); + + void OnRoleToggle(Snowflake role_id, bool new_set); + + void OnRoleCreate(Snowflake guild_id, Snowflake role_id); + void OnRoleUpdate(Snowflake guild_id, Snowflake role_id); + void OnRoleDelete(Snowflake guild_id, Snowflake role_id); + + int m_hoisted_position = 0; + + std::vector m_update_connection; + + std::unordered_set m_set_role_ids; + + Snowflake GuildID; + Snowflake UserID; + + Gtk::ListBox m_list; + + std::unordered_map m_rows; +}; + +class GuildSettingsMembersPaneInfo : public Gtk::ScrolledWindow { +public: + GuildSettingsMembersPaneInfo(Snowflake guild_id); + + void SetUser(Snowflake user_id); + +private: + Snowflake GuildID; + Snowflake UserID; + + Gtk::Label m_bot; + Gtk::Label m_id; + Gtk::Label m_created; + Gtk::Label m_joined; + Gtk::Label m_nickname; + Gtk::Label m_boosting; + GuildSettingsMembersPaneRoles m_roles; + Gtk::Box m_box; +}; + +class GuildSettingsMembersPaneMembers : public Gtk::Box { +public: + GuildSettingsMembersPaneMembers(Snowflake id); + +private: + Snowflake GuildID; + + Gtk::Entry m_search; + Gtk::ScrolledWindow m_list_scroll; + Gtk::ListBox m_list; + + typedef sigc::signal type_signal_member_select; + type_signal_member_select m_signal_member_select; + +public: + type_signal_member_select signal_member_select(); +}; + +class GuildSettingsMembersListItem : public Gtk::ListBoxRow { +public: + GuildSettingsMembersListItem(const GuildData &guild, const GuildMember &member); + + Glib::ustring DisplayTerm; + + Snowflake UserID; + Snowflake GuildID; + +private: + void UpdateColor(); + + Gtk::EventBox m_ev; + LazyImage m_avatar; + Gtk::Label m_name; + Gtk::Box m_main; + Gtk::Image *m_crown = nullptr; +}; + +class GuildSettingsMembersPane : public Gtk::Box { +public: + GuildSettingsMembersPane(Snowflake id); + +private: + Snowflake GuildID; + + Gtk::Box m_layout; + Gtk::Label m_note; + GuildSettingsMembersPaneMembers m_member_list; + GuildSettingsMembersPaneInfo m_member_info; +}; diff --git a/src/windows/guildsettings/rolespane.cpp b/src/windows/guildsettings/rolespane.cpp new file mode 100644 index 0000000..8d355ee --- /dev/null +++ b/src/windows/guildsettings/rolespane.cpp @@ -0,0 +1,419 @@ +#include "rolespane.hpp" +#include "abaddon.hpp" + +GuildSettingsRolesPane::GuildSettingsRolesPane(Snowflake id) + : Gtk::Box(Gtk::ORIENTATION_HORIZONTAL) + , GuildID(id) + , m_roles_list(id) + , m_roles_perms(id) { + set_name("guild-roles-pane"); + set_hexpand(true); + set_vexpand(true); + + m_roles_list.signal_role_select().connect(sigc::mem_fun(*this, &GuildSettingsRolesPane::OnRoleSelect)); + + m_roles_perms.set_sensitive(false); + + m_layout.set_homogeneous(true); + m_layout.add(m_roles_list); + m_layout.add(m_roles_perms); + add(m_layout); + + m_roles_list.show(); + m_roles_perms.show(); + m_layout.show(); +} + +void GuildSettingsRolesPane::OnRoleSelect(Snowflake role_id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto role = *discord.GetRole(role_id); + m_roles_perms.SetRole(role); + m_roles_perms.set_sensitive(discord.CanModifyRole(GuildID, role_id)); +} + +static std::vector g_target_entries = { + Gtk::TargetEntry("GTK_LIST_ROLES_ROW", Gtk::TARGET_SAME_APP, 0) +}; + +GuildSettingsRolesPaneRoles::GuildSettingsRolesPaneRoles(Snowflake guild_id) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , GuildID(guild_id) { + m_list.get_style_context()->add_class("guild-roles-pane-list"); + + m_list_scroll.set_hexpand(true); + m_list_scroll.set_vexpand(true); + m_list_scroll.set_propagate_natural_height(true); + m_list_scroll.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + + m_list.set_selection_mode(Gtk::SELECTION_SINGLE); + m_list.signal_row_selected().connect([this](Gtk::ListBoxRow *selected_) { + if (auto *selected = dynamic_cast(selected_)) + m_signal_role_select.emit(selected->RoleID); + }); + + m_list.set_focus_vadjustment(m_list_scroll.get_vadjustment()); + m_list.signal_on_drop().connect([this](Gtk::ListBoxRow *row_, int new_index) -> bool { + if (auto *row = dynamic_cast(row_)) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto num_rows = m_list.get_children().size(); + const auto new_pos = num_rows - new_index - 1; + if (row->RoleID == GuildID) return true; // moving role @everyone + if (static_cast(new_index) == num_rows) return true; // trying to move row below @everyone + // make sure it wont modify a neighbor role u dont have perms to modify + if (!discord.CanModifyRole(GuildID, row->RoleID)) return false; + const auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to set role position", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER_ON_PARENT); + dlg.run(); + } + }; + discord.ModifyRolePosition(GuildID, row->RoleID, new_pos, sigc::track_obj(cb, *this)); + return true; + } + return false; + }); + + auto &discord = Abaddon::Get().GetDiscordClient(); + discord.signal_role_create().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneRoles::OnRoleCreate)); + discord.signal_role_delete().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneRoles::OnRoleDelete)); + + const auto guild = *discord.GetGuild(GuildID); + const auto roles = guild.FetchRoles(); + const bool can_modify = discord.HasGuildPermission(discord.GetUserData().ID, GuildID, Permission::MANAGE_ROLES); + for (const auto &role : roles) { + auto *row = Gtk::manage(new GuildSettingsRolesPaneRolesListItem(guild, role)); + row->drag_source_set(g_target_entries, Gdk::BUTTON1_MASK, Gdk::ACTION_MOVE); + row->set_margin_start(5); + row->set_halign(Gtk::ALIGN_FILL); + row->show(); + m_rows[role.ID] = row; + if (can_modify) + m_list.add_draggable(row); + else + m_list.add(*row); + } + + m_list.set_sort_func([this](Gtk::ListBoxRow *rowa_, Gtk::ListBoxRow *rowb_) -> int { + auto *rowa = dynamic_cast(rowa_); + auto *rowb = dynamic_cast(rowb_); + return rowb->Position - rowa->Position; + }); + m_list.invalidate_sort(); + + m_list.set_filter_func([this](Gtk::ListBoxRow *row_) -> bool { + const auto search_term = m_search.get_text(); + if (search_term.size() == 0) return true; + if (auto *row = dynamic_cast(row_)) + return StringContainsCaseless(row->DisplayTerm, m_search.get_text()); + return true; + }); + + m_search.set_placeholder_text("Filter"); + m_search.signal_changed().connect([this] { + m_list.invalidate_filter(); + }); + + m_list_scroll.add(m_list); + add(m_search); + add(m_list_scroll); + + m_search.show(); + m_list.show(); + m_list_scroll.show(); +} + +void GuildSettingsRolesPaneRoles::OnRoleCreate(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID) return; + auto &discord = Abaddon::Get().GetDiscordClient(); + const bool can_modify = discord.HasGuildPermission(discord.GetUserData().ID, guild_id, Permission::MANAGE_ROLES); + const auto guild = *discord.GetGuild(guild_id); + const auto role = *discord.GetRole(role_id); + auto *row = Gtk::manage(new GuildSettingsRolesPaneRolesListItem(guild, role)); + row->show(); + m_rows[role_id] = row; + if (can_modify) + m_list.add_draggable(row); + else + m_list.add(*row); +} + +void GuildSettingsRolesPaneRoles::OnRoleDelete(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID) return; + auto it = m_rows.find(role_id); + delete it->second; + m_rows.erase(it); +} + +GuildSettingsRolesPaneRoles::type_signal_role_select GuildSettingsRolesPaneRoles::signal_role_select() { + return m_signal_role_select; +} + +GuildSettingsRolesPaneRolesListItem::GuildSettingsRolesPaneRolesListItem(const GuildData &guild, const RoleData &role) + : GuildID(guild.ID) + , RoleID(role.ID) + , Position(role.Position) { + auto &discord = Abaddon::Get().GetDiscordClient(); + + set_hexpand(true); + + UpdateItem(role); + + discord.signal_role_update().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneRolesListItem::OnRoleUpdate)); + + m_name.set_ellipsize(Pango::ELLIPSIZE_END); + + m_ev.set_halign(Gtk::ALIGN_START); + m_ev.add(m_name); + add(m_ev); + + m_name.show(); + m_ev.show(); +} + +void GuildSettingsRolesPaneRolesListItem::UpdateItem(const RoleData &role) { + DisplayTerm = role.Name; + + if (role.Color != 0) + m_name.set_markup("" + + Glib::Markup::escape_text(role.Name) + + ""); + else + m_name.set_text(role.Name); +} + +void GuildSettingsRolesPaneRolesListItem::OnRoleUpdate(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID || role_id != RoleID) return; + const auto role = Abaddon::Get().GetDiscordClient().GetRole(RoleID); + if (!role.has_value()) return; + Position = role->Position; + UpdateItem(*role); + changed(); +} + +GuildSettingsRolesPaneInfo::GuildSettingsRolesPaneInfo(Snowflake guild_id) + : GuildID(guild_id) + , m_layout(Gtk::ORIENTATION_VERTICAL) + , m_meta(Gtk::ORIENTATION_HORIZONTAL) { + set_propagate_natural_height(true); + set_propagate_natural_width(true); + + auto &discord = Abaddon::Get().GetDiscordClient(); + discord.signal_role_update().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneInfo::OnRoleUpdate)); + + const auto cb = [this](GdkEventKey *e) -> bool { + if (e->keyval == GDK_KEY_Return) + UpdateRoleName(); + return false; + }; + m_role_name.signal_key_press_event().connect(cb, false); + + m_role_name.set_tooltip_text("Press enter to submit"); + + m_role_name.set_max_length(100); + + m_role_name.set_margin_top(5); + m_role_name.set_margin_bottom(5); + m_role_name.set_margin_start(5); + m_role_name.set_margin_end(5); + + m_color_button.set_margin_top(5); + m_color_button.set_margin_bottom(5); + m_color_button.set_margin_start(5); + m_color_button.set_margin_end(5); + + m_color_button.signal_color_set().connect([this, &discord]() { + const auto color = m_color_button.get_rgba(); + const auto cb = [this, &discord](DiscordError code) { + if (code != DiscordError::NONE) { + m_color_button.set_rgba(IntToRGBA(discord.GetRole(RoleID)->Color)); + Gtk::MessageDialog dlg("Failed to set role color", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER_ON_PARENT); + dlg.run(); + } + }; + discord.ModifyRoleColor(GuildID, RoleID, color, cb); + }); + + int left_ypos = 0; + int right_ypos = 0; + + const int LEFT = 0; + const int RIGHT = 1; + + auto add_perms = [&](const std::string &label, int side, const std::initializer_list &perms) { + int &pos = side == LEFT ? left_ypos : right_ypos; + auto *header = Gtk::manage(new Gtk::Label(label)); + header->show(); + m_grid.attach(*header, side, pos++, 1, 1); + for (const auto perm : perms) { + auto *btn = Gtk::manage(new GuildSettingsRolesPanePermItem(perm)); + btn->signal_permission_click().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneInfo::OnPermissionToggle)); + m_perm_items[perm] = btn; + btn->show(); + m_grid.attach(*btn, side, pos++, 1, 1); + } + pos++; + }; + + // fuck you clang-format you suck + // clang-format off + add_perms("General", LEFT, { + Permission::VIEW_CHANNEL, + Permission::MANAGE_CHANNELS, + Permission::MANAGE_ROLES, + Permission::MANAGE_EMOJIS, + Permission::VIEW_AUDIT_LOG, + Permission::VIEW_GUILD_INSIGHTS, + Permission::MANAGE_WEBHOOKS, + Permission::MANAGE_GUILD }); + + add_perms("Membership", LEFT, { + Permission::CREATE_INSTANT_INVITE, + Permission::CHANGE_NICKNAME, + Permission::MANAGE_NICKNAMES, + Permission::KICK_MEMBERS, + Permission::BAN_MEMBERS }); + + add_perms("Text Channels", RIGHT, { + Permission::SEND_MESSAGES, + Permission::USE_PUBLIC_THREADS, + Permission::USE_PRIVATE_THREADS, + Permission::EMBED_LINKS, + Permission::ATTACH_FILES, + Permission::ADD_REACTIONS, + Permission::USE_EXTERNAL_EMOJIS, + Permission::MENTION_EVERYONE, + Permission::MANAGE_MESSAGES, + Permission::MANAGE_THREADS, + Permission::READ_MESSAGE_HISTORY, + Permission::SEND_TTS_MESSAGES, + Permission::USE_SLASH_COMMANDS }); + + add_perms("Voice Channels", RIGHT, { + Permission::CONNECT, + Permission::SPEAK, + Permission::STREAM, + Permission::USE_VAD, + Permission::PRIORITY_SPEAKER, + Permission::MUTE_MEMBERS, + Permission::DEAFEN_MEMBERS, + Permission::MOVE_MEMBERS }); + + add_perms("Advanced", LEFT, { Permission::ADMINISTRATOR }); + + // clang-format on + + m_meta.add(m_role_name); + m_meta.add(m_color_button); + m_layout.add(m_meta); + m_layout.add(m_grid); + add(m_layout); + m_meta.show(); + m_color_button.show(); + m_role_name.show(); + m_layout.show(); + m_grid.show(); +} + +void GuildSettingsRolesPaneInfo::SetRole(const RoleData &role) { + for (auto it = m_update_connections.begin(); it != m_update_connections.end();) { + it->disconnect(); + it = m_update_connections.erase(it); + } + + if (role.Color != 0) { + m_color_button.set_rgba(IntToRGBA(role.Color)); + } else { + static Gdk::RGBA trans; + trans.set_alpha(0.0); + m_color_button.set_rgba(trans); + } + + m_role_name.set_text(role.Name); + + RoleID = role.ID; + m_perms = role.Permissions; + for (const auto [perm, btn] : m_perm_items) { + btn->set_sensitive(true); + btn->set_active((role.Permissions & perm) == perm); + } +} + +void GuildSettingsRolesPaneInfo::OnRoleUpdate(Snowflake guild_id, Snowflake role_id) { + if (guild_id != GuildID || role_id != RoleID) return; + const auto role = *Abaddon::Get().GetDiscordClient().GetRole(RoleID); + m_role_name.set_text(role.Name); + + if (role.Color != 0) { + m_color_button.set_rgba(IntToRGBA(role.Color)); + } else { + static Gdk::RGBA trans; + trans.set_alpha(0.0); + m_color_button.set_rgba(trans); + } + + m_perms = role.Permissions; + for (const auto [perm, btn] : m_perm_items) + btn->set_active((role.Permissions & perm) == perm); +} + +void GuildSettingsRolesPaneInfo::OnPermissionToggle(Permission perm, bool new_set) { + auto btn = m_perm_items.at(perm); + btn->set_sensitive(false); + auto &discord = Abaddon::Get().GetDiscordClient(); + auto cb = [this, new_set, perm, btn](bool success) { + if (!success) { // undo + if (new_set) + m_perms &= ~perm; + else + m_perms |= perm; + } else + btn->set_active(new_set); + btn->set_sensitive(true); + }; + + if (new_set) + m_perms |= perm; + else + m_perms &= ~perm; + + sigc::signal tmp; + m_update_connections.push_back(tmp.connect(std::move(cb))); + const auto tmp_cb = [this, tmp = std::move(tmp)](DiscordError code) { tmp.emit(code == DiscordError::NONE); }; + discord.ModifyRolePermissions(GuildID, RoleID, m_perms, sigc::track_obj(tmp_cb, *this)); +} + +void GuildSettingsRolesPaneInfo::UpdateRoleName() { + auto &discord = Abaddon::Get().GetDiscordClient(); + if (discord.GetRole(RoleID)->Name == m_role_name.get_text()) return; + + const auto cb = [this, &discord](DiscordError code) { + if (code != DiscordError::NONE) { + m_role_name.set_text(discord.GetRole(RoleID)->Name); + Gtk::MessageDialog dlg("Failed to set role name", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER_ON_PARENT); + dlg.run(); + } + }; + discord.ModifyRoleName(GuildID, RoleID, m_role_name.get_text(), cb); +} + +GuildSettingsRolesPanePermItem::GuildSettingsRolesPanePermItem(Permission perm) + : Gtk::CheckButton(GetPermissionString(perm)) + , m_permission(perm) { + set_tooltip_text(GetPermissionDescription(m_permission)); + + const auto cb = [this](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + m_signal_permission.emit(m_permission, !get_active()); + return true; + } + return false; + }; + signal_button_press_event().connect(cb, false); +} + +GuildSettingsRolesPanePermItem::type_signal_permission_click GuildSettingsRolesPanePermItem::signal_permission_click() { + return m_signal_permission; +} diff --git a/src/windows/guildsettings/rolespane.hpp b/src/windows/guildsettings/rolespane.hpp new file mode 100644 index 0000000..2999f32 --- /dev/null +++ b/src/windows/guildsettings/rolespane.hpp @@ -0,0 +1,102 @@ +#pragma once +#include +#include +#include "discord/guild.hpp" +#include "components/draglistbox.hpp" + +class GuildSettingsRolesPaneRolesListItem : public Gtk::ListBoxRow { +public: + GuildSettingsRolesPaneRolesListItem(const GuildData &guild, const RoleData &role); + + Glib::ustring DisplayTerm; + + Snowflake GuildID; + Snowflake RoleID; + int Position; + +private: + void UpdateItem(const RoleData &role); + void OnRoleUpdate(Snowflake guild_id, Snowflake role_id); + + Gtk::EventBox m_ev; + Gtk::Label m_name; +}; + +class GuildSettingsRolesPaneRoles : public Gtk::Box { +public: + GuildSettingsRolesPaneRoles(Snowflake guild_id); + +private: + void OnRoleCreate(Snowflake guild_id, Snowflake role_id); + void OnRoleDelete(Snowflake guild_id, Snowflake role_id); + + Snowflake GuildID; + + Gtk::Entry m_search; + Gtk::ScrolledWindow m_list_scroll; + DragListBox m_list; + + typedef sigc::signal type_signal_role_select; + type_signal_role_select m_signal_role_select; + +public: + std::unordered_map m_rows; + type_signal_role_select signal_role_select(); +}; + +class GuildSettingsRolesPanePermItem : public Gtk::CheckButton { +public: + GuildSettingsRolesPanePermItem(Permission perm); + +private: + Permission m_permission; + + typedef sigc::signal type_signal_permission_click; + + type_signal_permission_click m_signal_permission; + +public: + type_signal_permission_click signal_permission_click(); +}; + +class GuildSettingsRolesPaneInfo : public Gtk::ScrolledWindow { +public: + GuildSettingsRolesPaneInfo(Snowflake guild_id); + + void SetRole(const RoleData &role); + +private: + void OnRoleUpdate(Snowflake guild_id, Snowflake role_id); + void OnPermissionToggle(Permission perm, bool new_set); + + void UpdateRoleName(); + + Snowflake GuildID; + Snowflake RoleID; + + Permission m_perms; + + std::vector m_update_connections; + + Gtk::Box m_layout; + Gtk::Box m_meta; + Gtk::Entry m_role_name; + Gtk::ColorButton m_color_button; + Gtk::Grid m_grid; + + std::unordered_map m_perm_items; +}; + +class GuildSettingsRolesPane : public Gtk::Box { +public: + GuildSettingsRolesPane(Snowflake id); + +private: + void OnRoleSelect(Snowflake role_id); + + Snowflake GuildID; + + Gtk::Box m_layout; + GuildSettingsRolesPaneRoles m_roles_list; + GuildSettingsRolesPaneInfo m_roles_perms; +}; diff --git a/src/windows/guildsettingswindow.cpp b/src/windows/guildsettingswindow.cpp new file mode 100644 index 0000000..1e3395d --- /dev/null +++ b/src/windows/guildsettingswindow.cpp @@ -0,0 +1,76 @@ +#include "guildsettingswindow.hpp" +#include "abaddon.hpp" + +GuildSettingsWindow::GuildSettingsWindow(Snowflake id) + : m_main(Gtk::ORIENTATION_VERTICAL) + , m_pane_info(id) + , m_pane_members(id) + , m_pane_roles(id) + , m_pane_bans(id) + , m_pane_invites(id) + , m_pane_emojis(id) + , m_pane_audit_log(id) + , GuildID(id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto guild = *discord.GetGuild(id); + + auto guild_update_cb = [this](Snowflake id) { + if (id != GuildID) return; + const auto guild = *Abaddon::Get().GetDiscordClient().GetGuild(id); + set_title(guild.Name); + if (guild.HasIcon()) + Abaddon::Get().GetImageManager().LoadFromURL(guild.GetIconURL(), sigc::mem_fun(*this, &GuildSettingsWindow::set_icon)); + }; + discord.signal_guild_update().connect(sigc::track_obj(guild_update_cb, *this)); + + set_name("guild-settings"); + set_default_size(800, 600); + set_title(guild.Name); + set_position(Gtk::WIN_POS_CENTER); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + get_style_context()->add_class("guild-settings-window"); + + if (guild.HasIcon()) { + Abaddon::Get().GetImageManager().LoadFromURL(guild.GetIconURL(), sigc::mem_fun(*this, &GuildSettingsWindow::set_icon)); + } + + m_switcher.set_stack(m_stack); + m_switcher.set_halign(Gtk::ALIGN_CENTER); + m_switcher.set_hexpand(true); + m_switcher.set_margin_top(10); + m_switcher.show(); + + m_pane_info.show(); + m_pane_members.show(); + m_pane_roles.show(); + m_pane_bans.show(); + m_pane_invites.show(); + m_pane_emojis.show(); + m_pane_audit_log.show(); + + m_stack.set_transition_duration(100); + m_stack.set_transition_type(Gtk::STACK_TRANSITION_TYPE_CROSSFADE); + m_stack.set_margin_top(10); + m_stack.set_margin_bottom(10); + m_stack.set_margin_left(10); + m_stack.set_margin_right(10); + + const auto self_id = discord.GetUserData().ID; + + m_stack.add(m_pane_info, "info", "Info"); + m_stack.add(m_pane_members, "members", "Members"); + m_stack.add(m_pane_roles, "roles", "Roles"); + m_stack.add(m_pane_bans, "bans", "Bans"); + if (discord.HasGuildPermission(self_id, GuildID, Permission::MANAGE_GUILD)) + m_stack.add(m_pane_invites, "invites", "Invites"); + m_stack.add(m_pane_emojis, "emojis", "Emojis"); + if (discord.HasGuildPermission(self_id, GuildID, Permission::VIEW_AUDIT_LOG)) + m_stack.add(m_pane_audit_log, "audit-log", "Audit Log"); + m_stack.show(); + + m_main.add(m_switcher); + m_main.add(m_stack); + m_main.show(); + add(m_main); +} diff --git a/src/windows/guildsettingswindow.hpp b/src/windows/guildsettingswindow.hpp new file mode 100644 index 0000000..b591640 --- /dev/null +++ b/src/windows/guildsettingswindow.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include "discord/snowflake.hpp" +#include "guildsettings/infopane.hpp" +#include "guildsettings/banspane.hpp" +#include "guildsettings/invitespane.hpp" +#include "guildsettings/auditlogpane.hpp" +#include "guildsettings/memberspane.hpp" +#include "guildsettings/rolespane.hpp" +#include "guildsettings/emojispane.hpp" + +class GuildSettingsWindow : public Gtk::Window { +public: + GuildSettingsWindow(Snowflake id); + +private: + Gtk::Box m_main; + Gtk::Stack m_stack; + Gtk::StackSwitcher m_switcher; + + GuildSettingsInfoPane m_pane_info; + GuildSettingsMembersPane m_pane_members; + GuildSettingsRolesPane m_pane_roles; + GuildSettingsBansPane m_pane_bans; + GuildSettingsInvitesPane m_pane_invites; + GuildSettingsEmojisPane m_pane_emojis; + GuildSettingsAuditLogPane m_pane_audit_log; + + Snowflake GuildID; +}; diff --git a/src/windows/mainwindow.cpp b/src/windows/mainwindow.cpp new file mode 100644 index 0000000..659107a --- /dev/null +++ b/src/windows/mainwindow.cpp @@ -0,0 +1,308 @@ +#include "mainwindow.hpp" +#include "abaddon.hpp" + +MainWindow::MainWindow() + : m_main_box(Gtk::ORIENTATION_VERTICAL) + , m_content_box(Gtk::ORIENTATION_HORIZONTAL) + , m_chan_content_paned(Gtk::ORIENTATION_HORIZONTAL) + , m_content_members_paned(Gtk::ORIENTATION_HORIZONTAL) { + set_default_size(1200, 800); + get_style_context()->add_class("app-window"); + + m_menu_discord.set_label("Discord"); + m_menu_discord.set_submenu(m_menu_discord_sub); + m_menu_discord_connect.set_label("Connect"); + m_menu_discord_connect.set_sensitive(false); + m_menu_discord_disconnect.set_label("Disconnect"); + m_menu_discord_disconnect.set_sensitive(false); + m_menu_discord_set_token.set_label("Set Token"); + m_menu_discord_join_guild.set_label("Accept Invite"); + m_menu_discord_join_guild.set_sensitive(false); + m_menu_discord_set_status.set_label("Set Status"); + m_menu_discord_set_status.set_sensitive(false); + m_menu_discord_add_recipient.set_label("Add user to DM"); + m_menu_discord_sub.append(m_menu_discord_connect); + m_menu_discord_sub.append(m_menu_discord_disconnect); + m_menu_discord_sub.append(m_menu_discord_set_token); + m_menu_discord_sub.append(m_menu_discord_join_guild); + m_menu_discord_sub.append(m_menu_discord_set_status); + m_menu_discord_sub.append(m_menu_discord_add_recipient); + m_menu_discord_sub.signal_popped_up().connect(sigc::mem_fun(*this, &MainWindow::OnDiscordSubmenuPopup)); // this gets called twice for some reason + m_menu_discord.set_submenu(m_menu_discord_sub); + + m_menu_file.set_label("File"); + m_menu_file.set_submenu(m_menu_file_sub); + m_menu_file_reload_css.set_label("Reload CSS"); + m_menu_file_clear_cache.set_label("Clear file cache"); + m_menu_file_sub.append(m_menu_file_reload_css); + m_menu_file_sub.append(m_menu_file_clear_cache); + + m_menu_view.set_label("View"); + m_menu_view.set_submenu(m_menu_view_sub); + m_menu_view_friends.set_label("Friends"); + m_menu_view_pins.set_label("Pins"); + m_menu_view_threads.set_label("Threads"); + m_menu_view_sub.append(m_menu_view_friends); + m_menu_view_sub.append(m_menu_view_pins); + m_menu_view_sub.append(m_menu_view_threads); + m_menu_view_sub.signal_popped_up().connect(sigc::mem_fun(*this, &MainWindow::OnViewSubmenuPopup)); + + m_menu_bar.append(m_menu_file); + m_menu_bar.append(m_menu_discord); + m_menu_bar.append(m_menu_view); + m_menu_bar.show_all(); + + m_menu_discord_connect.signal_activate().connect([this] { + m_signal_action_connect.emit(); + }); + + m_menu_discord_disconnect.signal_activate().connect([this] { + m_signal_action_disconnect.emit(); + }); + + m_menu_discord_set_token.signal_activate().connect([this] { + m_signal_action_set_token.emit(); + }); + + m_menu_discord_join_guild.signal_activate().connect([this] { + m_signal_action_join_guild.emit(); + }); + + m_menu_file_reload_css.signal_activate().connect([this] { + m_signal_action_reload_css.emit(); + }); + + m_menu_discord_set_status.signal_activate().connect([this] { + m_signal_action_set_status.emit(); + }); + + m_menu_file_clear_cache.signal_activate().connect([this] { + Abaddon::Get().GetImageManager().ClearCache(); + }); + + m_menu_discord_add_recipient.signal_activate().connect([this] { + m_signal_action_add_recipient.emit(GetChatActiveChannel()); + }); + + m_menu_view_friends.signal_activate().connect([this] { + UpdateChatActiveChannel(Snowflake::Invalid); + m_members.UpdateMemberList(); + m_content_stack.set_visible_child("friends"); + }); + + m_menu_view_pins.signal_activate().connect([this] { + m_signal_action_view_pins.emit(GetChatActiveChannel()); + }); + + m_menu_view_threads.signal_activate().connect([this] { + m_signal_action_view_threads.emit(GetChatActiveChannel()); + }); + + m_content_box.set_hexpand(true); + m_content_box.set_vexpand(true); + m_content_box.show(); + + m_main_box.add(m_menu_bar); + m_main_box.add(m_content_box); + m_main_box.show(); + + auto *member_list = m_members.GetRoot(); + auto *chat = m_chat.GetRoot(); + + chat->set_vexpand(true); + chat->set_hexpand(true); + chat->show(); + + m_channel_list.set_vexpand(true); + m_channel_list.set_size_request(-1, -1); + m_channel_list.show(); + + member_list->set_vexpand(true); + member_list->show(); + + m_friends.set_vexpand(true); + m_friends.set_hexpand(true); + m_friends.show(); + + m_content_stack.add(*chat, "chat"); + m_content_stack.add(m_friends, "friends"); + m_content_stack.set_vexpand(true); + m_content_stack.set_hexpand(true); + m_content_stack.set_visible_child("chat"); + m_content_stack.show(); + + m_chan_content_paned.pack1(m_channel_list); + m_chan_content_paned.pack2(m_content_members_paned); + m_chan_content_paned.child_property_shrink(m_channel_list) = false; + m_chan_content_paned.child_property_resize(m_channel_list) = false; + m_chan_content_paned.set_position(200); + m_chan_content_paned.show(); + m_content_box.add(m_chan_content_paned); + + m_content_members_paned.pack1(m_content_stack); + m_content_members_paned.pack2(*member_list); + m_content_members_paned.child_property_shrink(*member_list) = false; + m_content_members_paned.child_property_resize(*member_list) = false; + int w, h; + get_default_size(w, h); // :s + m_content_members_paned.set_position(w - m_chan_content_paned.get_position() - 150); + m_content_members_paned.show(); + + add(m_main_box); +} + +void MainWindow::UpdateComponents() { + bool discord_active = Abaddon::Get().IsDiscordActive(); + + if (!discord_active) { + m_chat.Clear(); + m_members.Clear(); + } else { + m_members.UpdateMemberList(); + } + UpdateChannelListing(); +} + +void MainWindow::UpdateMembers() { + m_members.UpdateMemberList(); +} + +void MainWindow::UpdateChannelListing() { + m_channel_list.UpdateListing(); +} + +void MainWindow::UpdateChatWindowContents() { + auto &discord = Abaddon::Get().GetDiscordClient(); + auto msgs = discord.GetMessagesForChannel(m_chat.GetActiveChannel(), 50); + m_chat.SetMessages(msgs); + m_members.UpdateMemberList(); +} + +void MainWindow::UpdateChatActiveChannel(Snowflake id) { + m_chat.SetActiveChannel(id); + m_members.SetActiveChannel(id); + m_channel_list.SetActiveChannel(id); + m_content_stack.set_visible_child("chat"); +} + +Snowflake MainWindow::GetChatActiveChannel() const { + return m_chat.GetActiveChannel(); +} + +void MainWindow::UpdateChatNewMessage(const Message &data) { + if (data.ChannelID == GetChatActiveChannel()) { + m_chat.AddNewMessage(data); + } +} + +void MainWindow::UpdateChatMessageDeleted(Snowflake id, Snowflake channel_id) { + if (channel_id == GetChatActiveChannel()) + m_chat.DeleteMessage(id); +} + +void MainWindow::UpdateChatMessageUpdated(Snowflake id, Snowflake channel_id) { + if (channel_id == GetChatActiveChannel()) + m_chat.UpdateMessage(id); +} + +void MainWindow::UpdateChatPrependHistory(const std::vector &msgs) { + m_chat.AddNewHistory(msgs); // given vector should be sorted ascending +} + +void MainWindow::InsertChatInput(std::string text) { + m_chat.InsertChatInput(text); +} + +Snowflake MainWindow::GetChatOldestListedMessage() { + return m_chat.GetOldestListedMessage(); +} + +void MainWindow::UpdateChatReactionAdd(Snowflake id, const Glib::ustring ¶m) { + m_chat.UpdateReactions(id); +} + +void MainWindow::UpdateChatReactionRemove(Snowflake id, const Glib::ustring ¶m) { + m_chat.UpdateReactions(id); +} + +void MainWindow::OnDiscordSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) { + auto &discord = Abaddon::Get().GetDiscordClient(); + auto channel_id = GetChatActiveChannel(); + m_menu_discord_add_recipient.set_visible(false); + if (channel_id.IsValid()) { + auto channel = discord.GetChannel(channel_id); + if (channel.has_value() && channel->GetDMRecipients().size() + 1 < 10) + m_menu_discord_add_recipient.set_visible(channel->Type == ChannelType::GROUP_DM); + } + + const bool discord_active = Abaddon::Get().GetDiscordClient().IsStarted(); + + std::string token = Abaddon::Get().GetDiscordToken(); + m_menu_discord_connect.set_sensitive(token.size() > 0 && !discord_active); + m_menu_discord_disconnect.set_sensitive(discord_active); + m_menu_discord_join_guild.set_sensitive(discord_active); + m_menu_discord_set_token.set_sensitive(!discord_active); + m_menu_discord_set_status.set_sensitive(discord_active); +} + +void MainWindow::OnViewSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y) { + m_menu_view_friends.set_sensitive(Abaddon::Get().GetDiscordClient().IsStarted()); + auto channel_id = GetChatActiveChannel(); + m_menu_view_pins.set_sensitive(false); + m_menu_view_threads.set_sensitive(false); + if (channel_id.IsValid()) { + auto channel = Abaddon::Get().GetDiscordClient().GetChannel(channel_id); + if (channel.has_value()) { + m_menu_view_threads.set_sensitive(channel->Type == ChannelType::GUILD_TEXT || channel->IsThread()); + m_menu_view_pins.set_sensitive(channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::DM || channel->Type == ChannelType::GROUP_DM || channel->IsThread()); + } + } +} + +ChannelList *MainWindow::GetChannelList() { + return &m_channel_list; +} + +ChatWindow *MainWindow::GetChatWindow() { + return &m_chat; +} + +MemberList *MainWindow::GetMemberList() { + return &m_members; +} + +MainWindow::type_signal_action_connect MainWindow::signal_action_connect() { + return m_signal_action_connect; +} + +MainWindow::type_signal_action_disconnect MainWindow::signal_action_disconnect() { + return m_signal_action_disconnect; +} + +MainWindow::type_signal_action_set_token MainWindow::signal_action_set_token() { + return m_signal_action_set_token; +} + +MainWindow::type_signal_action_reload_css MainWindow::signal_action_reload_css() { + return m_signal_action_reload_css; +} + +MainWindow::type_signal_action_join_guild MainWindow::signal_action_join_guild() { + return m_signal_action_join_guild; +} + +MainWindow::type_signal_action_set_status MainWindow::signal_action_set_status() { + return m_signal_action_set_status; +} + +MainWindow::type_signal_action_add_recipient MainWindow::signal_action_add_recipient() { + return m_signal_action_add_recipient; +} + +MainWindow::type_signal_action_view_pins MainWindow::signal_action_view_pins() { + return m_signal_action_view_pins; +} + +MainWindow::type_signal_action_view_threads MainWindow::signal_action_view_threads() { + return m_signal_action_view_threads; +} diff --git a/src/windows/mainwindow.hpp b/src/windows/mainwindow.hpp new file mode 100644 index 0000000..df1c968 --- /dev/null +++ b/src/windows/mainwindow.hpp @@ -0,0 +1,99 @@ +#pragma once +#include "components/channels.hpp" +#include "components/chatwindow.hpp" +#include "components/memberlist.hpp" +#include "components/friendslist.hpp" +#include + +class MainWindow : public Gtk::Window { +public: + MainWindow(); + + void UpdateComponents(); + void UpdateMembers(); + void UpdateChannelListing(); + void UpdateChatWindowContents(); + void UpdateChatActiveChannel(Snowflake id); + Snowflake GetChatActiveChannel() const; + void UpdateChatNewMessage(const Message &data); + void UpdateChatMessageDeleted(Snowflake id, Snowflake channel_id); + void UpdateChatMessageUpdated(Snowflake id, Snowflake channel_id); + void UpdateChatPrependHistory(const std::vector &msgs); + void InsertChatInput(std::string text); + Snowflake GetChatOldestListedMessage(); + void UpdateChatReactionAdd(Snowflake id, const Glib::ustring ¶m); + void UpdateChatReactionRemove(Snowflake id, const Glib::ustring ¶m); + + ChannelList *GetChannelList(); + ChatWindow *GetChatWindow(); + MemberList *GetMemberList(); + +public: + typedef sigc::signal type_signal_action_connect; + typedef sigc::signal type_signal_action_disconnect; + typedef sigc::signal type_signal_action_set_token; + typedef sigc::signal type_signal_action_reload_css; + typedef sigc::signal type_signal_action_join_guild; + typedef sigc::signal type_signal_action_set_status; + // this should probably be removed + typedef sigc::signal type_signal_action_add_recipient; // channel id + typedef sigc::signal type_signal_action_view_pins; // channel id + typedef sigc::signal type_signal_action_view_threads; // channel id + + type_signal_action_connect signal_action_connect(); + type_signal_action_disconnect signal_action_disconnect(); + type_signal_action_set_token signal_action_set_token(); + type_signal_action_reload_css signal_action_reload_css(); + type_signal_action_join_guild signal_action_join_guild(); + type_signal_action_set_status signal_action_set_status(); + type_signal_action_add_recipient signal_action_add_recipient(); + type_signal_action_view_pins signal_action_view_pins(); + type_signal_action_view_threads signal_action_view_threads(); + +protected: + type_signal_action_connect m_signal_action_connect; + type_signal_action_disconnect m_signal_action_disconnect; + type_signal_action_set_token m_signal_action_set_token; + type_signal_action_reload_css m_signal_action_reload_css; + type_signal_action_join_guild m_signal_action_join_guild; + type_signal_action_set_status m_signal_action_set_status; + type_signal_action_add_recipient m_signal_action_add_recipient; + type_signal_action_view_pins m_signal_action_view_pins; + type_signal_action_view_threads m_signal_action_view_threads; + +protected: + Gtk::Box m_main_box; + Gtk::Box m_content_box; + Gtk::Paned m_chan_content_paned; + Gtk::Paned m_content_members_paned; + + ChannelList m_channel_list; + ChatWindow m_chat; + MemberList m_members; + FriendsList m_friends; + + Gtk::Stack m_content_stack; + + Gtk::MenuBar m_menu_bar; + Gtk::MenuItem m_menu_discord; + Gtk::Menu m_menu_discord_sub; + Gtk::MenuItem m_menu_discord_connect; + Gtk::MenuItem m_menu_discord_disconnect; + Gtk::MenuItem m_menu_discord_set_token; + Gtk::MenuItem m_menu_discord_join_guild; + Gtk::MenuItem m_menu_discord_set_status; + Gtk::MenuItem m_menu_discord_add_recipient; // move me somewhere else some day + void OnDiscordSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y); + + Gtk::MenuItem m_menu_file; + Gtk::Menu m_menu_file_sub; + Gtk::MenuItem m_menu_file_reload_css; + Gtk::MenuItem m_menu_file_clear_cache; + + Gtk::MenuItem m_menu_view; + Gtk::Menu m_menu_view_sub; + Gtk::MenuItem m_menu_view_friends; + Gtk::MenuItem m_menu_view_pins; + Gtk::MenuItem m_menu_view_threads; + void OnViewSubmenuPopup(const Gdk::Rectangle *flipped_rect, const Gdk::Rectangle *final_rect, bool flipped_x, bool flipped_y); +}; diff --git a/src/windows/pinnedwindow.cpp b/src/windows/pinnedwindow.cpp new file mode 100644 index 0000000..a5484e3 --- /dev/null +++ b/src/windows/pinnedwindow.cpp @@ -0,0 +1,47 @@ +#include "pinnedwindow.hpp" +#include "abaddon.hpp" + +PinnedWindow::PinnedWindow(const ChannelData &data) + : ChannelID(data.ID) { + if (data.GuildID.has_value()) + GuildID = *data.GuildID; + + set_name("pinned-messages"); + set_default_size(450, 375); + if (data.Name.has_value()) + set_title("#" + *data.Name + " - Pinned Messages"); + else + set_title("Pinned Messages"); + set_position(Gtk::WIN_POS_CENTER); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + get_style_context()->add_class("pinned-messages-window"); + + add(m_chat); + m_chat.show(); + + m_chat.SetSeparateAll(true); + m_chat.SetActiveChannel(ChannelID); + m_chat.SetUsePinnedMenu(); + + Abaddon::Get().GetDiscordClient().signal_message_pinned().connect(sigc::mem_fun(*this, &PinnedWindow::OnMessagePinned)); + Abaddon::Get().GetDiscordClient().signal_message_unpinned().connect(sigc::mem_fun(*this, &PinnedWindow::OnMessageUnpinned)); + FetchPinned(); +} + +void PinnedWindow::OnMessagePinned(const Message &msg) { + FetchPinned(); +} + +void PinnedWindow::OnMessageUnpinned(const Message &msg) { + m_chat.ActuallyRemoveMessage(msg.ID); +} + +void PinnedWindow::FetchPinned() { + Abaddon::Get().GetDiscordClient().FetchPinned(ChannelID, sigc::mem_fun(*this, &PinnedWindow::OnFetchedPinned)); +} + +void PinnedWindow::OnFetchedPinned(const std::vector &msgs, DiscordError code) { + if (code != DiscordError::NONE) return; + m_chat.SetMessages(msgs.begin(), msgs.end()); +} diff --git a/src/windows/pinnedwindow.hpp b/src/windows/pinnedwindow.hpp new file mode 100644 index 0000000..cf2ec3c --- /dev/null +++ b/src/windows/pinnedwindow.hpp @@ -0,0 +1,22 @@ +#pragma once +#include +#include "discord/errors.hpp" +#include "discord/channel.hpp" +#include "discord/message.hpp" +#include "components/chatlist.hpp" + +class PinnedWindow : public Gtk::Window { +public: + PinnedWindow(const ChannelData &data); + + Snowflake GuildID; + Snowflake ChannelID; + +private: + void OnMessagePinned(const Message &msg); + void OnMessageUnpinned(const Message &msg); + void FetchPinned(); + void OnFetchedPinned(const std::vector &msgs, DiscordError code); + + ChatList m_chat; +}; diff --git a/src/windows/profile/mutualfriendspane.cpp b/src/windows/profile/mutualfriendspane.cpp new file mode 100644 index 0000000..339fd71 --- /dev/null +++ b/src/windows/profile/mutualfriendspane.cpp @@ -0,0 +1,58 @@ +#include "mutualfriendspane.hpp" +#include "abaddon.hpp" + +MutualFriendItem::MutualFriendItem(const UserData &user) + : Gtk::Box(Gtk::ORIENTATION_HORIZONTAL) { + get_style_context()->add_class("mutual-friend-item"); + m_name.get_style_context()->add_class("mutual-friend-item-name"); + m_avatar.get_style_context()->add_class("mutual-friend-item-avatar"); + + m_avatar.set_margin_end(10); + + const auto show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + auto &img = Abaddon::Get().GetImageManager(); + m_avatar.property_pixbuf() = img.GetPlaceholder(24); + if (user.HasAnimatedAvatar() && show_animations) { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf_animation() = pb; + }; + img.LoadAnimationFromURL(user.GetAvatarURL("gif", "32"), 24, 24, sigc::track_obj(cb, *this)); + } else { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf() = pb->scale_simple(24, 24, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(user.GetAvatarURL("png", "32"), sigc::track_obj(cb, *this)); + } + + m_name.set_markup(user.GetEscapedBoldString()); + + m_name.set_valign(Gtk::ALIGN_CENTER); + add(m_avatar); + add(m_name); + show_all_children(); +} + +MutualFriendsPane::MutualFriendsPane(Snowflake id) + : UserID(id) { + signal_map().connect(sigc::mem_fun(*this, &MutualFriendsPane::OnMap)); + add(m_list); + show_all_children(); +} + +void MutualFriendsPane::OnFetchRelationships(const std::vector &users) { + for (auto child : m_list.get_children()) + delete child; + + for (const auto &user : users) { + auto *item = Gtk::manage(new MutualFriendItem(user)); + item->show(); + m_list.add(*item); + } +} + +void MutualFriendsPane::OnMap() { + if (m_requested) return; + m_requested = true; + + Abaddon::Get().GetDiscordClient().FetchUserRelationships(UserID, sigc::mem_fun(*this, &MutualFriendsPane::OnFetchRelationships)); +} diff --git a/src/windows/profile/mutualfriendspane.hpp b/src/windows/profile/mutualfriendspane.hpp new file mode 100644 index 0000000..ef41aa6 --- /dev/null +++ b/src/windows/profile/mutualfriendspane.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class MutualFriendItem : public Gtk::Box { +public: + MutualFriendItem(const UserData &user); + +private: + Gtk::Image m_avatar; + Gtk::Label m_name; +}; + +class MutualFriendsPane : public Gtk::ScrolledWindow { +public: + MutualFriendsPane(Snowflake id); + + Snowflake UserID; + +private: + void OnMap(); + + bool m_requested = false; + + void OnFetchRelationships(const std::vector &users); + + Gtk::ListBox m_list; +}; diff --git a/src/windows/profile/mutualguildspane.cpp b/src/windows/profile/mutualguildspane.cpp new file mode 100644 index 0000000..6bfdc7b --- /dev/null +++ b/src/windows/profile/mutualguildspane.cpp @@ -0,0 +1,73 @@ +#include "mutualguildspane.hpp" +#include "abaddon.hpp" + +MutualGuildItem::MutualGuildItem(const MutualGuildData &guild) + : Gtk::Box(Gtk::ORIENTATION_HORIZONTAL) + , m_box(Gtk::ORIENTATION_VERTICAL) { + get_style_context()->add_class("mutual-guild-item"); + m_name.get_style_context()->add_class("mutual-guild-item-name"); + m_icon.get_style_context()->add_class("mutual-guild-item-icon"); + + m_icon.set_margin_end(10); + + // discord will return info (id + nick) for "deleted" guilds from this endpoint. strange ! + const auto data = Abaddon::Get().GetDiscordClient().GetGuild(guild.ID); + if (data.has_value()) { + const auto show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + auto &img = Abaddon::Get().GetImageManager(); + m_icon.property_pixbuf() = img.GetPlaceholder(24); + if (data->HasIcon()) { + if (data->HasAnimatedIcon() && show_animations) { + auto cb = [this](const Glib::RefPtr &pb) { + m_icon.property_pixbuf_animation() = pb; + }; + img.LoadAnimationFromURL(data->GetIconURL("gif", "32"), 24, 24, sigc::track_obj(cb, *this)); + } else { + auto cb = [this](const Glib::RefPtr &pb) { + m_icon.property_pixbuf() = pb->scale_simple(24, 24, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(data->GetIconURL("png", "32"), sigc::track_obj(cb, *this)); + } + } + + m_name.set_markup("" + Glib::Markup::escape_text(data->Name) + ""); + } else { + m_icon.property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(24); + m_name.set_markup("Unknown server"); + } + + if (guild.Nick.has_value()) { + m_nick = Gtk::manage(new Gtk::Label(*guild.Nick)); + m_nick->get_style_context()->add_class("mutual-guild-item-nick"); + m_nick->set_margin_start(5); + m_nick->set_halign(Gtk::ALIGN_START); + m_nick->set_single_line_mode(true); + m_nick->set_ellipsize(Pango::ELLIPSIZE_END); + } + + m_box.set_valign(Gtk::ALIGN_CENTER); + + m_box.add(m_name); + if (m_nick != nullptr) + m_box.add(*m_nick); + add(m_icon); + add(m_box); + show_all_children(); +} + +UserMutualGuildsPane::UserMutualGuildsPane(Snowflake id) + : UserID(id) { + add(m_list); + show_all_children(); +} + +void UserMutualGuildsPane::SetMutualGuilds(const std::vector &guilds) { + for (auto child : m_list.get_children()) + delete child; + + for (const auto &guild : guilds) { + auto *item = Gtk::manage(new MutualGuildItem(guild)); + item->show(); + m_list.add(*item); + } +} diff --git a/src/windows/profile/mutualguildspane.hpp b/src/windows/profile/mutualguildspane.hpp new file mode 100644 index 0000000..9bdd97e --- /dev/null +++ b/src/windows/profile/mutualguildspane.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class MutualGuildItem : public Gtk::Box { +public: + MutualGuildItem(const MutualGuildData &guild); + +private: + Gtk::Image m_icon; + Gtk::Box m_box; + Gtk::Label m_name; + Gtk::Label *m_nick = nullptr; +}; + +class UserMutualGuildsPane : public Gtk::ScrolledWindow { +public: + UserMutualGuildsPane(Snowflake id); + + void SetMutualGuilds(const std::vector &guilds); + + Snowflake UserID; + +private: + Gtk::ListBox m_list; +}; diff --git a/src/windows/profile/userinfopane.cpp b/src/windows/profile/userinfopane.cpp new file mode 100644 index 0000000..a95a14c --- /dev/null +++ b/src/windows/profile/userinfopane.cpp @@ -0,0 +1,236 @@ +#include "userinfopane.hpp" +#include +#include "abaddon.hpp" + +ConnectionItem::ConnectionItem(const ConnectionData &conn) + : m_box(Gtk::ORIENTATION_HORIZONTAL) + , m_name(conn.Name) { + Glib::RefPtr pixbuf; + try { + pixbuf = Gdk::Pixbuf::create_from_file(Abaddon::GetResPath("/" + conn.Type + ".png"), 32, 32); + } catch (const Glib::Exception &e) {} + std::string url; + if (conn.Type == "github") + url = "https://github.com/" + conn.Name; + else if (conn.Type == "steam") + url = "https://steamcommunity.com/profiles/" + conn.ID; + else if (conn.Type == "twitch") + url = "https://twitch.tv/" + conn.Name; + else if (conn.Type == "twitter") + url = "https://twitter.com/i/user/" + conn.ID; + else if (conn.Type == "spotify") + url = "https://open.spotify.com/user/" + conn.ID; + else if (conn.Type == "reddit") + url = "https://reddit.com/u/" + conn.Name; + else if (conn.Type == "youtube") + url = "https://www.youtube.com/channel/" + conn.ID; + else if (conn.Type == "facebook") + url = "https://www.facebook.com/" + conn.ID; + if (pixbuf) { + m_image = Gtk::manage(new Gtk::Image(pixbuf)); + m_image->get_style_context()->add_class("profile-connection-image"); + m_box.add(*m_image); + } + m_box.set_halign(Gtk::ALIGN_START); + m_box.set_size_request(200, -1); + m_box.get_style_context()->add_class("profile-connection"); + m_name.get_style_context()->add_class("profile-connection-label"); + m_name.set_valign(Gtk::ALIGN_CENTER); + m_name.set_single_line_mode(true); + m_name.set_ellipsize(Pango::ELLIPSIZE_END); + m_box.add(m_name); + if (url != "") { + auto cb = [this, url](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + LaunchBrowser(url); + return true; + } + return false; + }; + signal_button_press_event().connect(sigc::track_obj(cb, *this)); + AddPointerCursor(*this); + } + m_overlay.add(m_box); + if (conn.IsVerified) { + try { + const static auto checkmarks_path = Abaddon::GetResPath("/checkmark.png"); + static auto pb = Gdk::Pixbuf::create_from_file(checkmarks_path, 24, 24); + m_check = Gtk::manage(new Gtk::Image(pb)); + m_check->get_style_context()->add_class("profile-connection-check"); + m_check->set_margin_end(25); + m_check->set_valign(Gtk::ALIGN_CENTER); + m_check->set_halign(Gtk::ALIGN_END); + m_check->show(); + m_overlay.add_overlay(*m_check); + } catch (const Glib::Exception &e) {} + } + m_overlay.set_hexpand(false); + m_overlay.set_halign(Gtk::ALIGN_START); + add(m_overlay); + show_all_children(); +} + +ConnectionsContainer::ConnectionsContainer() { + get_style_context()->add_class("profile-connections"); + set_column_homogeneous(true); + set_row_spacing(10); + set_column_spacing(10); + show_all_children(); +} + +void ConnectionsContainer::SetConnections(const std::vector &connections) { + for (auto child : get_children()) + delete child; + + static const std::unordered_set supported_services = { + "battlenet", + "github", + "leagueoflegends", + "reddit", + "skype", + "spotify", + "steam", + "twitch", + "twitter", + "xbox", + "youtube", + "facebook" + }; + + for (size_t i = 0; i < connections.size(); i++) { + const auto &conn = connections[i]; + if (supported_services.find(conn.Type) == supported_services.end()) continue; + auto widget = Gtk::manage(new ConnectionItem(conn)); + widget->show(); + attach(*widget, i % 2, i / 2, 1, 1); + } + + set_halign(Gtk::ALIGN_FILL); + set_hexpand(true); +} + +NotesContainer::NotesContainer() + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) { + get_style_context()->add_class("profile-notes"); + m_label.get_style_context()->add_class("profile-notes-label"); + m_note.get_style_context()->add_class("profile-notes-text"); + + m_label.set_markup("NOTE"); + m_label.set_halign(Gtk::ALIGN_START); + + m_note.set_wrap_mode(Gtk::WRAP_WORD_CHAR); + m_note.signal_key_press_event().connect(sigc::mem_fun(*this, &NotesContainer::OnNoteKeyPress), false); + + add(m_label); + add(m_note); + show_all_children(); +} + +void NotesContainer::SetNote(const std::string ¬e) { + m_note.get_buffer()->set_text(note); +} + +void NotesContainer::UpdateNote() { + auto text = m_note.get_buffer()->get_text(); + if (text.size() > 256) + text = text.substr(0, 256); + m_signal_update_note.emit(text); +} + +bool NotesContainer::OnNoteKeyPress(GdkEventKey *event) { + if (event->type != GDK_KEY_PRESS) return false; + const auto text = m_note.get_buffer()->get_text(); + if (event->keyval == GDK_KEY_Return) { + if (event->state & GDK_SHIFT_MASK) { + int newlines = 0; + for (const auto c : text) + if (c == '\n') newlines++; + return newlines >= 5; + } else { + UpdateNote(); + return true; + } + } + + return false; +} + +NotesContainer::type_signal_update_note NotesContainer::signal_update_note() { + return m_signal_update_note; +} + +BioContainer::BioContainer() + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) { + m_label.set_markup("ABOUT ME"); + m_label.set_halign(Gtk::ALIGN_START); + m_bio.set_halign(Gtk::ALIGN_START); + m_bio.set_line_wrap(true); + m_bio.set_line_wrap_mode(Pango::WRAP_WORD_CHAR); + + m_label.show(); + m_bio.show(); + + add(m_label); + add(m_bio); +} + +void BioContainer::SetBio(const std::string &bio) { + m_bio.set_text(bio); +} + +ProfileUserInfoPane::ProfileUserInfoPane(Snowflake ID) + : Gtk::Box(Gtk::ORIENTATION_VERTICAL) + , UserID(ID) { + get_style_context()->add_class("profile-info-pane"); + m_created.get_style_context()->add_class("profile-info-created"); + + m_note.signal_update_note().connect([this](const Glib::ustring ¬e) { + auto cb = [this](DiscordError code) { + if (code != DiscordError::NONE) { + Gtk::MessageDialog dlg("Failed to set note", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dlg.set_position(Gtk::WIN_POS_CENTER); + dlg.run(); + } + }; + Abaddon::Get().GetDiscordClient().SetUserNote(UserID, note, sigc::track_obj(cb, *this)); + }); + + auto &discord = Abaddon::Get().GetDiscordClient(); + auto note_update_cb = [this](Snowflake id, std::string note) { + if (id == UserID) + m_note.SetNote(note); + }; + discord.signal_note_update().connect(sigc::track_obj(note_update_cb, m_note)); + + auto fetch_note_cb = [this](const std::string ¬e) { + m_note.SetNote(note); + }; + discord.FetchUserNote(UserID, sigc::track_obj(fetch_note_cb, *this)); + + m_created.set_halign(Gtk::ALIGN_START); + m_created.set_margin_top(5); + m_created.set_text("Account created: " + ID.GetLocalTimestamp()); + + m_conns.set_halign(Gtk::ALIGN_START); + m_conns.set_hexpand(true); + + m_created.show(); + m_note.show(); + m_conns.show(); + + add(m_created); + add(m_bio); + add(m_note); + add(m_conns); +} + +void ProfileUserInfoPane::SetProfile(const UserProfileData &data) { + if (data.User.Bio.has_value() && *data.User.Bio != "") { + m_bio.SetBio(*data.User.Bio); + m_bio.show(); + } else { + m_bio.hide(); + } + + m_conns.SetConnections(data.ConnectedAccounts); +} diff --git a/src/windows/profile/userinfopane.hpp b/src/windows/profile/userinfopane.hpp new file mode 100644 index 0000000..90a4d55 --- /dev/null +++ b/src/windows/profile/userinfopane.hpp @@ -0,0 +1,65 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class ConnectionItem : public Gtk::EventBox { +public: + ConnectionItem(const ConnectionData &connection); + +private: + Gtk::Overlay m_overlay; + Gtk::Box m_box; + Gtk::Label m_name; + Gtk::Image *m_image = nullptr; + Gtk::Image *m_check = nullptr; +}; + +class ConnectionsContainer : public Gtk::Grid { +public: + ConnectionsContainer(); + void SetConnections(const std::vector &connections); +}; + +class NotesContainer : public Gtk::Box { +public: + NotesContainer(); + void SetNote(const std::string ¬e); + +private: + void UpdateNote(); + bool OnNoteKeyPress(GdkEventKey *event); + + Gtk::Label m_label; + Gtk::TextView m_note; + + typedef sigc::signal type_signal_update_note; + type_signal_update_note m_signal_update_note; + +public: + type_signal_update_note signal_update_note(); +}; + +class BioContainer : public Gtk::Box { +public: + BioContainer(); + void SetBio(const std::string &bio); + +private: + Gtk::Label m_label; + Gtk::Label m_bio; +}; + +class ProfileUserInfoPane : public Gtk::Box { +public: + ProfileUserInfoPane(Snowflake ID); + void SetProfile(const UserProfileData &data); + + Snowflake UserID; + +private: + Gtk::Label m_created; + + BioContainer m_bio; + NotesContainer m_note; + ConnectionsContainer m_conns; +}; diff --git a/src/windows/profilewindow.cpp b/src/windows/profilewindow.cpp new file mode 100644 index 0000000..4d41f89 --- /dev/null +++ b/src/windows/profilewindow.cpp @@ -0,0 +1,127 @@ +#include "profilewindow.hpp" +#include "abaddon.hpp" + +ProfileWindow::ProfileWindow(Snowflake user_id) + : ID(user_id) + , m_main(Gtk::ORIENTATION_VERTICAL) + , m_upper(Gtk::ORIENTATION_HORIZONTAL) + , m_badges(Gtk::ORIENTATION_HORIZONTAL) + , m_pane_info(user_id) + , m_pane_guilds(user_id) + , m_pane_friends(user_id) { + auto &discord = Abaddon::Get().GetDiscordClient(); + auto user = *discord.GetUser(ID); + + discord.FetchUserProfile(user_id, sigc::mem_fun(*this, &ProfileWindow::OnFetchProfile)); + + set_name("user-profile"); + set_default_size(450, 375); + set_title(user.Username + "#" + user.Discriminator); + set_position(Gtk::WIN_POS_CENTER); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + get_style_context()->add_class("user-profile-window"); + m_main.get_style_context()->add_class("profile-main-container"); + m_avatar.get_style_context()->add_class("profile-avatar"); + m_username.get_style_context()->add_class("profile-username"); + m_switcher.get_style_context()->add_class("profile-switcher"); + m_stack.get_style_context()->add_class("profile-stack"); + m_badges.get_style_context()->add_class("profile-badges"); + + m_scroll.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_AUTOMATIC); + m_scroll.set_vexpand(true); + m_scroll.set_propagate_natural_height(true); + + if (user.HasAvatar()) + AddPointerCursor(m_avatar_ev); + m_avatar_ev.signal_button_press_event().connect([this, user](GdkEventButton *event) -> bool { + if (event->type == GDK_BUTTON_PRESS && event->button == GDK_BUTTON_PRIMARY) { + if (user.HasAnimatedAvatar()) + LaunchBrowser(user.GetAvatarURL("gif", "512")); + else + LaunchBrowser(user.GetAvatarURL("png", "512")); + } + return false; + }); + + static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); + auto &img = Abaddon::Get().GetImageManager(); + m_avatar.property_pixbuf() = img.GetPlaceholder(64); + auto icon_cb = [this](const Glib::RefPtr &pb) { + set_icon(pb); + }; + img.LoadFromURL(user.GetAvatarURL("png", "64"), sigc::track_obj(icon_cb, *this)); + + if (show_animations && user.HasAnimatedAvatar()) { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf_animation() = pb; + }; + img.LoadAnimationFromURL(user.GetAvatarURL("gif", "64"), 64, 64, sigc::track_obj(cb, *this)); + } else { + auto cb = [this](const Glib::RefPtr &pb) { + m_avatar.property_pixbuf() = pb->scale_simple(64, 64, Gdk::INTERP_BILINEAR); + }; + img.LoadFromURL(user.GetAvatarURL("png", "64"), sigc::track_obj(cb, *this)); + } + + m_username.set_markup(user.GetEscapedString()); + + m_switcher.set_stack(m_stack); + m_switcher.set_halign(Gtk::ALIGN_START); + m_switcher.set_hexpand(true); + + m_stack.add(m_pane_info, "info", "User Info"); + m_stack.add(m_pane_guilds, "guilds", "Mutual Servers"); + m_stack.add(m_pane_friends, "friends", "Mutual Friends"); + + m_badges.set_valign(Gtk::ALIGN_CENTER); + m_badges_scroll.set_hexpand(true); + m_badges_scroll.set_propagate_natural_width(true); + m_badges_scroll.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_NEVER); + + m_upper.set_halign(Gtk::ALIGN_START); + m_avatar.set_halign(Gtk::ALIGN_START); + m_username.set_halign(Gtk::ALIGN_START); + m_avatar_ev.add(m_avatar); + m_upper.add(m_avatar_ev); + m_upper.add(m_username); + m_badges_scroll.add(m_badges); + m_upper.add(m_badges_scroll); + m_main.add(m_upper); + m_main.add(m_switcher); + m_scroll.add(m_stack); + m_main.add(m_scroll); + add(m_main); + show_all_children(); +} + +void ProfileWindow::OnFetchProfile(const UserProfileData &data) { + m_pane_info.SetProfile(data); + m_pane_guilds.SetMutualGuilds(data.MutualGuilds); + + for (auto child : m_badges.get_children()) + delete child; + + if (!data.User.PublicFlags.has_value()) return; + const auto x = *data.User.PublicFlags; + for (uint64_t i = 1; i <= UserData::MaxFlag; i <<= 1) { + if (!(x & i)) continue; + const std::string name = UserData::GetFlagName(i); + if (name == "unknown") continue; + Glib::RefPtr pixbuf; + try { + if (name == "verifiedbot") + pixbuf = Gdk::Pixbuf::create_from_file(Abaddon::GetResPath("/checkmark.png"), 24, 24); + else + pixbuf = Gdk::Pixbuf::create_from_file(Abaddon::GetResPath("/" + name + ".png"), 24, 24); + } catch (const Glib::Exception &e) { + pixbuf = Abaddon::Get().GetImageManager().GetPlaceholder(24); + } + if (!pixbuf) continue; + auto *image = Gtk::manage(new Gtk::Image(pixbuf)); + image->get_style_context()->add_class("profile-badge"); + image->set_tooltip_text(UserData::GetFlagReadableName(i)); + image->show(); + m_badges.add(*image); + } +} diff --git a/src/windows/profilewindow.hpp b/src/windows/profilewindow.hpp new file mode 100644 index 0000000..3d8199b --- /dev/null +++ b/src/windows/profilewindow.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include "discord/snowflake.hpp" +#include "profile/userinfopane.hpp" +#include "profile/mutualguildspane.hpp" +#include "profile/mutualfriendspane.hpp" + +class ProfileWindow : public Gtk::Window { +public: + ProfileWindow(Snowflake user_id); + + Snowflake ID; + +private: + void OnFetchProfile(const UserProfileData &data); + + Gtk::Box m_main; + Gtk::Box m_upper; + Gtk::Box m_badges; + Gtk::ScrolledWindow m_badges_scroll; + Gtk::EventBox m_avatar_ev; + Gtk::Image m_avatar; + Gtk::Label m_username; + Gtk::ScrolledWindow m_scroll; + Gtk::Stack m_stack; + Gtk::StackSwitcher m_switcher; + + ProfileUserInfoPane m_pane_info; + UserMutualGuildsPane m_pane_guilds; + MutualFriendsPane m_pane_friends; +}; diff --git a/src/windows/threadswindow.cpp b/src/windows/threadswindow.cpp new file mode 100644 index 0000000..9071d81 --- /dev/null +++ b/src/windows/threadswindow.cpp @@ -0,0 +1,149 @@ +#include "threadswindow.hpp" +#include "abaddon.hpp" + +ThreadsWindow::ThreadsWindow(const ChannelData &channel) + : m_channel_id(channel.ID) + , m_filter_public(m_group, "Public") + , m_filter_private(m_group, "Private") + , m_box(Gtk::ORIENTATION_VERTICAL) + , m_active(channel, sigc::mem_fun(*this, &ThreadsWindow::ListFilterFunc)) + , m_archived(channel, sigc::mem_fun(*this, &ThreadsWindow::ListFilterFunc)) { + set_name("threads-window"); + set_default_size(450, 375); + set_title("#" + *channel.Name + " - Threads"); + set_position(Gtk::WIN_POS_CENTER); + get_style_context()->add_class("app-window"); + get_style_context()->add_class("app-popup"); + get_style_context()->add_class("threads-window"); + + const auto cb = [this](Snowflake id) { + Abaddon::Get().ActionChannelOpened(id); + hide(); + }; + m_active.signal_thread_open().connect(cb); + m_archived.signal_thread_open().connect(cb); + + m_switcher.set_halign(Gtk::ALIGN_CENTER); + m_switcher.set_stack(m_stack); + + m_stack.add(m_active, "active", "Active Threads"); + m_stack.add(m_archived, "archived", "Archived Threads"); + + m_filter_buttons.set_homogeneous(true); + m_filter_buttons.set_halign(Gtk::ALIGN_CENTER); + m_filter_buttons.add(m_filter_public); + m_filter_buttons.add(m_filter_private); + + // a little strange + const auto btncb = [this](Gtk::RadioButton *btn) { + if (btn->get_active()) { + if (btn == &m_filter_public) + m_filter_mode = FILTER_PUBLIC; + else if (btn == &m_filter_private) + m_filter_mode = FILTER_PRIVATE; + + m_active.InvalidateFilter(); + m_archived.InvalidateFilter(); + } + }; + m_filter_public.signal_toggled().connect(sigc::bind(btncb, &m_filter_public)); + m_filter_private.signal_toggled().connect(sigc::bind(btncb, &m_filter_private)); + + m_active.show(); + m_archived.show(); + m_switcher.show(); + m_filter_buttons.show_all(); + m_stack.show(); + m_box.show(); + + m_box.add(m_switcher); + m_box.add(m_filter_buttons); + m_box.add(m_stack); + add(m_box); +} + +bool ThreadsWindow::ListFilterFunc(Gtk::ListBoxRow *row_) { + if (auto *row = dynamic_cast(row_)) + return (m_filter_mode == FILTER_PUBLIC && (row->Type == ChannelType::GUILD_PUBLIC_THREAD || row->Type == ChannelType::GUILD_NEWS_THREAD)) || + (m_filter_mode == FILTER_PRIVATE && row->Type == ChannelType::GUILD_PRIVATE_THREAD); + return false; +} + +ThreadListRow::ThreadListRow(const ChannelData &channel) + : ID(channel.ID) + , Type(channel.Type) + , m_label(*channel.Name, Gtk::ALIGN_START) { + m_label.show(); + add(m_label); +} + +ActiveThreadsList::ActiveThreadsList(const ChannelData &channel, const Gtk::ListBox::SlotFilter &filter) { + set_vexpand(true); + + m_list.set_filter_func(filter); + m_list.set_selection_mode(Gtk::SELECTION_SINGLE); + m_list.set_hexpand(true); + m_list.show(); + + add(m_list); + + m_list.signal_button_press_event().connect([this](GdkEventButton *ev) -> bool { + if (ev->button == GDK_BUTTON_PRIMARY && ev->type == GDK_2BUTTON_PRESS) { + if (auto row = dynamic_cast(m_list.get_selected_row())) + m_signal_thread_open.emit(row->ID); + } + return false; + }); + + const auto threads = Abaddon::Get().GetDiscordClient().GetActiveThreads(channel.ID); + for (const auto &thread : threads) { + auto row = Gtk::manage(new ThreadListRow(thread)); + row->show(); + m_list.add(*row); + } +} + +void ActiveThreadsList::InvalidateFilter() { + m_list.invalidate_filter(); +} + +ActiveThreadsList::type_signal_thread_open ActiveThreadsList::signal_thread_open() { + return m_signal_thread_open; +} + +ArchivedThreadsList::ArchivedThreadsList(const ChannelData &channel, const Gtk::ListBox::SlotFilter &filter) { + set_vexpand(true); + + m_list.set_filter_func(filter); + m_list.set_selection_mode(Gtk::SELECTION_SINGLE); + m_list.set_hexpand(true); + m_list.show(); + + add(m_list); + + m_list.signal_button_press_event().connect([this](GdkEventButton *ev) -> bool { + if (ev->button == GDK_BUTTON_PRIMARY && ev->type == GDK_2BUTTON_PRESS) { + if (auto row = dynamic_cast(m_list.get_selected_row())) + m_signal_thread_open.emit(row->ID); + } + return false; + }); + + Abaddon::Get().GetDiscordClient().GetArchivedPublicThreads(channel.ID, sigc::mem_fun(*this, &ArchivedThreadsList::OnPublicFetched)); +} + +void ArchivedThreadsList::InvalidateFilter() { + m_list.invalidate_filter(); +} + +void ArchivedThreadsList::OnPublicFetched(DiscordError code, const ArchivedThreadsResponseData &data) { + for (const auto &thread : data.Threads) { + auto row = Gtk::manage(new ThreadListRow(thread)); + row->show(); + m_list.add(*row); + } +} + +ArchivedThreadsList::type_signal_thread_open ArchivedThreadsList::signal_thread_open() { + return m_signal_thread_open; +} diff --git a/src/windows/threadswindow.hpp b/src/windows/threadswindow.hpp new file mode 100644 index 0000000..0e42414 --- /dev/null +++ b/src/windows/threadswindow.hpp @@ -0,0 +1,79 @@ +#pragma once +#include +#include "discord/objects.hpp" + +class ActiveThreadsList : public Gtk::ScrolledWindow { +public: + ActiveThreadsList(const ChannelData &channel, const Gtk::ListBox::SlotFilter &filter); + + void InvalidateFilter(); + +private: + Gtk::ListBox m_list; + + using type_signal_thread_open = sigc::signal; + type_signal_thread_open m_signal_thread_open; + +public: + type_signal_thread_open signal_thread_open(); +}; + +class ArchivedThreadsList : public Gtk::ScrolledWindow { +public: + ArchivedThreadsList(const ChannelData &channel, const Gtk::ListBox::SlotFilter &filter); + + void InvalidateFilter(); + +private: + Gtk::ListBox m_list; + + void OnPublicFetched(DiscordError code, const ArchivedThreadsResponseData &data); + + using type_signal_thread_open = sigc::signal; + type_signal_thread_open m_signal_thread_open; + +public: + type_signal_thread_open signal_thread_open(); +}; + +// view all threads in a channel +class ThreadsWindow : public Gtk::Window { +public: + ThreadsWindow(const ChannelData &channel); + +private: + // this filtering is rather cringe but idk what a better alternative would be + bool ListFilterFunc(Gtk::ListBoxRow *row_); + + enum FilterMode { + FILTER_PUBLIC = 0, + FILTER_PRIVATE = 1, + }; + bool m_filter_mode = FILTER_PUBLIC; + + Snowflake m_channel_id; + + Gtk::StackSwitcher m_switcher; + Gtk::Stack m_stack; + + Gtk::RadioButtonGroup m_group; + Gtk::ButtonBox m_filter_buttons; + Gtk::RadioButton m_filter_public; + Gtk::RadioButton m_filter_private; + + Gtk::Box m_box; + + ActiveThreadsList m_active; + ArchivedThreadsList m_archived; +}; + +class ThreadListRow : public Gtk::ListBoxRow { +public: + ThreadListRow(const ChannelData &channel); + + Snowflake ID; + ChannelType Type; + +private: + Gtk::Label m_label; +}; -- cgit v1.2.3 From 4326c5e29b279ba8ca58139848aaea4e3c62fb03 Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Wed, 24 Nov 2021 03:14:41 -0500 Subject: remove SimpleIni as a dependency use Glib::KeyFile instead which is basically the same file format also read into and save from struct once, cuz its faster and less redundant --- .gitmodules | 3 - CMakeLists.txt | 7 -- README.md | 13 ++- cmake/Findsimpleini.cmake | 15 --- src/abaddon.cpp | 33 +++--- src/abaddon.hpp | 5 +- src/components/channels.cpp | 14 +-- src/components/chatmessage.cpp | 19 +-- src/components/friendslist.cpp | 3 +- src/components/memberlist.cpp | 6 +- src/dialogs/friendpicker.cpp | 2 +- src/discord/discord.cpp | 6 +- src/filecache.cpp | 2 +- src/settings.cpp | 188 +++++++++++++++--------------- src/settings.hpp | 87 +++++++------- src/windows/guildsettings/emojispane.cpp | 3 +- src/windows/guildsettings/infopane.cpp | 2 +- src/windows/guildsettings/memberspane.cpp | 5 +- src/windows/profile/mutualfriendspane.cpp | 3 +- src/windows/profile/mutualguildspane.cpp | 3 +- src/windows/profilewindow.cpp | 3 +- subprojects/simpleini | 1 - 22 files changed, 187 insertions(+), 236 deletions(-) delete mode 100644 cmake/Findsimpleini.cmake delete mode 160000 subprojects/simpleini (limited to 'src') diff --git a/.gitmodules b/.gitmodules index 412fb9a..65a1996 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,9 +7,6 @@ [submodule "ci/gtk-for-windows"] path = ci/gtk-for-windows url = https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer -[submodule "subprojects/simpleini"] - path = subprojects/simpleini - url = https://github.com/brofield/simpleini [submodule "subprojects/ixwebsocket"] path = subprojects/ixwebsocket url = https://github.com/machinezone/ixwebsocket diff --git a/CMakeLists.txt b/CMakeLists.txt index d27980b..fa56d6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,13 +22,6 @@ if (NOT IXWebSocket_FOUND) include_directories(IXWEBSOCKET_INCLUDE_DIRS) endif() -add_compile_definitions(SI_NO_CONVERSION) # only CSimpleIniA is used -find_package(simpleini QUIET) -if (NOT simpleini_FOUND) - message("simpleini was not found and will be included as a submodule") - include_directories(subprojects/simpleini) -endif() - if(MINGW OR WIN32) link_libraries(ws2_32) endif() diff --git a/README.md b/README.md index 7a890a8..5274c0a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Current features: ### Building manually (recommended if not on Windows): #### Windows: 1. `git clone https://github.com/uowuo/abaddon && cd abaddon` -2. `vcpkg install gtkmm:x64-windows nlohmann-json:x64-windows ixwebsocket:x64-windows zlib:x64-windows simpleini:x64-windows sqlite3:x64-windows openssl:x64-windows curl:x64-windows` +2. `vcpkg install gtkmm:x64-windows nlohmann-json:x64-windows ixwebsocket:x64-windows zlib:x64-windows sqlite3:x64-windows openssl:x64-windows curl:x64-windows` 3. `mkdir build && cd build` 4. `cmake -G"Visual Studio 16 2019" -A x64 -DCMAKE_TOOLCHAIN_FILE=c:\path\to\vcpkg\scripts\buildsystems\vcpkg.cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVCPKG_TARGET_TRIPLET=x64-windows ..` 5. Build with Visual Studio @@ -75,7 +75,6 @@ On Linux, `css` and `res` can also be loaded from `~/.local/share/abaddon` or `/ * [IXWebSocket](https://github.com/machinezone/IXWebSocket) * [libcurl](https://curl.se/) * [zlib](https://zlib.net/) -* [simpleini](https://github.com/brofield/simpleini) * [SQLite3](https://www.sqlite.org/index.html) ### TODO: @@ -178,18 +177,24 @@ Used in profile popup: ### Settings Settings are configured (for now) by editing abaddon.ini +The format is similar to the standard Windows ini format **except**: +* `#` is used to begin comments as opposed to `;` +* Section and key names are case-sensitive + You should edit these while the client is closed even though there's an option to reload while running This listing is organized by section. For example, memory_db would be set by adding `memory_db = true` under the line `[discord]` #### discord +* gateway (string) - override url for Discord gateway. must be json format and use zlib stream compression +* api_base (string) - override base url for Discord API * memory_db (true or false, default false) - if true, Discord data will be kept in memory as opposed to on disk * token (string) - Discord token used to login, this can be set from the menu * prefetch (true or false, default false) - if true, new messages will cause the avatar and image attachments to be automatically downloaded #### http * user_agent (string) - sets the user-agent to use in HTTP requests to the Discord API (not including media/images) -* concurrent (int, default 10) - how many images can be concurrently retrieved +* concurrent (int, default 20) - how many images can be concurrently retrieved #### gui * member_list_discriminator (true or false, default true) - show user discriminators in the member list @@ -199,8 +204,6 @@ For example, memory_db would be set by adding `memory_db = true` under the line * animations (true or false, default true) - use animated images where available (e.g. server icons, emojis, avatars). false means static images will be used * animated_guild_hover_only (true or false, default true) - only animate guild icons when the guild is being hovered over * owner_crown (true or false, default true) - show a crown next to the owner -* gateway (string) - override url for Discord gateway. must be json format and use zlib stream compression -* api_base (string) - override base url for Discord API #### style * linkcolor (string) - color to use for links in messages diff --git a/cmake/Findsimpleini.cmake b/cmake/Findsimpleini.cmake deleted file mode 100644 index fa6598a..0000000 --- a/cmake/Findsimpleini.cmake +++ /dev/null @@ -1,15 +0,0 @@ -set(simpleini_LIBRARY_NAME simpleini) - -find_path(simpleini_INCLUDE_DIR - NAMES SimpleIni.h - HINTS /usr/include - /usr/local/include - /opt/local/include - PATH_SUFFIXES ${simpleini_LIBRARY_NAME}) - -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(simpleini - REQUIRED_VARS - simpleini_INCLUDE_DIR) - -mark_as_advanced(simpleini_INCLUDE_DIR) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index f0f8574..f6c9ef5 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -23,12 +23,12 @@ Abaddon::Abaddon() : m_settings(Platform::FindConfigFile()) - , m_discord(m_settings.GetUseMemoryDB()) // stupid but easy + , m_discord(GetSettings().UseMemoryDB) // stupid but easy , m_emojis(GetResPath("/emojis.bin")) { LoadFromSettings(); // todo: set user agent for non-client(?) - std::string ua = m_settings.GetUserAgent(); + std::string ua = GetSettings().UserAgent; m_discord.SetUserAgent(ua); m_discord.signal_gateway_ready().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnReady)); @@ -43,7 +43,7 @@ Abaddon::Abaddon() m_discord.signal_thread_update().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnThreadUpdate)); m_discord.signal_message_sent().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnMessageSent)); m_discord.signal_disconnected().connect(sigc::mem_fun(*this, &Abaddon::DiscordOnDisconnect)); - if (m_settings.GetPrefetch()) + if (GetSettings().Prefetch) m_discord.signal_message_create().connect([this](const Message &message) { if (message.Author.HasAvatar()) m_img_mgr.Prefetch(message.Author.GetAvatarURL()); @@ -54,10 +54,6 @@ Abaddon::Abaddon() }); } -Abaddon::~Abaddon() { - m_settings.Close(); -} - Abaddon &Abaddon::Get() { static Abaddon instance; return instance; @@ -85,7 +81,7 @@ int Abaddon::StartGTK() { m_main_window->set_position(Gtk::WIN_POS_CENTER); if (!m_settings.IsValid()) { - Gtk::MessageDialog dlg(*m_main_window, "The settings file could not be created!", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + Gtk::MessageDialog dlg(*m_main_window, "The settings file could not be opened!", false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); dlg.set_position(Gtk::WIN_POS_CENTER); dlg.run(); } @@ -133,14 +129,19 @@ int Abaddon::StartGTK() { ActionReloadCSS(); - m_gtk_app->signal_shutdown().connect(sigc::mem_fun(*this, &Abaddon::StopDiscord), false); + m_gtk_app->signal_shutdown().connect(sigc::mem_fun(*this, &Abaddon::OnShutdown), false); m_main_window->show(); return m_gtk_app->run(*m_main_window); } +void Abaddon::OnShutdown() { + StopDiscord(); + m_settings.Close(); +} + void Abaddon::LoadFromSettings() { - std::string token = m_settings.GetDiscordToken(); + std::string token = GetSettings().DiscordToken; if (token.size()) { m_discord_token = token; m_discord.UpdateToken(m_discord_token); @@ -248,8 +249,8 @@ void Abaddon::DiscordOnThreadUpdate(const ThreadUpdateData &data) { } } -const SettingsManager &Abaddon::GetSettings() const { - return m_settings; +SettingsManager::Settings &Abaddon::GetSettings() { + return m_settings.GetSettings(); } Glib::RefPtr Abaddon::GetStyleProvider() { @@ -367,7 +368,7 @@ void Abaddon::SetupUserMenu() { } void Abaddon::SaveState() { - if (!m_settings.GetSaveState()) return; + if (!GetSettings().SaveState) return; AbaddonApplicationState state; state.ActiveChannel = m_main_window->GetChatActiveChannel(); @@ -387,7 +388,7 @@ void Abaddon::SaveState() { } void Abaddon::LoadState() { - if (!m_settings.GetSaveState()) return; + if (!GetSettings().SaveState) return; const auto data = ReadWholeFile(GetStateCachePath("/state.json")); if (data.empty()) return; @@ -491,7 +492,7 @@ void Abaddon::ActionSetToken() { m_discord_token = dlg.GetToken(); m_discord.UpdateToken(m_discord_token); m_main_window->UpdateComponents(); - m_settings.SetSetting("discord", "token", m_discord_token); + GetSettings().DiscordToken = m_discord_token; } } @@ -698,7 +699,7 @@ bool Abaddon::ShowConfirm(const Glib::ustring &prompt, Gtk::Window *window) { void Abaddon::ActionReloadCSS() { try { Gtk::StyleContext::remove_provider_for_screen(Gdk::Screen::get_default(), m_css_provider); - m_css_provider->load_from_path(GetCSSPath("/" + m_settings.GetMainCSS())); + m_css_provider->load_from_path(GetCSSPath("/" + GetSettings().MainCSS)); Gtk::StyleContext::add_provider_for_screen(Gdk::Screen::get_default(), m_css_provider, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); Gtk::StyleContext::remove_provider_for_screen(Gdk::Screen::get_default(), m_css_low_provider); diff --git a/src/abaddon.hpp b/src/abaddon.hpp index 0fb4f1f..d9d0bb0 100644 --- a/src/abaddon.hpp +++ b/src/abaddon.hpp @@ -14,7 +14,6 @@ class Abaddon { private: Abaddon(); - ~Abaddon(); Abaddon(const Abaddon &) = delete; Abaddon &operator=(const Abaddon &) = delete; Abaddon(Abaddon &&) = delete; @@ -24,6 +23,8 @@ public: static Abaddon &Get(); int StartGTK(); + void OnShutdown(); + void StartDiscord(); void StopDiscord(); @@ -74,7 +75,7 @@ public: void DiscordOnDisconnect(bool is_reconnecting, GatewayCloseCode close_code); void DiscordOnThreadUpdate(const ThreadUpdateData &data); - const SettingsManager &GetSettings() const; + SettingsManager::Settings &GetSettings(); Glib::RefPtr GetStyleProvider(); diff --git a/src/components/channels.cpp b/src/components/channels.cpp index da31de0..6d5e1a6 100644 --- a/src/components/channels.cpp +++ b/src/components/channels.cpp @@ -263,11 +263,9 @@ void ChannelList::UpdateGuild(Snowflake id) { const auto guild = Abaddon::Get().GetDiscordClient().GetGuild(id); if (!iter || !guild.has_value()) return; - static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); - (*iter)[m_columns.m_name] = "" + Glib::Markup::escape_text(guild->Name) + ""; (*iter)[m_columns.m_icon] = img.GetPlaceholder(GuildIconSize); - if (show_animations && guild->HasAnimatedIcon()) { + if (Abaddon::Get().GetSettings().ShowAnimations && guild->HasAnimatedIcon()) { const auto cb = [this, id](const Glib::RefPtr &pb) { auto iter = GetIteratorForGuildFromID(id); if (iter) (*iter)[m_columns.m_icon_anim] = pb; @@ -436,9 +434,7 @@ Gtk::TreeModel::iterator ChannelList::AddGuild(const GuildData &guild) { guild_row[m_columns.m_name] = "" + Glib::Markup::escape_text(guild.Name) + ""; guild_row[m_columns.m_icon] = img.GetPlaceholder(GuildIconSize); - static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); - - if (show_animations && guild.HasAnimatedIcon()) { + if (Abaddon::Get().GetSettings().ShowAnimations && guild.HasAnimatedIcon()) { const auto cb = [this, id = guild.ID](const Glib::RefPtr &pb) { auto iter = GetIteratorForGuildFromID(id); if (iter) (*iter)[m_columns.m_icon_anim] = pb; @@ -998,7 +994,7 @@ void CellRendererChannels::render_vfunc_guild(const Cairo::RefPtrmove_to(x1, y1); cr->line_to(x2, y2); cr->line_to(x3, y3); - static const auto expander_color = Gdk::RGBA(Abaddon::Get().GetSettings().GetChannelsExpanderColor()); + const auto expander_color = Gdk::RGBA(Abaddon::Get().GetSettings().ChannelsExpanderColor); cr->set_source_rgb(expander_color.get_red(), expander_color.get_green(), expander_color.get_blue()); cr->stroke(); @@ -1115,7 +1111,7 @@ void CellRendererChannels::render_vfunc_channel(const Cairo::RefPtroverride_color(Gdk::RGBA(color)); title_label->set_markup("" + Glib::Markup::escape_text(*embed.Title) + ""); } @@ -798,7 +798,6 @@ void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) { int mstart, mend; if (!match.fetch_pos(0, mstart, mend)) break; const bool is_animated = match.fetch(0)[1] == 'a'; - const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend); @@ -806,7 +805,7 @@ void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) { auto end_it = buf->get_iter_at_offset(chars_end); startpos = mend; - if (is_animated && show_animations) { + if (is_animated && Abaddon::Get().GetSettings().ShowAnimations) { const auto mark_start = buf->create_mark(start_it, false); end_it.backward_char(); const auto mark_end = buf->create_mark(end_it, false); @@ -845,11 +844,8 @@ void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) { } void ChatMessageItemContainer::HandleEmojis(Gtk::TextView &tv) { - static const bool stock_emojis = Abaddon::Get().GetSettings().GetShowStockEmojis(); - static const bool custom_emojis = Abaddon::Get().GetSettings().GetShowCustomEmojis(); - - if (stock_emojis) HandleStockEmojis(tv); - if (custom_emojis) HandleCustomEmojis(tv); + if (Abaddon::Get().GetSettings().ShowStockEmojis) HandleStockEmojis(tv); + if (Abaddon::Get().GetSettings().ShowCustomEmojis) HandleCustomEmojis(tv); } void ChatMessageItemContainer::CleanupEmojis(Glib::RefPtr buf) { @@ -969,9 +965,6 @@ void ChatMessageItemContainer::HandleLinks(Gtk::TextView &tv) { auto buf = tv.get_buffer(); Glib::ustring text = GetText(buf); - // i'd like to let this be done thru css like .message-link { color: #bitch; } but idk how - static auto link_color = Abaddon::Get().GetSettings().GetLinkColor(); - int startpos = 0; Glib::MatchInfo match; while (rgx->match(text, startpos, match)) { @@ -980,7 +973,7 @@ void ChatMessageItemContainer::HandleLinks(Gtk::TextView &tv) { std::string link = match.fetch(0); auto tag = buf->create_tag(); m_link_tagmap[tag] = link; - tag->property_foreground_rgba() = Gdk::RGBA(link_color); + tag->property_foreground_rgba() = Gdk::RGBA(Abaddon::Get().GetSettings().LinkColor); tag->set_property("underline", 1); // stupid workaround for vcpkg bug (i think) const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart); @@ -1138,7 +1131,7 @@ ChatMessageHeader::ChatMessageHeader(const Message &data) m_content_box_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); m_meta_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); m_avatar_ev.add_events(Gdk::ENTER_NOTIFY_MASK | Gdk::LEAVE_NOTIFY_MASK); - if (Abaddon::Get().GetSettings().GetShowAnimations()) { + if (Abaddon::Get().GetSettings().ShowAnimations) { m_content_box_ev.signal_enter_notify_event().connect(on_enter_cb); m_content_box_ev.signal_leave_notify_event().connect(on_leave_cb); m_meta_ev.signal_enter_notify_event().connect(on_enter_cb); diff --git a/src/components/friendslist.cpp b/src/components/friendslist.cpp index 3896f02..1331d19 100644 --- a/src/components/friendslist.cpp +++ b/src/components/friendslist.cpp @@ -257,8 +257,7 @@ FriendsListFriendRow::FriendsListFriendRow(RelationshipType type, const UserData auto &discord = Abaddon::Get().GetDiscordClient(); discord.signal_presence_update().connect(sigc::mem_fun(*this, &FriendsListFriendRow::OnPresenceUpdate)); - static bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); - if (data.HasAnimatedAvatar() && show_animations) { + if (data.HasAnimatedAvatar() && Abaddon::Get().GetSettings().ShowAnimations) { img->SetAnimated(true); img->SetURL(data.GetAvatarURL("gif", "32")); } else { diff --git a/src/components/memberlist.cpp b/src/components/memberlist.cpp index 0c4d9bc..4f21700 100644 --- a/src/components/memberlist.cpp +++ b/src/components/memberlist.cpp @@ -14,8 +14,7 @@ MemberListUserRow::MemberListUserRow(const std::optional &guild, cons m_avatar = Gtk::manage(new LazyImage(16, 16)); m_status_indicator = Gtk::manage(new StatusIndicator(ID)); - static bool crown = Abaddon::Get().GetSettings().GetShowOwnerCrown(); - if (crown && guild.has_value() && guild->OwnerID == data.ID) { + if (Abaddon::Get().GetSettings().ShowOwnerCrown && guild.has_value() && guild->OwnerID == data.ID) { try { const static auto crown_path = Abaddon::GetResPath("/crown.png"); auto pixbuf = Gdk::Pixbuf::create_from_file(crown_path, 12, 12); @@ -40,9 +39,8 @@ MemberListUserRow::MemberListUserRow(const std::optional &guild, cons m_label->set_single_line_mode(true); m_label->set_ellipsize(Pango::ELLIPSIZE_END); - static bool show_discriminator = Abaddon::Get().GetSettings().GetShowMemberListDiscriminators(); std::string display = data.Username; - if (show_discriminator) + if (Abaddon::Get().GetSettings().ShowMemberListDiscriminators) display += "#" + data.Discriminator; if (guild.has_value()) { if (const auto col_id = data.GetHoistedRole(guild->ID, true); col_id.IsValid()) { diff --git a/src/dialogs/friendpicker.cpp b/src/dialogs/friendpicker.cpp index fc099aa..476e5f6 100644 --- a/src/dialogs/friendpicker.cpp +++ b/src/dialogs/friendpicker.cpp @@ -67,7 +67,7 @@ FriendPickerDialogItem::FriendPickerDialogItem(Snowflake user_id) m_name.set_single_line_mode(true); m_avatar.property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(32); - if (user.HasAnimatedAvatar() && Abaddon::Get().GetSettings().GetShowAnimations()) { + if (user.HasAnimatedAvatar() && Abaddon::Get().GetSettings().ShowAnimations) { auto cb = [this](const Glib::RefPtr &pb) { m_avatar.property_pixbuf_animation() = pb; }; diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index bed959f..f920099 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -1303,13 +1303,11 @@ void DiscordClient::HandleGatewayHello(const GatewayMessage &msg) { // perhaps this should be set by the main class std::string DiscordClient::GetAPIURL() { - static const auto url = Abaddon::Get().GetSettings().GetAPIBaseURL(); - return url; + return Abaddon::Get().GetSettings().APIBaseURL; } std::string DiscordClient::GetGatewayURL() { - static const auto url = Abaddon::Get().GetSettings().GetGatewayURL(); - return url; + return Abaddon::Get().GetSettings().GatewayURL; } DiscordError DiscordClient::GetCodeFromResponse(const http::response_type &response) { diff --git a/src/filecache.cpp b/src/filecache.cpp index a731750..e04fbcb 100644 --- a/src/filecache.cpp +++ b/src/filecache.cpp @@ -142,7 +142,7 @@ void FileCacheWorkerThread::loop() { m_cv.wait(lock); } - static const auto concurrency = static_cast(Abaddon::Get().GetSettings().GetCacheHTTPConcurrency()); + static const auto concurrency = static_cast(Abaddon::Get().GetSettings().CacheHTTPConcurrency); if (m_handles.size() < concurrency) { std::optional entry; m_queue_mutex.lock(); diff --git a/src/settings.cpp b/src/settings.cpp index 0a7dbb7..beef624 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2,7 +2,7 @@ #include #include -SettingsManager::SettingsManager(std::string filename) +SettingsManager::SettingsManager(std::string_view filename) : m_filename(filename) { if (!std::filesystem::exists(filename)) { std::fstream fs; @@ -10,106 +10,106 @@ SettingsManager::SettingsManager(std::string filename) fs.close(); } - auto rc = m_ini.LoadFile(filename.c_str()); - m_ok = rc == SI_OK; -} - -void SettingsManager::Reload() { - m_ok = m_ini.LoadFile(m_filename.c_str()) == SI_OK; -} - -std::string SettingsManager::GetSettingString(const std::string §ion, const std::string &key, std::string fallback) const { - return m_ini.GetValue(section.c_str(), key.c_str(), fallback.c_str()); -} - -int SettingsManager::GetSettingInt(const std::string §ion, const std::string &key, int fallback) const { - return std::stoul(GetSettingString(section, key, std::to_string(fallback))); -} + try { + m_ok = m_file.load_from_file(m_filename, Glib::KEY_FILE_KEEP_COMMENTS); + } catch (const Glib::Error &e) { + fprintf(stderr, "error opening settings KeyFile: %s\n", e.what().c_str()); + m_ok = false; + } -bool SettingsManager::GetSettingBool(const std::string §ion, const std::string &key, bool fallback) const { - return GetSettingString(section, key, fallback ? "true" : "false") != "false"; + if (m_ok) ReadSettings(); +} + +void SettingsManager::ReadSettings() { +#define SMBOOL(section, key, var) \ + try { \ + m_settings.var = m_file.get_boolean(section, key); \ + } catch (...) {} +#define SMSTR(section, key, var) \ + try { \ + m_settings.var = m_file.get_string(section, key); \ + } catch (...) {} +#define SMINT(section, key, var) \ + try { \ + m_settings.var = m_file.get_integer(section, key); \ + } catch (...) {} + + SMSTR("discord", "api_base", APIBaseURL); + SMSTR("discord", "gateway", GatewayURL); + SMSTR("discord", "token", DiscordToken); + SMBOOL("discord", "memory_db", UseMemoryDB); + SMBOOL("discord", "prefetch", Prefetch); + SMSTR("gui", "css", MainCSS); + SMBOOL("gui", "animated_guild_hover_only", AnimatedGuildHoverOnly); + SMBOOL("gui", "animations", ShowAnimations); + SMBOOL("gui", "custom_emojis", ShowCustomEmojis); + SMBOOL("gui", "member_list_discriminator", ShowMemberListDiscriminators); + SMBOOL("gui", "owner_crown", ShowOwnerCrown); + SMBOOL("gui", "save_state", SaveState); + SMBOOL("gui", "stock_emojis", ShowStockEmojis); + SMINT("http", "concurrent", CacheHTTPConcurrency); + SMSTR("http", "user_agent", UserAgent); + SMSTR("style", "expandercolor", ChannelsExpanderColor); + SMSTR("style", "linkcolor", LinkColor); + SMSTR("style", "nsfwchannelcolor", NSFWChannelColor); + +#undef SMBOOL +#undef SMSTR +#undef SMINT + + m_read_settings = m_settings; } bool SettingsManager::IsValid() const { return m_ok; } -void SettingsManager::Close() { - m_ini.SaveFile(m_filename.c_str()); -} - -bool SettingsManager::GetUseMemoryDB() const { - return GetSettingBool("discord", "memory_db", false); -} - -std::string SettingsManager::GetUserAgent() const { - return GetSettingString("http", "user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36"); -} - -std::string SettingsManager::GetDiscordToken() const { - return GetSettingString("discord", "token"); -} - -bool SettingsManager::GetShowMemberListDiscriminators() const { - return GetSettingBool("gui", "member_list_discriminator", true); -} - -bool SettingsManager::GetShowStockEmojis() const { -#ifdef _WIN32 - return GetSettingBool("gui", "stock_emojis", false); -#else - return GetSettingBool("gui", "stock_emojis", true); -#endif -} - -bool SettingsManager::GetShowCustomEmojis() const { - return GetSettingBool("gui", "custom_emojis", true); -} - -std::string SettingsManager::GetLinkColor() const { - return GetSettingString("style", "linkcolor", "rgba(40, 200, 180, 255)"); +SettingsManager::Settings &SettingsManager::GetSettings() { + return m_settings; } -std::string SettingsManager::GetChannelsExpanderColor() const { - return GetSettingString("style", "expandercolor", "rgba(255, 83, 112, 255)"); -} - -std::string SettingsManager::GetNSFWChannelColor() const { - return GetSettingString("style", "nsfwchannelcolor", "#ed6666"); -} - -int SettingsManager::GetCacheHTTPConcurrency() const { - return GetSettingInt("http", "concurrent", 20); -} - -bool SettingsManager::GetPrefetch() const { - return GetSettingBool("discord", "prefetch", false); -} - -std::string SettingsManager::GetMainCSS() const { - return GetSettingString("gui", "css", "main.css"); -} - -bool SettingsManager::GetShowAnimations() const { - return GetSettingBool("gui", "animations", true); -} - -bool SettingsManager::GetShowOwnerCrown() const { - return GetSettingBool("gui", "owner_crown", true); -} - -std::string SettingsManager::GetGatewayURL() const { - return GetSettingString("discord", "gateway", "wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream"); -} - -std::string SettingsManager::GetAPIBaseURL() const { - return GetSettingString("discord", "api_base", "https://discord.com/api/v9"); -} - -bool SettingsManager::GetAnimatedGuildHoverOnly() const { - return GetSettingBool("gui", "animated_guild_hover_only", true); -} - -bool SettingsManager::GetSaveState() const { - return GetSettingBool("gui", "save_state", true); +void SettingsManager::Close() { + if (m_ok) { + // save anything that changed + // (futureproofing since only DiscordToken can actually change) +#define SMSTR(section, key, var) \ + if (m_settings.var != m_read_settings.var) \ + m_file.set_string(section, key, m_settings.var); +#define SMBOOL(section, key, var) \ + if (m_settings.var != m_read_settings.var) \ + m_file.set_boolean(section, key, m_settings.var); +#define SMINT(section, key, var) \ + if (m_settings.var != m_read_settings.var) \ + m_file.set_integer(section, key, m_settings.var); + + SMSTR("discord", "api_base", APIBaseURL); + SMSTR("discord", "gateway", GatewayURL); + SMSTR("discord", "token", DiscordToken); + SMBOOL("discord", "memory_db", UseMemoryDB); + SMBOOL("discord", "prefetch", Prefetch); + SMSTR("gui", "css", MainCSS); + SMBOOL("gui", "animated_guild_hover_only", AnimatedGuildHoverOnly); + SMBOOL("gui", "animations", ShowAnimations); + SMBOOL("gui", "custom_emojis", ShowCustomEmojis); + SMBOOL("gui", "member_list_discriminator", ShowMemberListDiscriminators); + SMBOOL("gui", "owner_crown", ShowOwnerCrown); + SMBOOL("gui", "save_state", SaveState); + SMBOOL("gui", "stock_emojis", ShowStockEmojis); + SMINT("http", "concurrent", CacheHTTPConcurrency); + SMSTR("http", "user_agent", UserAgent); + SMSTR("style", "expandercolor", ChannelsExpanderColor); + SMSTR("style", "linkcolor", LinkColor); + SMSTR("style", "nsfwchannelcolor", NSFWChannelColor); + +#undef SMSTR +#undef SMBOOL +#undef SMINT + + try { + if (!m_file.save_to_file(m_filename)) + fputs("failed to save settings KeyFile", stderr); + } catch (const Glib::Error &e) { + fprintf(stderr, "failed to save settings KeyFile: %s\n", e.what().c_str()); + } + } } diff --git a/src/settings.hpp b/src/settings.hpp index 3fff593..1192861 100644 --- a/src/settings.hpp +++ b/src/settings.hpp @@ -1,62 +1,55 @@ #pragma once #include #include -#include +#include class SettingsManager { public: - SettingsManager(std::string filename); - void Reload(); + struct Settings { + // [discord] + std::string APIBaseURL { "https://discord.com/api/v9" }; + std::string GatewayURL { "wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream" }; + std::string DiscordToken; + bool UseMemoryDB { false }; + bool Prefetch { false }; + + // [gui] + std::string MainCSS { "main.css" }; + bool AnimatedGuildHoverOnly { true }; + bool ShowAnimations { true }; + bool ShowCustomEmojis { true }; + bool ShowMemberListDiscriminators { true }; + bool ShowOwnerCrown { true }; + bool SaveState { true }; +#ifdef _WIN32 + bool ShowStockEmojis { false }; +#else + bool ShowStockEmojis { true }; +#endif + + // [http] + int CacheHTTPConcurrency { 20 }; + std::string UserAgent { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Safari/537.36" }; + + // [style] + // TODO: convert to StyleProperty + std::string LinkColor { "rgba(40, 200, 180, 255)" }; + std::string ChannelsExpanderColor { "rgba(255, 83, 112, 255)" }; + std::string NSFWChannelColor { "#ed6666" }; + }; + + SettingsManager(std::string_view filename); void Close(); - bool GetUseMemoryDB() const; - std::string GetUserAgent() const; - std::string GetDiscordToken() const; - bool GetShowMemberListDiscriminators() const; - bool GetShowStockEmojis() const; - bool GetShowCustomEmojis() const; - int GetCacheHTTPConcurrency() const; - bool GetPrefetch() const; - std::string GetMainCSS() const; - bool GetShowAnimations() const; - bool GetShowOwnerCrown() const; - std::string GetGatewayURL() const; - std::string GetAPIBaseURL() const; - bool GetAnimatedGuildHoverOnly() const; - bool GetSaveState() const; - - // i would like to use Gtk::StyleProperty for this, but it will not work on windows - // #1 it's missing from the project files for the version used by vcpkg - // #2 it's still broken and doesn't function even when added to the solution - // #3 it's a massive pain in the ass to try and bump the version to a functioning version - // because they switch build systems to nmake/meson (took months to get merged in vcpkg) - // #4 c++ build systems sucks - // three options are: use gtk4 with updated vcpkg, try and port it myself, or use msys2 instead of vcpkg - // im leaning towards msys - std::string GetLinkColor() const; - std::string GetChannelsExpanderColor() const; - std::string GetNSFWChannelColor() const; - bool IsValid() const; - - template - void SetSetting(std::string section, std::string key, T value) { - m_ini.SetValue(section.c_str(), key.c_str(), std::to_string(value).c_str()); - m_ini.SaveFile(m_filename.c_str()); - } - - void SetSetting(std::string section, std::string key, std::string value) { - m_ini.SetValue(section.c_str(), key.c_str(), value.c_str()); - m_ini.SaveFile(m_filename.c_str()); - } + Settings &GetSettings(); private: - std::string GetSettingString(const std::string §ion, const std::string &key, std::string fallback = "") const; - int GetSettingInt(const std::string §ion, const std::string &key, int fallback) const; - bool GetSettingBool(const std::string §ion, const std::string &key, bool fallback) const; + void ReadSettings(); -private: bool m_ok; std::string m_filename; - CSimpleIniA m_ini; + Glib::KeyFile m_file; + Settings m_settings; + Settings m_read_settings; }; diff --git a/src/windows/guildsettings/emojispane.cpp b/src/windows/guildsettings/emojispane.cpp index 1f4bfa9..57b697c 100644 --- a/src/windows/guildsettings/emojispane.cpp +++ b/src/windows/guildsettings/emojispane.cpp @@ -130,8 +130,7 @@ void GuildSettingsEmojisPane::AddEmojiRow(const EmojiData &emoji) { else row[m_columns.m_col_available] = "Yes"; - static bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); - if (show_animations && emoji.IsAnimated.has_value() && *emoji.IsAnimated) { + if (Abaddon::Get().GetSettings().ShowAnimations && emoji.IsAnimated.has_value() && *emoji.IsAnimated) { const auto cb = [this, id = emoji.ID](const Glib::RefPtr &pb) { for (auto &row : m_model->children()) { if (static_cast(row[m_columns.m_col_id]) == id) { diff --git a/src/windows/guildsettings/infopane.cpp b/src/windows/guildsettings/infopane.cpp index b4f75f3..9ef116f 100644 --- a/src/windows/guildsettings/infopane.cpp +++ b/src/windows/guildsettings/infopane.cpp @@ -81,7 +81,7 @@ GuildSettingsInfoPane::GuildSettingsInfoPane(Snowflake id) void GuildSettingsInfoPane::FetchGuildIcon(const GuildData &guild) { m_guild_icon.property_pixbuf() = Abaddon::Get().GetImageManager().GetPlaceholder(32); if (guild.HasIcon()) { - if (Abaddon::Get().GetSettings().GetShowAnimations() && guild.HasAnimatedIcon()) { + if (Abaddon::Get().GetSettings().ShowAnimations && guild.HasAnimatedIcon()) { auto cb = [this](const Glib::RefPtr &pixbuf) { m_guild_icon.property_pixbuf_animation() = pixbuf; }; diff --git a/src/windows/guildsettings/memberspane.cpp b/src/windows/guildsettings/memberspane.cpp index 36c5c0b..9dc76d3 100644 --- a/src/windows/guildsettings/memberspane.cpp +++ b/src/windows/guildsettings/memberspane.cpp @@ -99,7 +99,7 @@ GuildSettingsMembersListItem::GuildSettingsMembersListItem(const GuildData &guil auto &discord = Abaddon::Get().GetDiscordClient(); - if (member.User->HasAnimatedAvatar() && Abaddon::Get().GetSettings().GetShowAnimations()) + if (member.User->HasAnimatedAvatar() && Abaddon::Get().GetSettings().ShowAnimations) m_avatar.SetURL(member.User->GetAvatarURL("gif", "32")); else m_avatar.SetURL(member.User->GetAvatarURL("png", "32")); @@ -113,8 +113,7 @@ GuildSettingsMembersListItem::GuildSettingsMembersListItem(const GuildData &guil discord.signal_guild_member_update().connect(sigc::track_obj(member_update_cb, *this)); UpdateColor(); - static bool crown = Abaddon::Get().GetSettings().GetShowOwnerCrown(); - if (crown && guild.OwnerID == member.User->ID) { + if (Abaddon::Get().GetSettings().ShowOwnerCrown && guild.OwnerID == member.User->ID) { try { const static auto crown_path = Abaddon::GetResPath("/crown.png"); auto pixbuf = Gdk::Pixbuf::create_from_file(crown_path, 12, 12); diff --git a/src/windows/profile/mutualfriendspane.cpp b/src/windows/profile/mutualfriendspane.cpp index 339fd71..ca36e1d 100644 --- a/src/windows/profile/mutualfriendspane.cpp +++ b/src/windows/profile/mutualfriendspane.cpp @@ -9,10 +9,9 @@ MutualFriendItem::MutualFriendItem(const UserData &user) m_avatar.set_margin_end(10); - const auto show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); auto &img = Abaddon::Get().GetImageManager(); m_avatar.property_pixbuf() = img.GetPlaceholder(24); - if (user.HasAnimatedAvatar() && show_animations) { + if (user.HasAnimatedAvatar() && Abaddon::Get().GetSettings().ShowAnimations) { auto cb = [this](const Glib::RefPtr &pb) { m_avatar.property_pixbuf_animation() = pb; }; diff --git a/src/windows/profile/mutualguildspane.cpp b/src/windows/profile/mutualguildspane.cpp index 6bfdc7b..6c14fc4 100644 --- a/src/windows/profile/mutualguildspane.cpp +++ b/src/windows/profile/mutualguildspane.cpp @@ -13,11 +13,10 @@ MutualGuildItem::MutualGuildItem(const MutualGuildData &guild) // discord will return info (id + nick) for "deleted" guilds from this endpoint. strange ! const auto data = Abaddon::Get().GetDiscordClient().GetGuild(guild.ID); if (data.has_value()) { - const auto show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); auto &img = Abaddon::Get().GetImageManager(); m_icon.property_pixbuf() = img.GetPlaceholder(24); if (data->HasIcon()) { - if (data->HasAnimatedIcon() && show_animations) { + if (data->HasAnimatedIcon() && Abaddon::Get().GetSettings().ShowAnimations) { auto cb = [this](const Glib::RefPtr &pb) { m_icon.property_pixbuf_animation() = pb; }; diff --git a/src/windows/profilewindow.cpp b/src/windows/profilewindow.cpp index 4d41f89..9d93564 100644 --- a/src/windows/profilewindow.cpp +++ b/src/windows/profilewindow.cpp @@ -44,7 +44,6 @@ ProfileWindow::ProfileWindow(Snowflake user_id) return false; }); - static const bool show_animations = Abaddon::Get().GetSettings().GetShowAnimations(); auto &img = Abaddon::Get().GetImageManager(); m_avatar.property_pixbuf() = img.GetPlaceholder(64); auto icon_cb = [this](const Glib::RefPtr &pb) { @@ -52,7 +51,7 @@ ProfileWindow::ProfileWindow(Snowflake user_id) }; img.LoadFromURL(user.GetAvatarURL("png", "64"), sigc::track_obj(icon_cb, *this)); - if (show_animations && user.HasAnimatedAvatar()) { + if (Abaddon::Get().GetSettings().ShowAnimations && user.HasAnimatedAvatar()) { auto cb = [this](const Glib::RefPtr &pb) { m_avatar.property_pixbuf_animation() = pb; }; diff --git a/subprojects/simpleini b/subprojects/simpleini deleted file mode 160000 index 67156f6..0000000 --- a/subprojects/simpleini +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 67156f64b3447ce1eb81d6be44d29132fb49b70a -- cgit v1.2.3 From 8f30bb33a3d79a7207809b35d4a39751e4d53123 Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Wed, 24 Nov 2021 03:29:29 -0500 Subject: fix build --- src/settings.cpp | 2 +- src/settings.hpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/settings.cpp b/src/settings.cpp index beef624..6820ed0 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2,7 +2,7 @@ #include #include -SettingsManager::SettingsManager(std::string_view filename) +SettingsManager::SettingsManager(const std::string &filename) : m_filename(filename) { if (!std::filesystem::exists(filename)) { std::fstream fs; diff --git a/src/settings.hpp b/src/settings.hpp index 1192861..ca2303f 100644 --- a/src/settings.hpp +++ b/src/settings.hpp @@ -38,7 +38,7 @@ public: std::string NSFWChannelColor { "#ed6666" }; }; - SettingsManager(std::string_view filename); + SettingsManager(const std::string &filename); void Close(); bool IsValid() const; -- cgit v1.2.3 From 069c22e9cd37158fa50e3fac67d4d6938d173554 Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Wed, 24 Nov 2021 20:31:34 -0500 Subject: add fetching private archived threads --- src/discord/discord.cpp | 13 +++++++++++++ src/discord/discord.hpp | 1 + src/windows/threadswindow.cpp | 5 +++-- src/windows/threadswindow.hpp | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index f920099..a90fc50 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -292,6 +292,19 @@ void DiscordClient::GetArchivedPublicThreads(Snowflake channel_id, sigc::slot callback) { + m_http.MakeGET("/channels/" + std::to_string(channel_id) + "/users/@me/threads/archived/private", [this, callback](const http::response_type &r) { + if (CheckCode(r)) { + const auto data = nlohmann::json::parse(r.text).get(); + for (const auto &thread : data.Threads) + m_store.SetChannel(thread.ID, thread); + callback(DiscordError::NONE, data); + } else { + callback(GetCodeFromResponse(r), {}); + } + }); +} + bool DiscordClient::IsThreadJoined(Snowflake thread_id) const { return std::find(m_joined_threads.begin(), m_joined_threads.end(), thread_id) != m_joined_threads.end(); } diff --git a/src/discord/discord.hpp b/src/discord/discord.hpp index 4b9bc82..4010977 100644 --- a/src/discord/discord.hpp +++ b/src/discord/discord.hpp @@ -81,6 +81,7 @@ public: std::vector GetUsersInThread(Snowflake id) const; std::vector GetActiveThreads(Snowflake channel_id) const; void GetArchivedPublicThreads(Snowflake channel_id, sigc::slot callback); + void GetArchivedPrivateThreads(Snowflake channel_id, sigc::slot callback); bool IsThreadJoined(Snowflake thread_id) const; bool HasGuildPermission(Snowflake user_id, Snowflake guild_id, Permission perm) const; diff --git a/src/windows/threadswindow.cpp b/src/windows/threadswindow.cpp index 9071d81..c18819b 100644 --- a/src/windows/threadswindow.cpp +++ b/src/windows/threadswindow.cpp @@ -129,14 +129,15 @@ ArchivedThreadsList::ArchivedThreadsList(const ChannelData &channel, const Gtk:: return false; }); - Abaddon::Get().GetDiscordClient().GetArchivedPublicThreads(channel.ID, sigc::mem_fun(*this, &ArchivedThreadsList::OnPublicFetched)); + Abaddon::Get().GetDiscordClient().GetArchivedPublicThreads(channel.ID, sigc::mem_fun(*this, &ArchivedThreadsList::OnThreadsFetched)); + Abaddon::Get().GetDiscordClient().GetArchivedPrivateThreads(channel.ID, sigc::mem_fun(*this, &ArchivedThreadsList::OnThreadsFetched)); } void ArchivedThreadsList::InvalidateFilter() { m_list.invalidate_filter(); } -void ArchivedThreadsList::OnPublicFetched(DiscordError code, const ArchivedThreadsResponseData &data) { +void ArchivedThreadsList::OnThreadsFetched(DiscordError code, const ArchivedThreadsResponseData &data) { for (const auto &thread : data.Threads) { auto row = Gtk::manage(new ThreadListRow(thread)); row->show(); diff --git a/src/windows/threadswindow.hpp b/src/windows/threadswindow.hpp index 0e42414..ebcbd13 100644 --- a/src/windows/threadswindow.hpp +++ b/src/windows/threadswindow.hpp @@ -27,7 +27,7 @@ public: private: Gtk::ListBox m_list; - void OnPublicFetched(DiscordError code, const ArchivedThreadsResponseData &data); + void OnThreadsFetched(DiscordError code, const ArchivedThreadsResponseData &data); using type_signal_thread_open = sigc::signal; type_signal_thread_open m_signal_thread_open; -- cgit v1.2.3 From fb3d69c5e796512edd72dc4ebc81fdfb32f7d40d Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Wed, 24 Nov 2021 22:36:39 -0500 Subject: bump build number in identify --- src/discord/discord.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index a90fc50..801282c 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -2003,7 +2003,7 @@ void DiscordClient::SendIdentify() { msg.Properties.ReferrerCurrent = ""; msg.Properties.ReferringDomainCurrent = ""; msg.Properties.ReleaseChannel = "stable"; - msg.Properties.ClientBuildNumber = 91734; + msg.Properties.ClientBuildNumber = 105691; msg.Properties.ClientEventSource = ""; msg.Presence.Status = "online"; msg.Presence.Since = 0; -- cgit v1.2.3 From 0da913cd4a76aae1ca41abe5b1a51874c974d3aa Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Thu, 25 Nov 2021 02:32:23 -0500 Subject: remove unnecessary copying left over from debugging --- src/discord/store.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/discord/store.cpp b/src/discord/store.cpp index 9b615fd..63fb5f7 100644 --- a/src/discord/store.cpp +++ b/src/discord/store.cpp @@ -2159,8 +2159,7 @@ Store::Database::type_signal_close Store::Database::signal_close() { Store::Statement::Statement(Database &db, const char *command) : m_db(&db) { if (m_db->SetError(sqlite3_prepare_v2(m_db->obj(), command, -1, &m_stmt, nullptr)) != SQLITE_OK) return; - std::string tmp = command; - m_db->signal_close().connect([tmp, this] { + m_db->signal_close().connect([this] { sqlite3_finalize(m_stmt); m_stmt = nullptr; }); -- cgit v1.2.3 From 8c72d4c18d9c27ce1b5cd20f0cb98e5638c8becf Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Thu, 25 Nov 2021 02:57:11 -0500 Subject: dont print identify message to console mainly since i feel its only a matter of time before someone copy pastes it somewhere and itd be my fault also typedef -> using --- src/discord/discord.cpp | 3 +++ src/discord/websocket.cpp | 11 ++++++++++- src/discord/websocket.hpp | 11 ++++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index 801282c..83db97b 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -2012,7 +2012,10 @@ void DiscordClient::SendIdentify() { msg.ClientState.HighestLastMessageID = "0"; msg.ClientState.ReadStateVersion = 0; msg.ClientState.UserGuildSettingsVersion = -1; + const bool b = m_websocket.GetPrintMessages(); + m_websocket.SetPrintMessages(false); m_websocket.Send(msg); + m_websocket.SetPrintMessages(b); } void DiscordClient::SendResume() { diff --git a/src/discord/websocket.cpp b/src/discord/websocket.cpp index ff50cd3..c7e43e9 100644 --- a/src/discord/websocket.cpp +++ b/src/discord/websocket.cpp @@ -15,6 +15,14 @@ void Websocket::SetUserAgent(std::string agent) { m_agent = agent; } +bool Websocket::GetPrintMessages() const noexcept { + return m_print_messages; +} + +void Websocket::SetPrintMessages(bool show) noexcept { + m_print_messages = show; +} + void Websocket::Stop() { Stop(ix::WebSocketCloseConstants::kNormalClosureCode); } @@ -29,7 +37,8 @@ bool Websocket::IsOpen() const { } void Websocket::Send(const std::string &str) { - printf("sending %s\n", str.c_str()); + if (m_print_messages) + printf("sending %s\n", str.c_str()); m_websocket.sendText(str); } diff --git a/src/discord/websocket.hpp b/src/discord/websocket.hpp index e6a6489..26cd5d4 100644 --- a/src/discord/websocket.hpp +++ b/src/discord/websocket.hpp @@ -13,6 +13,9 @@ public: void SetUserAgent(std::string agent); + bool GetPrintMessages() const noexcept; + void SetPrintMessages(bool show) noexcept; + void Send(const std::string &str); void Send(const nlohmann::json &j); void Stop(); @@ -26,9 +29,9 @@ private: std::string m_agent; public: - typedef sigc::signal type_signal_open; - typedef sigc::signal type_signal_close; - typedef sigc::signal type_signal_message; + using type_signal_open = sigc::signal; + using type_signal_close = sigc::signal; + using type_signal_message = sigc::signal; type_signal_open signal_open(); type_signal_close signal_close(); @@ -38,4 +41,6 @@ private: type_signal_open m_signal_open; type_signal_close m_signal_close; type_signal_message m_signal_message; + + bool m_print_messages = true; }; -- cgit v1.2.3 From 192b043e7ac60bb06fbb25b2e46ef096b48c16fd Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Sun, 28 Nov 2021 22:40:41 -0500 Subject: fix distortion of non-1:1 emojis --- src/components/chatmessage.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/components/chatmessage.cpp b/src/components/chatmessage.cpp index 9514c8a..ef972bb 100644 --- a/src/components/chatmessage.cpp +++ b/src/components/chatmessage.cpp @@ -834,7 +834,9 @@ void ChatMessageItemContainer::HandleCustomEmojis(Gtk::TextView &tv) { buf->delete_mark(mark_start); buf->delete_mark(mark_end); auto it = buf->erase(start_it, end_it); - buf->insert_pixbuf(it, pixbuf->scale_simple(EmojiSize, EmojiSize, Gdk::INTERP_BILINEAR)); + int width, height; + GetImageDimensions(pixbuf->get_width(), pixbuf->get_height(), width, height, EmojiSize, EmojiSize); + buf->insert_pixbuf(it, pixbuf->scale_simple(width, height, Gdk::INTERP_BILINEAR)); }; img.LoadFromURL(EmojiData::URLFromID(match.fetch(2)), sigc::track_obj(cb, tv)); } -- cgit v1.2.3 From e02107feea8214a045e6faa969f00dcbc0d2b072 Mon Sep 17 00:00:00 2001 From: ouwou <26526779+ouwou@users.noreply.github.com> Date: Sun, 28 Nov 2021 22:42:55 -0500 Subject: actually retrieve roles for guilds FetchRoles isnt needed anymore cuz full roles are fetched now --- src/discord/discord.cpp | 4 +++- src/discord/guild.cpp | 15 --------------- src/discord/guild.hpp | 3 +-- src/discord/store.cpp | 30 +++++++++++++++++++++++++++--- src/discord/store.hpp | 2 ++ src/windows/guildsettings/memberspane.cpp | 7 ++++--- src/windows/guildsettings/rolespane.cpp | 25 +++++++++++++------------ 7 files changed, 50 insertions(+), 36 deletions(-) (limited to 'src') diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index 83db97b..b678de0 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -720,7 +720,9 @@ void DiscordClient::ModifyRoleColor(Snowflake guild_id, Snowflake role_id, Gdk:: } void DiscordClient::ModifyRolePosition(Snowflake guild_id, Snowflake role_id, int position, sigc::slot callback) { - const auto roles = GetGuild(guild_id)->FetchRoles(); + const auto guild = GetGuild(guild_id); + if (!guild.has_value() || !guild->Roles.has_value()) return; + const auto &roles = *guild->Roles; if (static_cast(position) > roles.size()) return; // gay and makes you send every role in between new and old position constexpr auto IDX_MAX = ~size_t { 0 }; diff --git a/src/discord/guild.cpp b/src/discord/guild.cpp index a02b896..966bd44 100644 --- a/src/discord/guild.cpp +++ b/src/discord/guild.cpp @@ -188,21 +188,6 @@ std::vector GuildData::GetSortedChannels(Snowflake ignore) const { return ret; } -std::vector GuildData::FetchRoles() const { - if (!Roles.has_value()) return {}; - std::vector ret; - ret.reserve(Roles->size()); - for (const auto thing : *Roles) { - auto r = Abaddon::Get().GetDiscordClient().GetRole(thing.ID); - if (r.has_value()) - ret.push_back(*r); - } - std::sort(ret.begin(), ret.end(), [](const RoleData &a, const RoleData &b) -> bool { - return a.Position > b.Position; - }); - return ret; -} - void from_json(const nlohmann::json &j, GuildApplicationData &m) { JS_D("user_id", m.UserID); JS_D("guild_id", m.GuildID); diff --git a/src/discord/guild.hpp b/src/discord/guild.hpp index 3c3828d..51b5a01 100644 --- a/src/discord/guild.hpp +++ b/src/discord/guild.hpp @@ -50,7 +50,7 @@ struct GuildData { std::optional VerificationLevel; std::optional DefaultMessageNotifications; std::optional ExplicitContentFilter; - std::optional> Roles; // only access id + std::optional> Roles; std::optional> Emojis; // only access id std::optional> Features; std::optional MFALevel; @@ -96,5 +96,4 @@ struct GuildData { bool HasAnimatedIcon() const; std::string GetIconURL(std::string ext = "png", std::string size = "32") const; std::vector GetSortedChannels(Snowflake ignore = Snowflake::Invalid) const; - std::vector FetchRoles() const; // sorted }; diff --git a/src/discord/store.cpp b/src/discord/store.cpp index 63fb5f7..1cb7231 100644 --- a/src/discord/store.cpp +++ b/src/discord/store.cpp @@ -765,6 +765,16 @@ std::optional Store::GetGuild(Snowflake id) const { s->Reset(); } + { + auto &s = m_stmt_get_guild_roles; + s->Bind(1, id); + r.Roles.emplace(); + while (s->FetchOne()) { + r.Roles->push_back(GetRoleBound(s)); + } + s->Reset(); + } + return r; } @@ -961,9 +971,17 @@ std::optional Store::GetRole(Snowflake id) const { return {}; } + auto role = GetRoleBound(s); + + s->Reset(); + + return role; +} + +RoleData Store::GetRoleBound(std::unique_ptr &s) const { RoleData r; - r.ID = id; + s->Get(0, r.ID); //s->Get(1, guild id); s->Get(2, r.Name); s->Get(3, r.Color); @@ -973,8 +991,6 @@ std::optional Store::GetRole(Snowflake id) const { s->Get(7, r.IsManaged); s->Get(8, r.IsMentionable); - s->Reset(); - return r; } @@ -1726,6 +1742,14 @@ bool Store::CreateStatements() { return false; } + m_stmt_get_guild_roles = std::make_unique(m_db, R"( + SELECT * FROM roles WHERE guild = ? + )"); + if (!m_stmt_get_guild_roles->OK()) { + fprintf(stderr, "failed to prepare get guild roles statement: %s\n", m_db.ErrStr()); + return false; + } + m_stmt_set_emoji = std::make_unique(m_db, R"( REPLACE INTO emojis VALUES ( ?, ?, ?, ?, ?, ?, ? diff --git a/src/discord/store.hpp b/src/discord/store.hpp index 80e2407..715f280 100644 --- a/src/discord/store.hpp +++ b/src/discord/store.hpp @@ -235,6 +235,7 @@ private: }; Message GetMessageBound(std::unique_ptr &stmt) const; + RoleData GetRoleBound(std::unique_ptr &stmt) const; void SetMessageInteractionPair(Snowflake message_id, const MessageInteractionData &interaction); @@ -264,6 +265,7 @@ private: STMT(get_member); STMT(set_role); STMT(get_role); + STMT(get_guild_roles); STMT(set_emoji); STMT(get_emoji); STMT(set_perm); diff --git a/src/windows/guildsettings/memberspane.cpp b/src/windows/guildsettings/memberspane.cpp index 9dc76d3..bda92b4 100644 --- a/src/windows/guildsettings/memberspane.cpp +++ b/src/windows/guildsettings/memberspane.cpp @@ -238,9 +238,10 @@ GuildSettingsMembersPaneRoles::GuildSettingsMembersPaneRoles(Snowflake guild_id) discord.signal_role_delete().connect(sigc::mem_fun(*this, &GuildSettingsMembersPaneRoles::OnRoleDelete)); const auto guild = *discord.GetGuild(guild_id); - const auto roles = guild.FetchRoles(); - for (const auto &role : roles) { - CreateRow(can_modify, role, guild.OwnerID == self_id); + if (guild.Roles.has_value()) { + for (const auto &role : *guild.Roles) { + CreateRow(can_modify, role, guild.OwnerID == self_id); + } } m_list.set_sort_func([this](Gtk::ListBoxRow *a, Gtk::ListBoxRow *b) -> int { diff --git a/src/windows/guildsettings/rolespane.cpp b/src/windows/guildsettings/rolespane.cpp index 8d355ee..3567e95 100644 --- a/src/windows/guildsettings/rolespane.cpp +++ b/src/windows/guildsettings/rolespane.cpp @@ -79,19 +79,20 @@ GuildSettingsRolesPaneRoles::GuildSettingsRolesPaneRoles(Snowflake guild_id) discord.signal_role_delete().connect(sigc::mem_fun(*this, &GuildSettingsRolesPaneRoles::OnRoleDelete)); const auto guild = *discord.GetGuild(GuildID); - const auto roles = guild.FetchRoles(); const bool can_modify = discord.HasGuildPermission(discord.GetUserData().ID, GuildID, Permission::MANAGE_ROLES); - for (const auto &role : roles) { - auto *row = Gtk::manage(new GuildSettingsRolesPaneRolesListItem(guild, role)); - row->drag_source_set(g_target_entries, Gdk::BUTTON1_MASK, Gdk::ACTION_MOVE); - row->set_margin_start(5); - row->set_halign(Gtk::ALIGN_FILL); - row->show(); - m_rows[role.ID] = row; - if (can_modify) - m_list.add_draggable(row); - else - m_list.add(*row); + if (guild.Roles.has_value()) { + for (const auto &role : *guild.Roles) { + auto *row = Gtk::manage(new GuildSettingsRolesPaneRolesListItem(guild, role)); + row->drag_source_set(g_target_entries, Gdk::BUTTON1_MASK, Gdk::ACTION_MOVE); + row->set_margin_start(5); + row->set_halign(Gtk::ALIGN_FILL); + row->show(); + m_rows[role.ID] = row; + if (can_modify) + m_list.add_draggable(row); + else + m_list.add(*row); + } } m_list.set_sort_func([this](Gtk::ListBoxRow *rowa_, Gtk::ListBoxRow *rowb_) -> int { -- cgit v1.2.3