From 1a652d9116914d207936607a82f7ac5d38ac0766 Mon Sep 17 00:00:00 2001 From: Kristofer Berggren Date: Sat, 20 Apr 2024 14:51:42 +0800 Subject: [PATCH] fixes #216 - add support for reactions --- README.md | 11 +- lib/common/src/protocol.h | 92 ++++- lib/common/src/version.h | 2 +- lib/duchat/src/duchat.cpp | 11 +- lib/ncutil/CMakeLists.txt | 4 + lib/ncutil/src/cacheutil.cpp | 102 +++++ lib/ncutil/src/cacheutil.h | 20 + lib/ncutil/src/fileutil.cpp | 13 +- lib/ncutil/src/fileutil.h | 1 + lib/ncutil/src/messagecache.cpp | 323 ++++++++++++--- lib/ncutil/src/messagecache.h | 19 +- lib/ncutil/src/serialization.h | 158 +++++++ lib/tgchat/src/tgchat.cpp | 390 ++++++++++++++++-- lib/wmchat/go/cgowm.go | 10 + lib/wmchat/go/gowm.go | 59 +++ lib/wmchat/src/wmchat.cpp | 52 +++ lib/wmchat/src/wmchat.h | 2 + src/main.cpp | 6 +- src/nchat.1 | 11 +- src/uicolorconfig.cpp | 3 + src/uiconfig.cpp | 3 +- src/uiemojilistdialog.cpp | 90 +++- src/uiemojilistdialog.h | 19 +- src/uihistoryview.cpp | 115 +++++- src/uikeyconfig.cpp | 3 +- src/uimodel.cpp | 149 ++++++- src/uimodel.h | 8 +- themes/basic-color/color.conf | 6 + themes/catppuccin-mocha/color.conf | 4 + themes/default/color.conf | 6 + themes/dracula/color.conf | 4 + themes/espresso/color.conf | 4 + themes/gruvbox-dark/color.conf | 4 + .../solarized-dark-higher-contrast/color.conf | 4 + themes/tokyo-night/color.conf | 4 + themes/tomorrow-night/color.conf | 4 + themes/zenbones-dark/color.conf | 4 + themes/zenburned/color.conf | 4 + 38 files changed, 1584 insertions(+), 140 deletions(-) create mode 100644 lib/ncutil/src/cacheutil.cpp create mode 100644 lib/ncutil/src/cacheutil.h create mode 100644 lib/ncutil/src/serialization.h diff --git a/README.md b/README.md index a58c96de..72a9031c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Features - Show user status (online, away, typing) - Toggle to view textized emojis vs. graphical - View / save media files (documents, photos, videos) +- Send and display reactions Usage @@ -60,11 +61,11 @@ Interactive Commands: Ctrl-x send message Ctrl-y toggle show emojis KeyUp select message + Alt-$ external spell check Alt-, decrease contact list width Alt-. increase contact list width Alt-d delete/leave current chat Alt-e external editor compose - Alt-s external spell check Alt-t external telephone call Interactive Commands for Selected Message: @@ -77,6 +78,7 @@ Interactive Commands for Selected Message: Ctrl-z edit selected message Alt-w external message viewer Alt-c copy selected message to clipboard + Alt-s add/remove reaction on selected message Interactive Commands for Text Input: @@ -339,6 +341,7 @@ This configuration file holds general user interface settings. Default content: phone_number_indicator= proxy_indicator=🔒 read_indicator=✓ + reactions_enabled=1 spell_check_command= syncing_indicator=⇄ terminal_bell_active=0 @@ -527,6 +530,10 @@ Specifies top bar text to indicate proxy is enabled. Specifies text to indicate a message has been read by the receiver. +### reactions_enabled + +Specifies whether to display reactions. + ### spell_check_command Specifies a custom command to use for spell checking composed messages. If not @@ -654,6 +661,8 @@ This configuration file holds user interface color settings. Default content: history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=gray + history_text_reaction_color_bg= + history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/lib/common/src/protocol.h b/lib/common/src/protocol.h index e0e85044..1377b240 100644 --- a/lib/common/src/protocol.h +++ b/lib/common/src/protocol.h @@ -8,7 +8,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -24,6 +26,8 @@ enum ProtocolFeature FeatureTypingTimeout = (1 << 1), FeatureEditMessagesWithinTwoDays = (1 << 2), FeatureEditMessagesWithinFifteenMins = (1 << 3), + FeatureLimitedReactions = (1 << 4), + FeatureMarkReadEveryView = (1 << 5), }; class Protocol @@ -77,6 +81,9 @@ enum MessageType CreateChatRequestType, SetCurrentChatRequestType, DeferGetSponsoredMessagesRequestType, + GetAvailableReactionsRequestType, + SendReactionRequestType, + GetUnreadReactionsRequestType, ServiceMessageType, NewContactsNotifyType, NewChatsNotifyType, @@ -96,6 +103,8 @@ enum MessageType UpdateMuteNotifyType, ProtocolUiControlNotifyType, RequestAppExitNotifyType, + NewMessageReactionsNotifyType, + AvailableReactionsNotifyType, }; struct ContactInfo @@ -110,7 +119,7 @@ struct ChatInfo { std::string id; bool isUnread = false; - bool isUnreadMention = false; // tgchat only + bool isUnreadMention = false; // only required for tgchat bool isMuted = false; int64_t lastMessageTime = -1; }; @@ -132,6 +141,31 @@ struct FileInfo std::string fileType; }; +// ensure CacheUtil and Serialization are up-to-date after modifying Reactions +static const std::string s_ReactionsSelfId = "You"; +struct Reactions +{ + bool needConsolidationWithCache = false; // true = need consolidation with cache before usage + bool updateCountBasedOnSender = false; // true = need to update emojiCount based on senderEmoji + bool replaceCount = false; // true = replace emoji counts + std::map senderEmojis; + std::map emojiCounts; + + bool operator==(const Reactions& p_Other) const + { + if (senderEmojis != p_Other.senderEmojis) return false; + + if (emojiCounts != p_Other.emojiCounts) return false; + + return true; + } + + bool operator!=(const Reactions& p_Other) const + { + return (*this == p_Other); + } +}; + struct ChatMessage { std::string id; @@ -141,11 +175,12 @@ struct ChatMessage std::string quotedText; std::string quotedSender; std::string fileInfo; - std::string link; // tgchat sponsored msg only, not db cached + std::string link; // only required for tgchat, sponsored msg, not db cached + Reactions reactions; int64_t timeSent = -1; bool isOutgoing = true; bool isRead = false; - bool hasMention = false; // tgchat only, not db cached + bool hasMention = false; // only required for tgchat, not db cached }; enum DownloadFileAction @@ -225,6 +260,7 @@ class MarkMessageReadRequest : public RequestMessage std::string chatId; std::string senderId; // only required for wmchat std::string msgId; + bool readAllReactions = false; }; class DeleteMessageRequest : public RequestMessage @@ -232,7 +268,7 @@ class DeleteMessageRequest : public RequestMessage public: virtual MessageType GetMessageType() const { return DeleteMessageRequestType; } std::string chatId; - std::string senderId; // only needed for wmchat + std::string senderId; // only required for wmchat std::string msgId; }; @@ -322,6 +358,32 @@ class DeferGetSponsoredMessagesRequest : public RequestMessage std::string chatId; }; +class GetAvailableReactionsRequest : public RequestMessage +{ +public: + virtual MessageType GetMessageType() const { return GetAvailableReactionsRequestType; } + std::string chatId; + std::string msgId; +}; + +class SendReactionRequest : public RequestMessage +{ +public: + virtual MessageType GetMessageType() const { return SendReactionRequestType; } + std::string chatId; + std::string senderId; // only required for wmchat + std::string msgId; + std::string emoji; + std::string prevEmoji; // only required for tgchat, to clear reaction +}; + +class GetUnreadReactionsRequest : public RequestMessage +{ +public: + virtual MessageType GetMessageType() const { return GetUnreadReactionsRequestType; } + std::string chatId; +}; + // Service messages class ServiceMessage { @@ -529,3 +591,25 @@ class RequestAppExitNotify : public ServiceMessage ServiceMessage(p_ProfileId) { } virtual MessageType GetMessageType() const { return RequestAppExitNotifyType; } }; + +class NewMessageReactionsNotify : public ServiceMessage +{ +public: + explicit NewMessageReactionsNotify(const std::string& p_ProfileId) : + ServiceMessage(p_ProfileId) { } + virtual MessageType GetMessageType() const { return NewMessageReactionsNotifyType; } + std::string chatId; + std::string msgId; + Reactions reactions; +}; + +class AvailableReactionsNotify : public ServiceMessage +{ +public: + explicit AvailableReactionsNotify(const std::string& p_ProfileId) : + ServiceMessage(p_ProfileId) { } + virtual MessageType GetMessageType() const { return AvailableReactionsNotifyType; } + std::string chatId; + std::string msgId; + std::set emojis; +}; diff --git a/lib/common/src/version.h b/lib/common/src/version.h index 5476b55e..338003fc 100644 --- a/lib/common/src/version.h +++ b/lib/common/src/version.h @@ -7,4 +7,4 @@ #pragma once -#define NCHAT_VERSION "4.59" +#define NCHAT_VERSION "4.60" diff --git a/lib/duchat/src/duchat.cpp b/lib/duchat/src/duchat.cpp index 81d862a6..a18da62e 100644 --- a/lib/duchat/src/duchat.cpp +++ b/lib/duchat/src/duchat.cpp @@ -247,7 +247,7 @@ void DuChat::PerformRequest(std::shared_ptr p_RequestMessage) { "Michael", "Pam, what you don't understand is that at my level you just don't " "look in the want-ads for a job. You are head-hunted." }, - { "Jim", "You called any headhunters?" }, + { "Jim", "You've called any headhunters?" }, { "Michael", "Any good headhunter knows I am available." }, { "Dwight", "Any really good headhunter would storm your village at sunset with " @@ -315,6 +315,7 @@ void DuChat::PerformRequest(std::shared_ptr p_RequestMessage) newContactsNotify->contactInfos.push_back(contactInfo); // From others + bool isFirst = true; for (auto& message : groupMessages) { std::string name = message.first; @@ -327,6 +328,14 @@ void DuChat::PerformRequest(std::shared_ptr p_RequestMessage) chatMessage.timeSent = (t * 1000); chatMessage.isOutgoing = (id == sid); chatMessage.isRead = true; + + if (isFirst) + { + isFirst = false; + chatMessage.reactions.emojiCounts["😨"] = 4; + chatMessage.reactions.emojiCounts["🤣"] = 2; + } + t = t - 100; s_Messages[gid].push_back(chatMessage); } diff --git a/lib/ncutil/CMakeLists.txt b/lib/ncutil/CMakeLists.txt index 84ace631..c40dee77 100644 --- a/lib/ncutil/CMakeLists.txt +++ b/lib/ncutil/CMakeLists.txt @@ -24,6 +24,8 @@ add_library(ncutil SHARED src/appconfig.h src/apputil.cpp src/apputil.h + src/cacheutil.cpp + src/cacheutil.h src/clipboard.cpp src/clipboard.h src/config.cpp @@ -48,6 +50,7 @@ add_library(ncutil SHARED src/protocolutil.h src/scopeddirlock.cpp src/scopeddirlock.h + src/serialization.h src/sqlitehelp.cpp src/sqlitehelp.h src/status.cpp @@ -72,6 +75,7 @@ target_include_directories(ncutil PRIVATE "../common/src") target_include_directories(ncutil PRIVATE "../../ext/apathy") target_include_directories(ncutil PRIVATE "../../ext/sqlite_modern_cpp/hdr") target_include_directories(ncutil PRIVATE "../../ext/clip") +target_include_directories(ncutil PRIVATE "../../ext/cereal/include") # Compiler flags set_target_properties(ncutil PROPERTIES COMPILE_FLAGS diff --git a/lib/ncutil/src/cacheutil.cpp b/lib/ncutil/src/cacheutil.cpp new file mode 100644 index 00000000..b6e5af42 --- /dev/null +++ b/lib/ncutil/src/cacheutil.cpp @@ -0,0 +1,102 @@ +// messagecache.cpp +// +// Copyright (c) 2024 Kristofer Berggren +// All rights reserved. +// +// nchat is distributed under the MIT license, see LICENSE for details. + +#include "cacheutil.h" + +#include +#include + +#include "log.h" + +// #define DEBUG_UPDATE_REACTIONS + +// Determines whether a Reactions instance needs serialization +bool CacheUtil::IsDefaultReactions(const Reactions& p_Reactions) +{ + return p_Reactions.senderEmojis.empty() && p_Reactions.emojiCounts.empty() && + !p_Reactions.updateCountBasedOnSender && !p_Reactions.needConsolidationWithCache && !p_Reactions.replaceCount; +} + +// Debug helper +std::string CacheUtil::ReactionsToString(const Reactions& p_Reactions) +{ + std::stringstream sstream; + sstream << "needConsolidation=" << p_Reactions.needConsolidationWithCache << " "; + sstream << "updateCount=" << p_Reactions.updateCountBasedOnSender << " "; + sstream << "replaceCount=" << p_Reactions.replaceCount << " "; + sstream << "senderEmojis=[ "; + for (const auto& senderEmoji : p_Reactions.senderEmojis) + { + sstream << "(" << senderEmoji.first << ": " << senderEmoji.second << ") "; + } + sstream << "] "; + + sstream << "emojiCounts=[ "; + for (const auto& emojiCount : p_Reactions.emojiCounts) + { + sstream << "(" << emojiCount.first << ": " << emojiCount.second << ") "; + } + sstream << "] "; + + return sstream.str(); +} + +// This function takes an original Reactions instance, p_Source, and adds/removes senderEmojis +// based on an "update" Reactions instance, p_Target. Then counting of emoji types is done and +// result is stored in emojiCounts. +void CacheUtil::UpdateReactions(const Reactions& p_Source, Reactions& p_Target) +{ +#ifdef DEBUG_UPDATE_REACTIONS + LOG_INFO("update reactions"); + LOG_INFO("source: %s", ReactionsToString(p_Source).c_str()); + LOG_INFO("target: %s", ReactionsToString(p_Target).c_str()); +#endif + + // Update senderEmojis + std::map combinedSenderEmojis = p_Source.senderEmojis; + for (const auto& senderEmoji : p_Target.senderEmojis) + { + if (senderEmoji.second.empty()) + { + combinedSenderEmojis.erase(senderEmoji.first); + } + else + { + combinedSenderEmojis[senderEmoji.first] = senderEmoji.second; + } + } + + p_Target.senderEmojis = combinedSenderEmojis; + + // Handle replace count + if (p_Target.replaceCount) + { + // Do nothing, using provided emojiCounts + } + else + { + p_Target.emojiCounts = p_Source.emojiCounts; + } + + // Update emojiCounts based on senderEmojis + if (p_Target.updateCountBasedOnSender) + { + p_Target.emojiCounts.clear(); + for (const auto& senderEmoji : p_Target.senderEmojis) + { + p_Target.emojiCounts[senderEmoji.second] += 1; + } + } + + p_Target.needConsolidationWithCache = false; + p_Target.updateCountBasedOnSender = false; + p_Target.replaceCount = false; + +#ifdef DEBUG_UPDATE_REACTIONS + LOG_INFO("result: %s", ReactionsToString(p_Target).c_str()); +#endif +} diff --git a/lib/ncutil/src/cacheutil.h b/lib/ncutil/src/cacheutil.h new file mode 100644 index 00000000..fdeb3f2a --- /dev/null +++ b/lib/ncutil/src/cacheutil.h @@ -0,0 +1,20 @@ +// cacheutil.h +// +// Copyright (c) 2024 Kristofer Berggren +// All rights reserved. +// +// nchat is distributed under the MIT license, see LICENSE for details. + +#pragma once + +#include + +#include "protocol.h" + +class CacheUtil +{ +public: + static bool IsDefaultReactions(const Reactions& p_Reactions); + static std::string ReactionsToString(const Reactions& p_Reactions); + static void UpdateReactions(const Reactions& p_Source, Reactions& p_Target); +}; diff --git a/lib/ncutil/src/fileutil.cpp b/lib/ncutil/src/fileutil.cpp index 59f00acc..b6980a7b 100644 --- a/lib/ncutil/src/fileutil.cpp +++ b/lib/ncutil/src/fileutil.cpp @@ -1,6 +1,6 @@ // fileutil.cpp // -// Copyright (c) 2020-2023 Kristofer Berggren +// Copyright (c) 2020-2024 Kristofer Berggren // All rights reserved. // // nchat is distributed under the MIT license, see LICENSE for details. @@ -208,6 +208,17 @@ std::string FileUtil::GetLibSuffix() return ""; } +std::string FileUtil::GetSuffixedCount(ssize_t p_Size) +{ + std::vector suffixes({ "", "K", "M", "G", "T", "P" }); + size_t i = 0; + for (i = 0; (i < suffixes.size()) && (p_Size >= 1024); i++, (p_Size /= 1024)) + { + } + + return std::to_string(p_Size) + suffixes.at(i); +} + std::string FileUtil::GetSuffixedSize(ssize_t p_Size) { std::vector suffixes({ "B", "KB", "MB", "GB", "TB", "PB" }); diff --git a/lib/ncutil/src/fileutil.h b/lib/ncutil/src/fileutil.h index 22969b01..0bb712d6 100644 --- a/lib/ncutil/src/fileutil.h +++ b/lib/ncutil/src/fileutil.h @@ -74,6 +74,7 @@ class FileUtil static std::string GetMimeType(const std::string& p_Path); static std::string GetSelfPath(); static std::string GetLibSuffix(); + static std::string GetSuffixedCount(ssize_t p_Size); static std::string GetSuffixedSize(ssize_t p_Size); static void InitDirVersion(const std::string& p_Dir, int p_Version); static bool IsDir(const std::string& p_Path); diff --git a/lib/ncutil/src/messagecache.cpp b/lib/ncutil/src/messagecache.cpp index d24587c9..665ab184 100644 --- a/lib/ncutil/src/messagecache.cpp +++ b/lib/ncutil/src/messagecache.cpp @@ -16,9 +16,11 @@ #include #include "appconfig.h" +#include "cacheutil.h" #include "log.h" #include "fileutil.h" #include "protocolutil.h" +#include "serialization.h" #include "sqlitehelp.h" #include "strutil.h" #include "timeutil.h" @@ -36,7 +38,6 @@ std::deque> MessageCache::m_Queue; std::string MessageCache::m_HistoryDir; bool MessageCache::m_CacheEnabled = true; -// @note: minor db schema updates can simply update table name to avoid losing other tables data static const std::string s_TableContacts = "contacts2"; static const std::string s_TableChats = "chats2"; static const std::string s_TableMessages = "messages"; @@ -171,6 +172,15 @@ void MessageCache::AddFromServiceMessage(const std::string& p_ProfileId, } break; + case NewMessageReactionsNotifyType: + { + std::shared_ptr newMessageReactionsNotify = + std::static_pointer_cast(p_ServiceMessage); + MessageCache::UpdateMessageReactions(p_ProfileId, newMessageReactionsNotify->chatId, + newMessageReactionsNotify->msgId, newMessageReactionsNotify->reactions); + } + break; + case UpdateMuteNotifyType: { std::shared_ptr updateMuteNotify = std::static_pointer_cast( @@ -229,39 +239,88 @@ void MessageCache::AddProfile(const std::string& p_ProfileId, bool p_CheckSync, *m_Dbs[p_ProfileId] << "PRAGMA synchronous = OFF"; *m_Dbs[p_ProfileId] << "PRAGMA journal_mode = MEMORY"; - // create table if not exists - *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS " + s_TableMessages + " (" - "chatId TEXT," - "id TEXT," - "senderId TEXT," - "text TEXT," - "quotedId TEXT," - "quotedText TEXT," - "quotedSender TEXT," - "fileInfo TEXT," - "fileStatus INT," - "fileType TEXT," - "timeSent INT," - "isOutgoing INT," - "isRead INT," - "UNIQUE(chatId, id) ON CONFLICT REPLACE" - ");"; - - *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS " + s_TableContacts + " (" - "id TEXT," - "name TEXT," - "phone TEXT," - "isSelf INT," - "UNIQUE(id) ON CONFLICT REPLACE" - ");"; - - *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS " + s_TableChats + " (" - "id TEXT," - "isMuted INT," - "UNIQUE(id) ON CONFLICT REPLACE" - ");"; - - // @todo: create index (id, timeSent, chatId) + // note: use actual table names instead if variables during schema setup / update + + // fresh database will get version 0 + // existing legacy database will get version 3 (as the three tables existed) + // existing modern database will have its stored version 4 or newer + *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS version AS " + "SELECT COUNT(name) AS schema FROM sqlite_master WHERE TYPE='table' AND " + "(name='contacts2' OR name='chats2' OR name='messages');"; + + // *INDENT-OFF* + int64_t schemaVersion = 0; + *m_Dbs[p_ProfileId] << "SELECT schema FROM version;" >> + [&](const int64_t& schema) + { + schemaVersion = schema; + }; + // *INDENT-ON* + + LOG_DEBUG("detected db schema %d", schemaVersion); + + if (schemaVersion < 3) + { + LOG_INFO("create base db schema"); + + *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS messages (" + "chatId TEXT," + "id TEXT," + "senderId TEXT," + "text TEXT," + "quotedId TEXT," + "quotedText TEXT," + "quotedSender TEXT," + "fileInfo TEXT," + "fileStatus INT," + "fileType TEXT," + "timeSent INT," + "isOutgoing INT," + "isRead INT," + "UNIQUE(chatId, id) ON CONFLICT REPLACE" + ");"; + + *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS contacts2 (" + "id TEXT," + "name TEXT," + "phone TEXT," + "isSelf INT," + "UNIQUE(id) ON CONFLICT REPLACE" + ");"; + + *m_Dbs[p_ProfileId] << "CREATE TABLE IF NOT EXISTS chats2 (" + "id TEXT," + "isMuted INT," + "UNIQUE(id) ON CONFLICT REPLACE" + ");"; + + schemaVersion = 3; + *m_Dbs[p_ProfileId] << "UPDATE version " + "SET schema=?;" << schemaVersion; + } + + if (schemaVersion == 3) + { + LOG_INFO("update db schema 3 to 4"); + + *m_Dbs[p_ProfileId] << "ALTER TABLE messages ADD COLUMN " + "reactions BLOB;"; + + schemaVersion = 4; + *m_Dbs[p_ProfileId] << "UPDATE version " + "SET schema=?;" << schemaVersion; + } + + static const int64_t s_SchemaVersion = 4; + if (schemaVersion > s_SchemaVersion) + { + LOG_WARNING("cache db schema %d from newer nchat version detected, if cache issues are encountered " + "please delete ~/.nchat/history or perform a fresh nchat setup", schemaVersion); + } + else + { + LOG_TRACE("db schema ready"); + } } catch (const sqlite::sqlite_exception& ex) { @@ -528,6 +587,20 @@ void MessageCache::UpdateMessageFileInfo(const std::string& p_ProfileId, const s EnqueueRequest(updateMessageFileInfoRequest); } +void MessageCache::UpdateMessageReactions(const std::string& p_ProfileId, const std::string& p_ChatId, + const std::string& p_MsgId, const Reactions& p_Reactions) +{ + if (!m_CacheEnabled) return; + + std::shared_ptr updateMessageReactionsRequest = + std::make_shared(); + updateMessageReactionsRequest->profileId = p_ProfileId; + updateMessageReactionsRequest->chatId = p_ChatId; + updateMessageReactionsRequest->msgId = p_MsgId; + updateMessageReactionsRequest->reactions = p_Reactions; + EnqueueRequest(updateMessageReactionsRequest); +} + void MessageCache::UpdateMute(const std::string& p_ProfileId, const std::string& p_ChatId, bool p_IsMuted) { if (!m_CacheEnabled) return; @@ -765,23 +838,90 @@ void MessageCache::PerformRequest(std::shared_ptr p_Request) } } - try + for (const auto& msg : addMessagesRequest->chatMessages) { - *m_Dbs[profileId] << "BEGIN;"; - for (const auto& msg : addMessagesRequest->chatMessages) + // Fetch already cached message reactions + Reactions oldReactions; + try { - *m_Dbs[profileId] << "INSERT INTO " + s_TableMessages + " " - "(chatId, id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, timeSent, isOutgoing, isRead) VALUES " - "(?,?,?,?,?,?,?,?,?,?,?);" << - chatId << msg.id << msg.senderId << msg.text << msg.quotedId << msg.quotedText << msg.quotedSender << - msg.fileInfo << msg.timeSent << - msg.isOutgoing << msg.isRead; + // *INDENT-OFF* + *m_Dbs[profileId] << "SELECT reactions FROM " + s_TableMessages + " WHERE chatId = ? AND id = ?;" << + chatId << msg.id >> + [&](const std::vector& reactionsBytes) + { + if (!reactionsBytes.empty()) + { + oldReactions = Serialization::FromBytes(reactionsBytes); + } + }; + // *INDENT-ON* + } + catch (const sqlite::sqlite_exception& ex) + { + HANDLE_SQLITE_EXCEPTION(ex); + } + + Reactions reactions = msg.reactions; + if (CacheUtil::IsDefaultReactions(oldReactions)) + { + // If not previously cached, or cached reactions are default, then overwrite. + + LOG_DEBUG("insert reactions %s", msg.id.c_str()); + + std::vector reactionsBytes; + if (!CacheUtil::IsDefaultReactions(reactions)) + { + reactionsBytes = Serialization::ToBytes(reactions); + } + + try + { + *m_Dbs[profileId] << "INSERT INTO " + s_TableMessages + " " + "(chatId, id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, timeSent, isOutgoing, isRead, reactions) VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?);" << + chatId << msg.id << msg.senderId << msg.text << msg.quotedId << msg.quotedText << msg.quotedSender << + msg.fileInfo << msg.timeSent << msg.isOutgoing << msg.isRead << reactionsBytes; + } + catch (const sqlite::sqlite_exception& ex) + { + HANDLE_SQLITE_EXCEPTION(ex); + } + } + else + { + // If message already exists and has non-default reactions, then merge reactions. + + LOG_DEBUG("merge reactions %s", msg.id.c_str()); + CacheUtil::UpdateReactions(oldReactions, reactions); + std::vector reactionsBytes; + if (!CacheUtil::IsDefaultReactions(reactions)) + { + reactionsBytes = Serialization::ToBytes(reactions); + } + + try + { + *m_Dbs[profileId] << "INSERT INTO " + s_TableMessages + " " + "(chatId, id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, timeSent, isOutgoing, isRead, reactions) VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?);" << + chatId << msg.id << msg.senderId << msg.text << msg.quotedId << msg.quotedText << msg.quotedSender << + msg.fileInfo << msg.timeSent << msg.isOutgoing << msg.isRead << reactionsBytes; + } + catch (const sqlite::sqlite_exception& ex) + { + HANDLE_SQLITE_EXCEPTION(ex); + } + + { + // Send consolidated info to ui + std::shared_ptr newMessageReactionsNotify = + std::make_shared(profileId); + newMessageReactionsNotify->chatId = chatId; + newMessageReactionsNotify->msgId = msg.id; + newMessageReactionsNotify->reactions = reactions; + CallMessageHandler(newMessageReactionsNotify); + } } - *m_Dbs[profileId] << "COMMIT;"; - } - catch (const sqlite::sqlite_exception& ex) - { - HANDLE_SQLITE_EXCEPTION(ex); } } break; @@ -1083,8 +1223,8 @@ void MessageCache::PerformRequest(std::shared_ptr p_Request) try { - *m_Dbs[profileId] << "UPDATE " + s_TableMessages + " SET isRead = ? WHERE chatId = ? AND id = ?;" << (int)isRead << chatId << - msgId; + *m_Dbs[profileId] << "UPDATE " + s_TableMessages + " SET isRead = ? WHERE chatId = ? AND id = ?;" << + (int)isRead << chatId << msgId; } catch (const sqlite::sqlite_exception& ex) { @@ -1121,6 +1261,73 @@ void MessageCache::PerformRequest(std::shared_ptr p_Request) } break; + case UpdateMessageReactionsRequestType: + { + std::unique_lock lock(m_DbMutex); + std::shared_ptr updateMessageReactionsRequest = + std::static_pointer_cast(p_Request); + const std::string& profileId = updateMessageReactionsRequest->profileId; + if (!m_Dbs[profileId]) return; + + if (CacheUtil::IsDefaultReactions(updateMessageReactionsRequest->reactions)) return; + + Reactions oldReactions; + Reactions reactions = updateMessageReactionsRequest->reactions; + const std::string& chatId = updateMessageReactionsRequest->chatId; + const std::string& msgId = updateMessageReactionsRequest->msgId; + + try + { + // *INDENT-OFF* + *m_Dbs[profileId] << "SELECT reactions FROM " + s_TableMessages + " WHERE chatId = ? AND id = ?;" << + chatId << msgId >> + [&](const std::vector& reactionsBytes) + { + if (!reactionsBytes.empty()) + { + oldReactions = Serialization::FromBytes(reactionsBytes); + } + }; + // *INDENT-ON* + } + catch (const sqlite::sqlite_exception& ex) + { + HANDLE_SQLITE_EXCEPTION(ex); + } + + LOG_DEBUG("update reactions %s", msgId.c_str()); + CacheUtil::UpdateReactions(oldReactions, reactions); + + std::vector reactionsBytes; + if (!CacheUtil::IsDefaultReactions(reactions)) + { + reactionsBytes = Serialization::ToBytes(reactions); + } + + try + { + *m_Dbs[profileId] << "UPDATE " + s_TableMessages + " SET reactions = ? WHERE chatId = ? AND id = ?;" + << reactionsBytes << chatId << msgId; + } + catch (const sqlite::sqlite_exception& ex) + { + HANDLE_SQLITE_EXCEPTION(ex); + } + + { + // Send consolidated info to ui + std::shared_ptr newMessageReactionsNotify = + std::make_shared(profileId); + newMessageReactionsNotify->chatId = chatId; + newMessageReactionsNotify->msgId = msgId; + newMessageReactionsNotify->reactions = reactions; + CallMessageHandler(newMessageReactionsNotify); + } + + LOG_DEBUG("cache update reactions %s %s", chatId.c_str(), msgId.c_str()); + } + break; + case UpdateMuteRequestType: { std::unique_lock lock(m_DbMutex); @@ -1164,12 +1371,13 @@ void MessageCache::PerformFetchMessagesFrom(const std::string& p_ProfileId, cons { // *INDENT-OFF* *m_Dbs[p_ProfileId] << - "SELECT id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, timeSent, " + "SELECT id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, reactions, timeSent, " "isOutgoing, isRead FROM " + s_TableMessages + " WHERE chatId = ? AND timeSent < ? " "ORDER BY timeSent DESC LIMIT ?;" << p_ChatId << p_FromMsgIdTimeSent << p_Limit >> [&](const std::string& id, const std::string& senderId, const std::string& text, const std::string& quotedId, const std::string& quotedText, const std::string& quotedSender, const std::string& fileInfo, + std::vector reactionsBytes, int64_t timeSent, int32_t isOutgoing, int32_t isRead) { ChatMessage chatMessage; @@ -1184,6 +1392,11 @@ void MessageCache::PerformFetchMessagesFrom(const std::string& p_ProfileId, cons chatMessage.isOutgoing = isOutgoing; chatMessage.isRead = isRead; + if (!reactionsBytes.empty()) + { + chatMessage.reactions = Serialization::FromBytes(reactionsBytes); + } + p_ChatMessages.push_back(chatMessage); }; // *INDENT-ON* @@ -1202,11 +1415,12 @@ void MessageCache::PerformFetchOneMessage(const std::string& p_ProfileId, const { // *INDENT-OFF* *m_Dbs[p_ProfileId] << - "SELECT id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, timeSent, " + "SELECT id, senderId, text, quotedId, quotedText, quotedSender, fileInfo, reactions, timeSent, " "isOutgoing, isRead FROM " + s_TableMessages + " WHERE chatId = ? AND id = ?;" << p_ChatId << p_MsgId >> [&](const std::string& id, const std::string& senderId, const std::string& text, const std::string& quotedId, const std::string& quotedText, const std::string& quotedSender, const std::string& fileInfo, + std::vector reactionsBytes, int64_t timeSent, int32_t isOutgoing, int32_t isRead) { ChatMessage chatMessage; @@ -1221,6 +1435,11 @@ void MessageCache::PerformFetchOneMessage(const std::string& p_ProfileId, const chatMessage.isOutgoing = isOutgoing; chatMessage.isRead = isRead; + if (!reactionsBytes.empty()) + { + chatMessage.reactions = Serialization::FromBytes(reactionsBytes); + } + p_ChatMessages.push_back(chatMessage); }; // *INDENT-ON* diff --git a/lib/ncutil/src/messagecache.h b/lib/ncutil/src/messagecache.h index 5977b773..2336ad71 100644 --- a/lib/ncutil/src/messagecache.h +++ b/lib/ncutil/src/messagecache.h @@ -1,6 +1,6 @@ // messagecache.h // -// Copyright (c) 2020-2023 Kristofer Berggren +// Copyright (c) 2020-2024 Kristofer Berggren // All rights reserved. // // nchat is distributed under the MIT license, see LICENSE for details. @@ -41,6 +41,7 @@ class MessageCache DeleteOneChatRequestType, UpdateMessageIsReadRequestType, UpdateMessageFileInfoRequestType, + UpdateMessageReactionsRequestType, UpdateMuteRequestType, }; @@ -148,6 +149,16 @@ class MessageCache std::string fileInfo; }; + class UpdateMessageReactionsRequest : public Request + { + public: + virtual RequestType GetRequestType() const { return UpdateMessageReactionsRequestType; } + std::string profileId; + std::string chatId; + std::string msgId; + Reactions reactions; + }; + class UpdateMuteRequest : public Request { public: @@ -164,7 +175,8 @@ class MessageCache static void AddFromServiceMessage(const std::string& p_ProfileId, std::shared_ptr p_ServiceMessage); - static void AddProfile(const std::string& p_ProfileId, bool p_CheckSync, int p_DirVersion, bool p_IsSetup, bool* p_IsRemoved = nullptr); + static void AddProfile(const std::string& p_ProfileId, bool p_CheckSync, int p_DirVersion, bool p_IsSetup, + bool* p_IsRemoved = nullptr); static void AddMessages(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_FromMsgId, const std::vector& p_ChatMessages); static void AddChats(const std::string& p_ProfileId, const std::vector& p_ChatInfos); @@ -183,7 +195,8 @@ class MessageCache bool p_IsRead); static void UpdateMessageFileInfo(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId, const std::string& p_FileInfo); - + static void UpdateMessageReactions(const std::string& p_ProfileId, const std::string& p_ChatId, + const std::string& p_MsgId, const Reactions& p_Reactions); static void UpdateMute(const std::string& p_ProfileId, const std::string& p_ChatId, bool p_IsMuted); static void Export(const std::string& p_ExportDir); diff --git a/lib/ncutil/src/serialization.h b/lib/ncutil/src/serialization.h new file mode 100644 index 00000000..224fb1f7 --- /dev/null +++ b/lib/ncutil/src/serialization.h @@ -0,0 +1,158 @@ +// serialization.h +// +// Copyright (c) 2024 Kristofer Berggren +// All rights reserved. +// +// nchat is distributed under the MIT license, see LICENSE for details. + +#pragma once + +#include + +#include +#include +#include +#include + +#include "log.h" + +class Serialization +{ +public: + template + static std::vector ToBytes(const T& p_Data) + { + try + { + std::stringstream sstream; + { + cereal::BinaryOutputArchive outputArchive(sstream); + outputArchive(p_Data); + } + const std::string& str = sstream.str(); + return std::vector(str.begin(), str.end()); + } + catch (...) + { + LOG_WARNING("failed to serialize to bytes"); + } + + return std::vector(); + } + + template + static T FromBytes(const std::vector& p_Bytes) + { + T data; + if (p_Bytes.empty()) return data; + + try + { + std::stringstream sstream(std::string(p_Bytes.begin(), p_Bytes.end())); + { + cereal::BinaryInputArchive inputArchive(sstream); + inputArchive(data); + } + } + catch (...) + { + LOG_WARNING("failed to deserialize from bytes"); + } + + return data; + } + + template + void ToFile(const std::string& p_File, const T& p_Data) + { + try + { + std::ofstream fstream(p_File, std::ios::binary); + { + cereal::BinaryOutputArchive outputArchive(fstream); + outputArchive(p_Data); + } + } + catch (...) + { + LOG_WARNING("failed to serialize to file"); + } + } + + template + static T FromFile(const std::string& p_File) + { + T data; + + try + { + std::ifstream fstream(p_File, std::ios::binary); + { + cereal::BinaryInputArchive inputArchive(fstream); + inputArchive(data); + } + } + catch (...) + { + LOG_WARNING("failed to deserialize from file"); + } + + return data; + } + + template + static std::string ToString(const T& p_Data) + { + std::string str; + + try + { + std::stringstream sstream; + { + cereal::BinaryOutputArchive outputArchive(sstream); + outputArchive(p_Data); + } + + str = sstream.str(); + } + catch (...) + { + LOG_WARNING("failed to serialize to string"); + } + + return str; + } + + template + static T FromString(const std::string& p_Str) + { + T data; + if (p_Str.empty()) return data; + + try + { + std::stringstream sstream(p_Str); + { + cereal::BinaryInputArchive inputArchive(sstream); + inputArchive(data); + } + } + catch (...) + { + LOG_WARNING("failed to deserialize from string"); + } + + return data; + } +}; + +template +void serialize(Archive& p_Archive, Reactions& p_Reactions, const uint32_t p_Version) +{ + // For versioning example, see https://github.com/USCiLab/cereal/issues/340 + if (p_Version == 1) + { + p_Archive(p_Reactions.senderEmojis, p_Reactions.emojiCounts); + } +} +CEREAL_CLASS_VERSION(Reactions, 1) diff --git a/lib/tgchat/src/tgchat.cpp b/lib/tgchat/src/tgchat.cpp index b9cca132..a2a1fe14 100644 --- a/lib/tgchat/src/tgchat.cpp +++ b/lib/tgchat/src/tgchat.cpp @@ -28,6 +28,7 @@ #include "appconfig.h" #include "apputil.h" +#include "cacheutil.h" #include "config.h" #include "fileutil.h" #include "log.h" @@ -146,7 +147,7 @@ class TgChat::Impl void CreateChat(Object p_Object); std::string GetRandomString(size_t p_Len); std::uint64_t GetNextQueryId(); - std::int64_t GetSenderId(const td::td_api::message& p_TdMessage); + std::int64_t GetSenderId(td::td_api::object_ptr&& p_TdMessageSender); std::string GetText(td::td_api::object_ptr&& p_FormattedText); void TdMessageContentConvert(td::td_api::MessageContent& p_TdMessageContent, int64_t p_SenderId, std::string& p_Text, std::string& p_FileInfo); @@ -166,6 +167,10 @@ class TgChat::Impl std::string ConvertMarkdownV2ToV1(const std::string& p_Str); void SetProtocolUiControl(bool p_IsTakeControl); std::string GetProfilePhoneNumber(); + void GetMsgReactions(td::td_api::object_ptr& p_InteractionInfo, + Reactions& p_Reactions); + void GetReactionsEmojis(td::td_api::object_ptr& p_AvailableReactions, + std::set& p_Emojis); private: std::thread m_ServiceThread; @@ -278,7 +283,8 @@ std::string TgChat::Impl::GetProfileDisplayName() const bool TgChat::Impl::HasFeature(ProtocolFeature p_ProtocolFeature) const { - static int customFeatures = FeatureTypingTimeout | FeatureEditMessagesWithinTwoDays; + static int customFeatures = FeatureTypingTimeout | FeatureEditMessagesWithinTwoDays | FeatureLimitedReactions | + FeatureMarkReadEveryView; return (p_ProtocolFeature & customFeatures); } @@ -883,25 +889,42 @@ void TgChat::Impl::PerformRequest(std::shared_ptr p_RequestMessa return; } - std::vector msgIds = - { StrUtil::NumFromHex(markMessageReadRequest->msgId) }; - auto view_messages = td::td_api::make_object(); - view_messages->chat_id_ = chatId; - view_messages->message_ids_ = msgIds; - view_messages->force_read_ = true; - - SendQuery(std::move(view_messages), - [this, markMessageReadRequest](Object object) + if (!markMessageReadRequest->msgId.empty()) { - if (object->get_id() == td::td_api::error::ID) return; + std::vector msgIds = + { StrUtil::NumFromHex(markMessageReadRequest->msgId) }; + auto view_messages = td::td_api::make_object(); + view_messages->chat_id_ = chatId; + view_messages->message_ids_ = msgIds; + view_messages->force_read_ = true; + + SendQuery(std::move(view_messages), + [this, markMessageReadRequest](Object object) + { + if (object->get_id() == td::td_api::error::ID) return; - std::shared_ptr markMessageReadNotify = - std::make_shared(m_ProfileId); - markMessageReadNotify->success = true; - markMessageReadNotify->chatId = markMessageReadRequest->chatId; - markMessageReadNotify->msgId = markMessageReadRequest->msgId; - CallMessageHandler(markMessageReadNotify); - }); + std::shared_ptr markMessageReadNotify = + std::make_shared(m_ProfileId); + markMessageReadNotify->success = true; + markMessageReadNotify->chatId = markMessageReadRequest->chatId; + markMessageReadNotify->msgId = markMessageReadRequest->msgId; + CallMessageHandler(markMessageReadNotify); + }); + } + + if (markMessageReadRequest->readAllReactions) + { + auto read_all_chat_reactions = td::td_api::make_object(); + read_all_chat_reactions->chat_id_ = chatId; + + SendQuery(std::move(read_all_chat_reactions), + [](Object object) + { + if (object->get_id() == td::td_api::error::ID) return; + + LOG_TRACE("Marked reactions read"); + }); + } } break; @@ -1132,8 +1155,41 @@ void TgChat::Impl::PerformRequest(std::shared_ptr p_RequestMessa std::shared_ptr setCurrentChatRequest = std::static_pointer_cast(p_RequestMessage); int64_t chatId = StrUtil::NumFromHex(setCurrentChatRequest->chatId); - m_CurrentChat = chatId; - RequestSponsoredMessagesIfNeeded(); + if (chatId != m_CurrentChat) + { + if (m_CurrentChat != 0) + { + LOG_DEBUG("close chat %lld", chatId); + auto close_chat = td::td_api::make_object(); + close_chat->chat_id_ = m_CurrentChat; + SendQuery(std::move(close_chat), [](Object object) + { + if (object->get_id() == td::td_api::error::ID) + { + LOG_WARNING("close chat failed"); + return; + } + }); + } + + if (chatId != 0) + { + LOG_DEBUG("open chat %lld", chatId); + auto open_chat = td::td_api::make_object(); + open_chat->chat_id_ = chatId; + SendQuery(std::move(open_chat), [](Object object) + { + if (object->get_id() == td::td_api::error::ID) + { + LOG_WARNING("open chat failed"); + return; + } + }); + } + + m_CurrentChat = chatId; + RequestSponsoredMessagesIfNeeded(); + } } break; @@ -1146,6 +1202,156 @@ void TgChat::Impl::PerformRequest(std::shared_ptr p_RequestMessa } break; + case GetAvailableReactionsRequestType: + { + LOG_DEBUG("Get available reactions"); + std::shared_ptr getAvailableReactionsRequest = + std::static_pointer_cast(p_RequestMessage); + + int64_t chatId = StrUtil::NumFromHex(getAvailableReactionsRequest->chatId); + int64_t msgId = StrUtil::NumFromHex(getAvailableReactionsRequest->msgId); + + auto get_available_reactions = td::td_api::make_object(chatId, msgId, 8 /*row_size_ 5-25*/); + SendQuery(std::move(get_available_reactions), + [this, getAvailableReactionsRequest](Object object) + { + if (object->get_id() == td::td_api::error::ID) return; + + if (object->get_id() == td::td_api::availableReactions::ID) + { + std::set emojis; + auto available_reactions_ = td::move_tl_object_as(object); + GetReactionsEmojis(available_reactions_, emojis); + + std::shared_ptr availableReactionsNotify = + std::make_shared(m_ProfileId); + availableReactionsNotify->chatId = getAvailableReactionsRequest->chatId; + availableReactionsNotify->msgId = getAvailableReactionsRequest->msgId; + availableReactionsNotify->emojis = emojis; + CallMessageHandler(availableReactionsNotify); + } + }); + } + break; + + case SendReactionRequestType: + { + LOG_DEBUG("Send reaction"); + std::shared_ptr sendReactionRequest = + std::static_pointer_cast(p_RequestMessage); + + int64_t chatId = StrUtil::NumFromHex(sendReactionRequest->chatId); + int64_t msgId = StrUtil::NumFromHex(sendReactionRequest->msgId); + std::string emoji = sendReactionRequest->emoji; + std::string prevEmoji = sendReactionRequest->prevEmoji; + + if (!emoji.empty()) + { + auto reactionTypeEmoji = td::td_api::make_object(emoji); + bool is_big = false; + bool update_recent_reactions = true; + auto add_message_reaction = td::td_api::make_object(chatId, msgId, std::move(reactionTypeEmoji), + is_big, update_recent_reactions); + SendQuery(std::move(add_message_reaction), + [this, sendReactionRequest, emoji](Object object) + { + if (object->get_id() == td::td_api::error::ID) + { + LOG_WARNING("add message reaction error"); + return; + } + + Reactions reactions; + reactions.needConsolidationWithCache = true; + reactions.replaceCount = false; + reactions.senderEmojis[s_ReactionsSelfId] = emoji; + + std::shared_ptr newMessageReactionsNotify = + std::make_shared(m_ProfileId); + newMessageReactionsNotify->chatId = sendReactionRequest->chatId; + newMessageReactionsNotify->msgId = sendReactionRequest->msgId; + newMessageReactionsNotify->reactions = reactions; + CallMessageHandler(newMessageReactionsNotify); + + LOG_TRACE("added reaction"); + }); + } + else + { + auto reactionTypeEmoji = td::td_api::make_object(prevEmoji); + auto remove_message_reaction = td::td_api::make_object(chatId, msgId, std::move(reactionTypeEmoji)); + SendQuery(std::move(remove_message_reaction), + [this, sendReactionRequest, emoji](Object object) + { + if (object->get_id() == td::td_api::error::ID) + { + LOG_WARNING("remove message reaction error"); + return; + } + + Reactions reactions; + reactions.needConsolidationWithCache = true; + reactions.replaceCount = false; + reactions.senderEmojis[s_ReactionsSelfId] = emoji; + + std::shared_ptr newMessageReactionsNotify = + std::make_shared(m_ProfileId); + newMessageReactionsNotify->chatId = sendReactionRequest->chatId; + newMessageReactionsNotify->msgId = sendReactionRequest->msgId; + newMessageReactionsNotify->reactions = reactions; + CallMessageHandler(newMessageReactionsNotify); + + LOG_TRACE("removed reaction"); + }); + } + } + break; + + case GetUnreadReactionsRequestType: + { + LOG_DEBUG("Get unread reactions"); + std::shared_ptr getUnreadReactionsRequest = + std::static_pointer_cast(p_RequestMessage); + + int64_t chatId = StrUtil::NumFromHex(getUnreadReactionsRequest->chatId); + + auto search_chat_messages = td::td_api::make_object(); + search_chat_messages->chat_id_ = chatId; + search_chat_messages->limit_ = 100; + search_chat_messages->filter_ = td::td_api::make_object(); + + SendQuery(std::move(search_chat_messages), + [this, chatId](Object object) + { + if (object->get_id() == td::td_api::error::ID) return; + + if (object->get_id() == td::td_api::foundChatMessages::ID) + { + auto found_chat_messages = td::move_tl_object_as(object); + auto& messages = found_chat_messages->messages_; + + std::vector chatMessages; + for (auto it = messages.begin(); it != messages.end(); ++it) + { + auto message = td::move_tl_object_as(*it); + ChatMessage chatMessage; + TdMessageConvert(*message, chatMessage); + chatMessages.push_back(chatMessage); + } + + std::shared_ptr newMessagesNotify = + std::make_shared(m_ProfileId); + newMessagesNotify->success = true; + newMessagesNotify->chatId = StrUtil::NumToHex(chatId); + newMessagesNotify->chatMessages = chatMessages; + newMessagesNotify->fromMsgId = ""; + newMessagesNotify->sequence = false; + CallMessageHandler(newMessagesNotify); + } + }); + } + break; + default: LOG_DEBUG("unknown request message %d", p_RequestMessage->GetMessageType()); break; @@ -1557,6 +1763,49 @@ void TgChat::Impl::ProcessUpdate(td::td_api::object_ptr upda CallMessageHandler(updateMuteNotify); } }, + [this](td::td_api::updateMessageInteractionInfo& update_message_interaction_info) + { + LOG_TRACE("update message interaction info"); + + std::string chatId = StrUtil::NumToHex(update_message_interaction_info.chat_id_); + std::string msgId = StrUtil::NumToHex(update_message_interaction_info.message_id_); + + std::shared_ptr newMessageReactionsNotify = + std::make_shared(m_ProfileId); + + newMessageReactionsNotify->chatId = chatId; + newMessageReactionsNotify->msgId = msgId; + + auto interactionInfo = td::move_tl_object_as(update_message_interaction_info.interaction_info_); + if (interactionInfo) + { + GetMsgReactions(interactionInfo, newMessageReactionsNotify->reactions); + + std::shared_ptr markMessageReadRequest = + std::make_shared(); + markMessageReadRequest->chatId = chatId; + markMessageReadRequest->readAllReactions = true; + SendRequest(markMessageReadRequest); + } + + CallMessageHandler(newMessageReactionsNotify); + }, + [this](td::td_api::updateChatUnreadReactionCount& update_chat_unread_reaction_count) + { + LOG_TRACE("update chat unread reaction count"); + + // @todo: consider only fetching for current chat, and/or when switching chat. + int64_t chatId = update_chat_unread_reaction_count.chat_id_; + + std::shared_ptr getUnreadReactionsRequest = + std::make_shared(); + getUnreadReactionsRequest->chatId = StrUtil::NumToHex(chatId); + SendRequest(getUnreadReactionsRequest); + }, + [](td::td_api::updateMessageUnreadReactions&) + { + LOG_TRACE("update message unread reactions"); + }, [](td::td_api::updateRecentStickers&) { LOG_TRACE("update recent stickers"); @@ -1997,20 +2246,23 @@ std::uint64_t TgChat::Impl::GetNextQueryId() return ++m_CurrentQueryId; } -std::int64_t TgChat::Impl::GetSenderId(const td::td_api::message& p_TdMessage) +std::int64_t TgChat::Impl::GetSenderId(td::td_api::object_ptr&& p_TdMessageSender) { std::int64_t senderId = 0; - if (p_TdMessage.sender_id_->get_id() == td::td_api::messageSenderUser::ID) + if (p_TdMessageSender) { - auto& message_sender_user = - static_cast(*p_TdMessage.sender_id_); - senderId = message_sender_user.user_id_; - } - else if (p_TdMessage.sender_id_->get_id() == td::td_api::messageSenderChat::ID) - { - auto& message_sender_chat = - static_cast(*p_TdMessage.sender_id_); - senderId = message_sender_chat.chat_id_; + if (p_TdMessageSender->get_id() == td::td_api::messageSenderUser::ID) + { + auto& message_sender_user = + static_cast(*p_TdMessageSender); + senderId = message_sender_user.user_id_; + } + else if (p_TdMessageSender->get_id() == td::td_api::messageSenderChat::ID) + { + auto& message_sender_chat = + static_cast(*p_TdMessageSender); + senderId = message_sender_chat.chat_id_; + } } return senderId; @@ -2339,7 +2591,7 @@ void TgChat::Impl::TdMessageConvert(td::td_api::message& p_TdMessage, ChatMessag { if (!p_TdMessage.content_) return; - const int64_t senderId = GetSenderId(p_TdMessage); + const int64_t senderId = GetSenderId(td::move_tl_object_as(p_TdMessage.sender_id_)); TdMessageContentConvert(*p_TdMessage.content_, senderId, p_ChatMessage.text, p_ChatMessage.fileInfo); p_ChatMessage.id = StrUtil::NumToHex(p_TdMessage.id_); @@ -2378,6 +2630,11 @@ void TgChat::Impl::TdMessageConvert(td::td_api::message& p_TdMessage, ChatMessag p_ChatMessage.isRead = !p_TdMessage.contains_unread_mention_; } } + + if (p_TdMessage.interaction_info_) + { + GetMsgReactions(p_TdMessage.interaction_info_, p_ChatMessage.reactions); + } } void TgChat::Impl::DownloadFile(std::string p_ChatId, std::string p_MsgId, std::string p_FileId, @@ -2812,7 +3069,7 @@ void TgChat::Impl::SetProtocolUiControl(bool p_IsTakeControl) std::string TgChat::Impl::GetProfilePhoneNumber() { - std::vector profileInfo = StrUtil::Split(m_ProfileId, '_'); + std::vector profileInfo = StrUtil::Split(m_ProfileId, '_'); if (profileInfo.size() == 2) { return profileInfo.at(1); @@ -2820,3 +3077,68 @@ std::string TgChat::Impl::GetProfilePhoneNumber() return ""; } + +void TgChat::Impl::GetMsgReactions(td::td_api::object_ptr& p_InteractionInfo, + Reactions& p_Reactions) +{ + p_Reactions.needConsolidationWithCache = true; + p_Reactions.replaceCount = true; + auto messageReactions = td::move_tl_object_as(p_InteractionInfo->reactions_); + if (messageReactions) + { + for (auto it = messageReactions->reactions_.begin(); it != messageReactions->reactions_.end(); ++it) + { + auto messageReaction = td::move_tl_object_as(*it); + auto reactionTypeEmoji = td::move_tl_object_as(messageReaction->type_); + if (reactionTypeEmoji) + { + p_Reactions.emojiCounts[reactionTypeEmoji->emoji_] = messageReaction->total_count_; + + const int64_t usedSenderId = + GetSenderId(td::move_tl_object_as(messageReaction->used_sender_id_)); + if (IsSelf(usedSenderId)) + { + p_Reactions.senderEmojis[s_ReactionsSelfId] = reactionTypeEmoji->emoji_; + } + else + { + for (auto sit = messageReaction->recent_sender_ids_.begin(); + sit != messageReaction->recent_sender_ids_.end(); ++sit) + { + const int64_t senderId = GetSenderId(td::move_tl_object_as(*sit)); + if (IsSelf(senderId)) + { + p_Reactions.senderEmojis[s_ReactionsSelfId] = reactionTypeEmoji->emoji_; + break; + } + } + } + } + } + } +} + +void TgChat::Impl::GetReactionsEmojis(td::td_api::object_ptr& p_AvailableReactions, + std::set& p_Emojis) +{ + auto ReactionsArrayToEmojiSet = + [](td::td_api::array>& p_ReactionsArray, + std::set& p_EmojiSet) + { + for (auto it = p_ReactionsArray.begin(); it != p_ReactionsArray.end(); ++it) + { + if ((*it)->type_->get_id() != td::td_api::reactionTypeEmoji::ID) continue; + + auto reactionTypeEmoji = td::move_tl_object_as((*it)->type_); + if (reactionTypeEmoji) + { + p_EmojiSet.insert(reactionTypeEmoji->emoji_); + } + } + }; + + p_Emojis.clear(); + ReactionsArrayToEmojiSet(p_AvailableReactions->top_reactions_, p_Emojis); + ReactionsArrayToEmojiSet(p_AvailableReactions->recent_reactions_, p_Emojis); + ReactionsArrayToEmojiSet(p_AvailableReactions->popular_reactions_, p_Emojis); +} diff --git a/lib/wmchat/go/cgowm.go b/lib/wmchat/go/cgowm.go index 64254a94..3dc5ae12 100644 --- a/lib/wmchat/go/cgowm.go +++ b/lib/wmchat/go/cgowm.go @@ -15,6 +15,7 @@ package main // extern void WmNewStatusNotify(int p_ConnId, char* p_ChatId, char* p_UserId, int p_IsOnline, int p_IsTyping, int p_TimeSeen); // extern void WmNewMessageStatusNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, int p_IsRead); // extern void WmNewMessageFileNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p_FilePath, int p_FileStatus, int p_Action); +// extern void WmNewMessageReactionNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p_SenderId, char* p_Text, int p_FromMe); // extern void WmDeleteChatNotify(int p_ConnId, char* p_ChatId); // extern void WmUpdateMuteNotify(int p_ConnId, char* p_ChatId, int p_IsMuted); // extern void WmReinit(int p_ConnId); @@ -103,6 +104,11 @@ func CWmDownloadFile(connId int, chatId *C.char, msgId *C.char, fileId *C.char, return WmDownloadFile(connId, C.GoString(chatId), C.GoString(msgId), C.GoString(fileId), action) } +//export CWmSendReaction +func CWmSendReaction(connId int, chatId *C.char, senderId *C.char, msgId *C.char, emoji *C.char) int { + return WmSendReaction(connId, C.GoString(chatId), C.GoString(senderId), C.GoString(msgId), C.GoString(emoji)) +} + func CWmNewContactsNotify(connId int, chatId string, name string, phone string, isSelf int) { C.WmNewContactsNotify(C.int(connId), C.CString(chatId), C.CString(name), C.CString(phone), C.int(isSelf)) } @@ -127,6 +133,10 @@ func CWmNewMessageFileNotify(connId int, chatId string, msgId string, filePath s C.WmNewMessageFileNotify(C.int(connId), C.CString(chatId), C.CString(msgId), C.CString(filePath), C.int(fileStatus), C.int(action)) } +func CWmNewMessageReactionNotify(connId int, chatId string, msgId string, senderId string, text string, fromMe int) { + C.WmNewMessageReactionNotify(C.int(connId), C.CString(chatId), C.CString(msgId), C.CString(senderId), C.CString(text), C.int(fromMe)) +} + func CWmDeleteChatNotify(connId int, chatId string) { C.WmDeleteChatNotify(C.int(connId), C.CString(chatId)) } diff --git a/lib/wmchat/go/gowm.go b/lib/wmchat/go/gowm.go index 2fbc2de6..590059cd 100644 --- a/lib/wmchat/go/gowm.go +++ b/lib/wmchat/go/gowm.go @@ -949,6 +949,9 @@ func (handler *WmEventHandler) HandleMessage(messageInfo types.MessageInfo, msg case msg.TemplateMessage != nil: handler.HandleTemplateMessage(messageInfo, msg, isSync) + case msg.ReactionMessage != nil: + handler.HandleReactionMessage(messageInfo, msg, isSync) + default: handler.HandleUnsupportedMessage(messageInfo, msg, isSync) } @@ -1333,6 +1336,31 @@ func (handler *WmEventHandler) HandleTemplateMessage(messageInfo types.MessageIn CWmNewMessagesNotify(connId, chatId, msgId, senderId, text, BoolToInt(fromMe), quotedId, fileId, filePath, fileStatus, timeSent, BoolToInt(isRead)) } +func (handler *WmEventHandler) HandleReactionMessage(messageInfo types.MessageInfo, msg *waProto.Message, isSync bool) { + LOG_TRACE(fmt.Sprintf("ReactionMessage")) + + connId := handler.connId + + // get reaction part + reaction := msg.GetReactionMessage() + if reaction == nil { + LOG_WARNING(fmt.Sprintf("get reaction message failed")) + return + } + + chatId := GetChatId(messageInfo.Chat, messageInfo.Sender) + fromMe := messageInfo.IsFromMe + senderId := JidToStr(messageInfo.Sender) + text := reaction.GetText() + msgId := *reaction.Key.Id + + CWmNewMessageReactionNotify(connId, chatId, msgId, senderId, text, BoolToInt(fromMe)) + + // @todo: add auto-marking reactions of read, investigate why below does not work + //reMsgId := messageInfo.ID + //WmMarkMessageRead(connId, chatId, senderId, reMsgId) +} + func (handler *WmEventHandler) HandleUnsupportedMessage(messageInfo types.MessageInfo, msg *waProto.Message, isSync bool) { // list from type Message struct in def.pb.go msgType := "Unknown" @@ -2211,3 +2239,34 @@ func WmDownloadFile(connId int, chatId string, msgId string, fileId string, acti return 0 } + +func WmSendReaction(connId int, chatId string, senderId string, msgId string, emoji string) int { + + LOG_TRACE("send reaction " + strconv.Itoa(connId) + ", " + chatId + ", " + msgId + ", \"" + emoji + "\"") + + // sanity check arg + if connId == -1 { + LOG_WARNING("invalid connId") + return -1 + } + + // get client + client := GetClient(connId) + + // send reaction + chatJid, _ := types.ParseJID(chatId) + senderJid, _ := types.ParseJID(senderId) + _, sendErr := + client.SendMessage(context.Background(), chatJid, client.BuildReaction(chatJid, senderJid, msgId, emoji)) + + if sendErr != nil { + LOG_WARNING(fmt.Sprintf("send reaction error %#v", sendErr)) + return -1 + } else { + LOG_TRACE(fmt.Sprintf("send reaction ok")) + fromMe := true //messageInfo.IsFromMe + CWmNewMessageReactionNotify(connId, chatId, msgId, senderId, emoji, BoolToInt(fromMe)) + } + + return 0 +} diff --git a/lib/wmchat/src/wmchat.cpp b/lib/wmchat/src/wmchat.cpp index 7d688e6a..9d59e19e 100644 --- a/lib/wmchat/src/wmchat.cpp +++ b/lib/wmchat/src/wmchat.cpp @@ -544,6 +544,26 @@ void WmChat::PerformRequest(std::shared_ptr p_RequestMessage) } break; + case SendReactionRequestType: + { + LOG_DEBUG("send reaction"); + + std::shared_ptr sendReactionRequest = + std::static_pointer_cast(p_RequestMessage); + std::string chatId = sendReactionRequest->chatId; + std::string senderId = sendReactionRequest->senderId; + std::string msgId = sendReactionRequest->msgId; + std::string emoji = sendReactionRequest->emoji; + + CWmSendReaction(m_ConnId, + const_cast(chatId.c_str()), + const_cast(senderId.c_str()), + const_cast(msgId.c_str()), + const_cast(emoji.c_str()) + ); + } + break; + case DeferNotifyRequestType: { std::shared_ptr deferNotifyRequest = @@ -818,6 +838,38 @@ void WmNewMessageFileNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p free(p_FilePath); } +void WmNewMessageReactionNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p_SenderId, char* p_Text, + int p_FromMe) +{ + WmChat* instance = WmChat::GetInstance(p_ConnId); + if (instance == nullptr) return; + + { + const std::string senderId = (p_FromMe == 1) ? s_ReactionsSelfId : std::string(p_SenderId); + Reactions reactions; + // go via message cache for consolidation and count before handled by ui + reactions.needConsolidationWithCache = true; + reactions.updateCountBasedOnSender = true; + reactions.replaceCount = false; + reactions.senderEmojis[senderId] = std::string(p_Text); + + std::shared_ptr newMessageReactionsNotify = + std::make_shared(instance->GetProfileId()); + newMessageReactionsNotify->chatId = std::string(p_ChatId); + newMessageReactionsNotify->msgId = std::string(p_MsgId); + newMessageReactionsNotify->reactions = reactions; + + std::shared_ptr deferNotifyRequest = std::make_shared(); + deferNotifyRequest->serviceMessage = newMessageReactionsNotify; + instance->SendRequest(deferNotifyRequest); + } + + free(p_ChatId); + free(p_MsgId); + free(p_SenderId); + free(p_Text); +} + void WmDeleteChatNotify(int p_ConnId, char* p_ChatId) { WmChat* instance = WmChat::GetInstance(p_ConnId); diff --git a/lib/wmchat/src/wmchat.h b/lib/wmchat/src/wmchat.h index 7de0720c..bc120de5 100644 --- a/lib/wmchat/src/wmchat.h +++ b/lib/wmchat/src/wmchat.h @@ -95,6 +95,8 @@ void WmNewStatusNotify(int p_ConnId, char* p_ChatId, char* p_UserId, int p_IsOnl void WmNewMessageStatusNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, int p_IsRead); void WmNewMessageFileNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p_FilePath, int p_FileStatus, int p_Action); +void WmNewMessageReactionNotify(int p_ConnId, char* p_ChatId, char* p_MsgId, char* p_SenderId, char* p_Text, + int p_FromMe); void WmDeleteChatNotify(int p_ConnId, char* p_ChatId); void WmUpdateMuteNotify(int p_ConnId, char* p_ChatId, int p_IsMuted); void WmReinit(int p_ConnId); diff --git a/src/main.cpp b/src/main.cpp index 24863398..a09e6e67 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -73,7 +73,8 @@ class ProtocolFactory : public ProtocolBaseFactory std::shared_ptr protocol; #ifdef HAS_DYNAMICLOAD std::string libPath = - FileUtil::DirName(FileUtil::GetSelfPath()) + "/../" CMAKE_INSTALL_LIBDIR "/" + T::GetLibName() + FileUtil::GetLibSuffix(); + FileUtil::DirName(FileUtil::GetSelfPath()) + "/../" CMAKE_INSTALL_LIBDIR "/" + T::GetLibName() + + FileUtil::GetLibSuffix(); std::string createFunc = T::GetCreateFunc(); void* handle = dlopen(libPath.c_str(), RTLD_LAZY); if (handle == nullptr) @@ -572,10 +573,10 @@ void ShowHelp() " Ctrl-x send message\n" " Ctrl-y toggle show emojis\n" " KeyUp select message\n" + " Alt-$ external spell check\n" " Alt-, decrease contact list width\n" " Alt-. increase contact list width\n" " Alt-e external editor compose\n" - " Alt-s external spell check\n" " Alt-t external telephone call\n" "\n" "Interactive Commands for Selected Message:\n" @@ -587,6 +588,7 @@ void ShowHelp() " Ctrl-z edit selected message\n" " Alt-w external message viewer\n" " Alt-c copy selected message to clipboard\n" + " Alt-s add/remove reaction on selected message\n" "\n" "Interactive Commands for Text Input:\n" " Ctrl-a move cursor to start of line\n" diff --git a/src/nchat.1 b/src/nchat.1 index 48e9d631..b53344ca 100644 --- a/src/nchat.1 +++ b/src/nchat.1 @@ -1,5 +1,5 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man. -.TH NCHAT "1" "April 2024" "nchat v4.59" "User Commands" +.TH NCHAT "1" "April 2024" "nchat v4.60" "User Commands" .SH NAME nchat \- ncurses chat .SH SYNOPSIS @@ -85,6 +85,9 @@ toggle show emojis KeyUp select message .TP +Alt\-$ +external spell check +.TP Alt\-, decrease contact list width .TP @@ -94,9 +97,6 @@ increase contact list width Alt\-e external editor compose .TP -Alt\-s -external spell check -.TP Alt\-t external telephone call .SS "Interactive Commands for Selected Message:" @@ -124,6 +124,9 @@ external message viewer .TP Alt\-c copy selected message to clipboard +.TP +Alt\-s +add/remove reaction on selected message .SS "Interactive Commands for Text Input:" .TP Ctrl\-a diff --git a/src/uicolorconfig.cpp b/src/uicolorconfig.cpp index 5b837e51..6eb4b4cb 100644 --- a/src/uicolorconfig.cpp +++ b/src/uicolorconfig.cpp @@ -27,6 +27,7 @@ void UiColorConfig::Init() start_color(); } + const std::string defaultReactionColor = (COLORS > 8) ? "gray" : ""; const std::string defaultSentColor = (COLORS > 8) ? "gray" : ""; const std::string defaultShadedColor = (COLORS > 8) ? "gray" : ""; const std::string defaultQuotedColor = (COLORS > 8) ? "gray" : ""; @@ -57,6 +58,8 @@ void UiColorConfig::Init() { "history_text_attr_selected", "reverse" }, { "history_text_sent_color_bg", "" }, { "history_text_sent_color_fg", defaultSentColor }, + { "history_text_reaction_color_bg", "" }, + { "history_text_reaction_color_fg", defaultReactionColor }, { "history_text_recv_color_bg", "" }, { "history_text_recv_color_fg", "" }, { "history_text_quoted_color_bg", "" }, diff --git a/src/uiconfig.cpp b/src/uiconfig.cpp index 23f5ffbc..e97efd6f 100644 --- a/src/uiconfig.cpp +++ b/src/uiconfig.cpp @@ -1,6 +1,6 @@ // uiconfig.cpp // -// Copyright (c) 2019-2023 Kristofer Berggren +// Copyright (c) 2019-2024 Kristofer Berggren // All rights reserved. // // nchat is distributed under the MIT license, see LICENSE for details. @@ -48,6 +48,7 @@ void UiConfig::Init() { "phone_number_indicator", "" }, { "proxy_indicator", "\xF0\x9F\x94\x92" }, { "read_indicator", "\xe2\x9c\x93" }, + { "reactions_enabled", "1" }, { "spell_check_command", "" }, { "syncing_indicator", "\xe2\x87\x84" }, { "terminal_bell_active", "0" }, diff --git a/src/uiemojilistdialog.cpp b/src/uiemojilistdialog.cpp index bbc465bc..49527b0f 100644 --- a/src/uiemojilistdialog.cpp +++ b/src/uiemojilistdialog.cpp @@ -8,36 +8,61 @@ #include "uiemojilistdialog.h" #include "emojilist.h" +#include "log.h" #include "strutil.h" #include "uimodel.h" -UiEmojiListDialog::UiEmojiListDialog(const UiDialogParams& p_Params) +static std::pair s_NoneOptionTextEmoji("[none]", ""); + +UiEmojiListDialog::UiEmojiListDialog(const UiDialogParams& p_Params, const std::string& p_DefaultOption /*= ""*/, + bool p_HasNoneOption /*= false*/, bool p_HasLimitedEmojis /*= false*/) : UiListDialog(p_Params, true /*p_ShadeHidden*/) + , m_DefaultOption(p_DefaultOption) + , m_HasNoneOption(p_HasNoneOption) + , m_HasLimitedEmojis(p_HasLimitedEmojis) + , m_HasAvailableEmojisPending(p_HasLimitedEmojis) { + if (m_HasLimitedEmojis) + { + m_Model->GetAvailableEmojis(m_AvailableEmojis, m_HasAvailableEmojisPending); + } + UpdateList(); + SetSelectedEmoji(m_DefaultOption); } UiEmojiListDialog::~UiEmojiListDialog() { } -std::wstring UiEmojiListDialog::GetSelectedEmoji() +std::wstring UiEmojiListDialog::GetSelectedEmoji(bool p_EmojiEnabled) { - return m_SelectedEmoji; + return StrUtil::ToWString(p_EmojiEnabled ? m_SelectedTextEmoji.second : m_SelectedTextEmoji.first); } -void UiEmojiListDialog::OnSelect() +void UiEmojiListDialog::SetSelectedEmoji(const std::string& p_Emoji) { - if (m_TextEmojis.empty()) return; + if (p_Emoji.empty()) return; - EmojiList::AddUsage(std::next(m_TextEmojis.begin(), m_Index)->first); - if (m_Model->GetEmojiEnabled()) + for (auto it = m_TextEmojis.begin(); it != m_TextEmojis.end(); ++it) { - m_SelectedEmoji = StrUtil::ToWString(std::next(m_TextEmojis.begin(), m_Index)->second); + if (it->second == p_Emoji) + { + m_Index = std::distance(m_TextEmojis.begin(), it); + break; + } } - else +} + +void UiEmojiListDialog::OnSelect() +{ + if (m_TextEmojis.empty()) return; + + m_SelectedTextEmoji = *std::next(m_TextEmojis.begin(), m_Index); // ex: (":thumbsup:", 0xf0 0x9f 0x91 0x8d) + + if (m_SelectedTextEmoji != s_NoneOptionTextEmoji) { - m_SelectedEmoji = StrUtil::ToWString(std::next(m_TextEmojis.begin(), m_Index)->first); + EmojiList::AddUsage(std::next(m_TextEmojis.begin(), m_Index)->first); } m_Result = true; @@ -50,21 +75,54 @@ void UiEmojiListDialog::OnBack() bool UiEmojiListDialog::OnTimer() { - return false; + bool rv = false; + if (m_HasLimitedEmojis && m_HasAvailableEmojisPending) + { + m_Model->GetAvailableEmojis(m_AvailableEmojis, m_HasAvailableEmojisPending); + if (!m_HasAvailableEmojisPending) + { + UpdateList(); + SetSelectedEmoji(m_DefaultOption); + rv = true; + } + } + + return rv; } void UiEmojiListDialog::UpdateList() { - const bool emojiEnabled = m_Model->GetEmojiEnabled(); + std::string selectedEmoji; + if (!m_TextEmojis.empty()) + { + selectedEmoji = std::next(m_TextEmojis.begin(), m_Index)->second; + } + m_Index = 0; m_Items.clear(); m_TextEmojis.clear(); std::vector> textEmojis = EmojiList::Get(StrUtil::ToString(m_FilterStr)); + + if (m_HasNoneOption) + { + textEmojis.insert(textEmojis.begin(), s_NoneOptionTextEmoji); + if (!m_FilterStr.empty()) + { + m_Index = 1; + } + } + + const bool emojiEnabled = m_Model->GetEmojiEnabled(); for (auto& textEmoji : textEmojis) { - std::wstring desc = StrUtil::ToWString(textEmoji.first); - std::wstring item = StrUtil::ToWString(textEmoji.second); - if (StrUtil::WStringWidth(item) <= 0) continue; // mainly for mac + if (m_HasLimitedEmojis) + { + if (!m_AvailableEmojis.count(textEmoji.second) && !textEmoji.second.empty()) continue; + } + + std::wstring desc = StrUtil::ToWString(textEmoji.first); // ex: :thumbsup: + std::wstring item = StrUtil::ToWString(textEmoji.second); // ex: 0xf0 0x9f 0x91 0x8d + if ((StrUtil::WStringWidth(item) <= 0) && (!item.empty())) continue; // mainly for mac if (emojiEnabled) { @@ -83,4 +141,6 @@ void UiEmojiListDialog::UpdateList() m_Items.push_back(StrUtil::TrimPadWString(item, m_W)); m_TextEmojis.push_back(textEmoji); } + + SetSelectedEmoji(selectedEmoji); } diff --git a/src/uiemojilistdialog.h b/src/uiemojilistdialog.h index fa9d6689..705aa8c9 100644 --- a/src/uiemojilistdialog.h +++ b/src/uiemojilistdialog.h @@ -1,6 +1,6 @@ // uiemojilistdialog.h // -// Copyright (c) 2019-2021 Kristofer Berggren +// Copyright (c) 2019-2024 Kristofer Berggren // All rights reserved. // // nchat is distributed under the MIT license, see LICENSE for details. @@ -9,13 +9,16 @@ #include "uilistdialog.h" +#include + class UiEmojiListDialog : public UiListDialog { public: - UiEmojiListDialog(const UiDialogParams& p_Params); + UiEmojiListDialog(const UiDialogParams& p_Params, const std::string& p_DefaultOption = "", + bool p_HasNoneOption = false, bool p_HasLimitedEmojis = false); virtual ~UiEmojiListDialog(); - std::wstring GetSelectedEmoji(); + std::wstring GetSelectedEmoji(bool p_EmojiEnabled); protected: virtual void OnSelect(); @@ -24,7 +27,15 @@ class UiEmojiListDialog : public UiListDialog void UpdateList(); +private: + void SetSelectedEmoji(const std::string& p_Emoji); + private: std::vector> m_TextEmojis; - std::wstring m_SelectedEmoji; + std::pair m_SelectedTextEmoji; + std::set m_AvailableEmojis; + std::string m_DefaultOption; + bool m_HasNoneOption = false; + bool m_HasLimitedEmojis = false; + bool m_HasAvailableEmojisPending = false; }; diff --git a/src/uihistoryview.cpp b/src/uihistoryview.cpp index 77c13e4d..c5f98abe 100644 --- a/src/uihistoryview.cpp +++ b/src/uihistoryview.cpp @@ -1,6 +1,6 @@ // uihistoryview.cpp // -// Copyright (c) 2019-2023 Kristofer Berggren +// Copyright (c) 2019-2024 Kristofer Berggren // All rights reserved. // // nchat is distributed under the MIT license, see LICENSE for details. @@ -10,6 +10,7 @@ #include "appconfig.h" #include "apputil.h" #include "fileutil.h" +#include "log.h" #include "protocolutil.h" #include "strutil.h" #include "timeutil.h" @@ -59,6 +60,7 @@ void UiHistoryView::Draw() static int colorPairTextSent = UiColorConfig::GetColorPair("history_text_sent_color"); static int colorPairTextRecv = UiColorConfig::GetColorPair("history_text_recv_color"); static int colorPairTextQuoted = UiColorConfig::GetColorPair("history_text_quoted_color"); + static int colorPairTextReaction = UiColorConfig::GetColorPair("history_text_reaction_color"); static int colorPairTextAttachment = UiColorConfig::GetColorPair("history_text_attachment_color"); static int attributeTextNormal = UiColorConfig::GetAttribute("history_text_attr"); static int attributeTextSelected = UiColorConfig::GetAttribute("history_text_attr_selected"); @@ -91,7 +93,6 @@ void UiHistoryView::Draw() for (auto it = std::next(messageVec.begin(), messageOffset); it != messageVec.end(); ++it) { bool isSelectedMessage = firstMessage && m_Model->GetSelectMessageActive(); - firstMessage = false; ChatMessage& msg = messages[*it]; @@ -126,6 +127,7 @@ void UiHistoryView::Draw() wlines = StrUtil::WordWrap(StrUtil::ToWString(text), m_PaddedW, false, false, false, 2); } + // Quoted message if (!msg.quotedId.empty()) { std::string quotedText; @@ -162,6 +164,7 @@ void UiHistoryView::Draw() wlines.insert(wlines.begin(), quote); } + // File attachment if (!msg.fileInfo.empty()) { FileInfo fileInfo = ProtocolUtil::FileInfoFromHex(msg.fileInfo); @@ -214,19 +217,101 @@ void UiHistoryView::Draw() wlines.insert(wlines.begin(), fileStr); } + // Reactions + int reactionLines = 0; + static bool reactionsEnabled = UiConfig::GetBool("reactions_enabled"); + if (reactionsEnabled) + { + std::string selfEmoji; + auto sit = msg.reactions.senderEmojis.find(s_ReactionsSelfId); + if (sit != msg.reactions.senderEmojis.end()) + { + selfEmoji = sit->second; + } + + // Allow also if we have self emoji, even if not yet consolidated into count + if (!msg.reactions.emojiCounts.empty() || !selfEmoji.empty()) + { + bool foundSelf = false; + std::string reactionsText; + std::multimap emojiCountsSorted; + for (const auto& emojiCount : msg.reactions.emojiCounts) + { + float count = emojiCount.second; + if (emojiCount.first == selfEmoji) + { + count += 0.1; // for equal count, prioritize own selected reaction + foundSelf = true; + } + + emojiCountsSorted.insert(std::make_pair(count, emojiCount.first)); + } + + if (!foundSelf && !selfEmoji.empty()) + { + LOG_DEBUG("insert missing reaction for self"); + emojiCountsSorted.insert(std::make_pair(1.1, selfEmoji)); + } + + bool firstReaction = true; + for (auto emojiCount = emojiCountsSorted.rbegin(); emojiCount != emojiCountsSorted.rend(); ++emojiCount) + { + reactionsText += (firstReaction ? " " : " "); + if (emojiCount->second == selfEmoji) + { + // Highlight own reaction emoji + reactionsText += "" + emojiCount->second + "*"; + } + else + { + reactionsText += emojiCount->second; + } + + if (emojiCount->first > 1.5) + { + reactionsText += " " + FileUtil::GetSuffixedCount(static_cast(emojiCount->first)); + } + + firstReaction = false; + } + + if (!reactionsText.empty()) + { + if (!emojiEnabled) + { + reactionsText = StrUtil::Textize(reactionsText); + } + + const int maxReactionsLen = m_PaddedW - 4; + std::wstring reactions = StrUtil::ToWString(reactionsText); + if (StrUtil::WStringWidth(reactions) > maxReactionsLen) + { + reactions = StrUtil::TrimPadWString(reactions, maxReactionsLen) + L"... "; + } + else + { + reactions += L" "; + } + + wlines.insert(wlines.end(), reactions); + reactionLines = 1; + } + } + } + const int maxMessageLines = (m_PaddedH - 1); - if ((int)wlines.size() > maxMessageLines) + if (firstMessage && ((int)wlines.size() > maxMessageLines)) { wlines.resize(maxMessageLines - 1); wlines.push_back(L"[...]"); + reactionLines = 0; } for (auto wline = wlines.rbegin(); wline != wlines.rend(); ++wline) { - std::wstring wdisp = StrUtil::TrimPadWString(*wline, m_PaddedW); - - bool isAttachment = (wdisp.rfind(attachmentIndicator, 0) == 0); - bool isQuote = (wdisp.rfind(quoteIndicator, 0) == 0); + bool isAttachment = (wline->rfind(attachmentIndicator, 0) == 0); + bool isQuote = (wline->rfind(quoteIndicator, 0) == 0); + bool isReaction = (reactionLines == 1) && (std::distance(wline, wlines.rbegin()) == 0); if (isAttachment) { @@ -236,11 +321,16 @@ void UiHistoryView::Draw() { wattron(m_PaddedWin, attributeText | colorPairTextQuoted); } + else if (isReaction) + { + wattron(m_PaddedWin, attributeTextNormal | colorPairTextReaction); + } else { wattron(m_PaddedWin, attributeText | colorPairText); } + const std::wstring wdisp = isReaction ? *wline : StrUtil::TrimPadWString(*wline, m_PaddedW); mvwaddnwstr(m_PaddedWin, y, 0, wdisp.c_str(), std::min((int)wdisp.size(), m_PaddedW)); if (isAttachment) @@ -251,6 +341,10 @@ void UiHistoryView::Draw() { wattroff(m_PaddedWin, attributeText | colorPairTextQuoted); } + else if (isReaction) + { + wattroff(m_PaddedWin, attributeTextNormal | colorPairTextReaction); + } else { wattroff(m_PaddedWin, attributeText | colorPairText); @@ -294,10 +388,7 @@ void UiHistoryView::Draw() wtime = L" (" + StrUtil::ToWString(TimeUtil::GetTimeString(msg.timeSent, false /* p_IsExport */)) + L")"; } - if (!msg.isOutgoing && !msg.isRead) - { - m_Model->MarkRead(currentChat.first, currentChat.second, *it); - } + m_Model->MarkRead(currentChat.first, currentChat.second, *it, (!msg.isOutgoing && !msg.isRead)); static const std::string readIndicator = " " + UiConfig::GetStr("read_indicator"); std::wstring wreceipt = StrUtil::ToWString(msg.isRead ? readIndicator : ""); @@ -321,6 +412,8 @@ void UiHistoryView::Draw() if (--y < 0) break; if (--y < 0) break; + + firstMessage = false; } wrefresh(m_PaddedWin); diff --git a/src/uikeyconfig.cpp b/src/uikeyconfig.cpp index f5a9c539..0cb6eaee 100644 --- a/src/uikeyconfig.cpp +++ b/src/uikeyconfig.cpp @@ -217,7 +217,8 @@ void UiKeyConfig::Init(bool p_MapKeys) { "paste", "\\33\\166" }, // alt/opt-v { "ext_call", "\\33\\164" }, // alt/opt-t { "ext_edit", "\\33\\145" }, // alt/opt-e - { "spell", "\\33\\163" }, // alt/opt-s + { "react", "\\33\\163" }, // alt/opt-s + { "spell", "\\33\\44" }, // alt/opt-$ { "toggle_emoji", "KEY_CTRLY" }, { "toggle_help", "KEY_CTRLG" }, { "toggle_list", "KEY_CTRLL" }, diff --git a/src/uimodel.cpp b/src/uimodel.cpp index 0dad06d8..849d7b58 100644 --- a/src/uimodel.cpp +++ b/src/uimodel.cpp @@ -88,6 +88,7 @@ void UiModel::KeyHandler(wint_t p_Key) static wint_t keyCopy = UiKeyConfig::GetKey("copy"); static wint_t keyPaste = UiKeyConfig::GetKey("paste"); + static wint_t keyReact = UiKeyConfig::GetKey("react"); static wint_t keySpell = UiKeyConfig::GetKey("spell"); static wint_t keyToggleList = UiKeyConfig::GetKey("toggle_list"); @@ -245,6 +246,10 @@ void UiModel::KeyHandler(wint_t p_Key) { Paste(); } + else if (p_Key == keyReact) + { + React(); + } else if (p_Key == keySpell) { ExternalSpell(); @@ -926,8 +931,12 @@ void UiModel::ResetMessageOffset() UpdateHistory(); } -void UiModel::MarkRead(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId) +void UiModel::MarkRead(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId, + bool p_WasUnread) { + const bool markReadEveryView = HasProtocolFeature(p_ProfileId, FeatureMarkReadEveryView); + if (!markReadEveryView && !p_WasUnread) return; + static const bool markReadOnView = UiConfig::GetBool("mark_read_on_view"); if (!markReadOnView && !m_HistoryInteraction) return; @@ -939,7 +948,10 @@ void UiModel::MarkRead(const std::string& p_ProfileId, const std::string& p_Chat auto mit = messages.find(p_MsgId); if (mit != messages.end()) { - mit->second.isRead = true; + if (p_WasUnread) + { + mit->second.isRead = true; + } senderId = mit->second.senderId; } @@ -1421,9 +1433,8 @@ void UiModel::InsertEmoji() UiEmojiListDialog dialog(params); if (dialog.Run()) { - std::wstring emoji = dialog.GetSelectedEmoji(); - std::unique_lock lock(m_ModelMutex); + std::wstring emoji = dialog.GetSelectedEmoji(GetEmojiEnabled()); std::string profileId = m_CurrentChat.first; std::string chatId = m_CurrentChat.second; @@ -1835,6 +1846,29 @@ void UiModel::MessageHandler(std::shared_ptr p_ServiceMessage) } break; + case NewMessageReactionsNotifyType: + { + std::shared_ptr newMessageReactionsNotify = + std::static_pointer_cast(p_ServiceMessage); + if (!newMessageReactionsNotify->reactions.needConsolidationWithCache) + { + std::string chatId = newMessageReactionsNotify->chatId; + std::string msgId = newMessageReactionsNotify->msgId; + + LOG_TRACE("new reactions for %s in %s count %d", msgId.c_str(), chatId.c_str(), + newMessageReactionsNotify->reactions.emojiCounts.size()); + std::unordered_map& messages = m_Messages[profileId][chatId]; + auto mit = messages.find(msgId); + if (mit != messages.end()) + { + mit->second.reactions = newMessageReactionsNotify->reactions; + } + + UpdateHistory(); + } + } + break; + case ReceiveTypingNotifyType: { std::shared_ptr receiveTypingNotify = std::static_pointer_cast( @@ -1969,6 +2003,18 @@ void UiModel::MessageHandler(std::shared_ptr p_ServiceMessage) } break; + case AvailableReactionsNotifyType: + { + std::shared_ptr availableReactionsNotify = + std::static_pointer_cast(p_ServiceMessage); + std::string chatId = availableReactionsNotify->chatId; + // ignore msgId for now + m_AvailableReactions[profileId][chatId] = availableReactionsNotify->emojis; + m_AvailableReactionsPending[profileId][chatId] = false; + LOG_TRACE("available reactions notify %s count %d", chatId.c_str(), availableReactionsNotify->emojis.size()); + } + break; + default: LOG_DEBUG("unknown service message %d", p_ServiceMessage->GetMessageType()); break; @@ -3312,3 +3358,98 @@ void UiModel::HandleProtocolUiControl(std::unique_lock& lock) LOG_TRACE("handle protocol ui control end"); } + +void UiModel::React() +{ + if (!GetSelectMessageActive() || GetEditMessageActive()) return; + + std::string profileId; + std::string chatId; + std::string senderId; + std::string msgId; + std::string selfEmoji; + bool hasLimitedReactions = false; + + { + std::unique_lock lock(m_ModelMutex); + + profileId = m_CurrentChat.first; + chatId = m_CurrentChat.second; + const std::vector& messageVec = m_MessageVec[profileId][chatId]; + const int messageOffset = m_MessageOffset[profileId][chatId]; + auto it = std::next(messageVec.begin(), messageOffset); + if (it == messageVec.end()) return; + + msgId = *it; + const std::unordered_map& messages = m_Messages[profileId][chatId]; + auto mit = messages.find(msgId); + if (mit == messages.end()) + { + LOG_WARNING("message %s missing", msgId.c_str()); + return; + } + else + { + senderId = mit->second.senderId; + auto sit = mit->second.reactions.senderEmojis.find(s_ReactionsSelfId); + if (sit != mit->second.reactions.senderEmojis.end()) + { + selfEmoji = sit->second; + } + } + + hasLimitedReactions = HasProtocolFeature(profileId, FeatureLimitedReactions); + if (hasLimitedReactions) + { + LOG_TRACE("request available reactions"); + m_AvailableReactionsPending[profileId][chatId] = true; + std::shared_ptr getAvailableReactionsRequest = + std::make_shared(); + getAvailableReactionsRequest->chatId = chatId; + getAvailableReactionsRequest->msgId = msgId; + SendProtocolRequest(profileId, getAvailableReactionsRequest); + } + } + + UiDialogParams params(m_View.get(), this, "Set Reaction", 0.75, 0.65); + UiEmojiListDialog dialog(params, selfEmoji, true /*p_HasNone*/, hasLimitedReactions); + if (dialog.Run()) + { + std::string emoji = StrUtil::ToString(dialog.GetSelectedEmoji(true /*p_EmojiEnabled*/)); + + std::unique_lock lock(m_ModelMutex); + + if (emoji != selfEmoji) + { + std::shared_ptr sendReactionRequest = std::make_shared(); + sendReactionRequest->chatId = chatId; + sendReactionRequest->senderId = senderId; + sendReactionRequest->msgId = msgId; + sendReactionRequest->emoji = emoji; + sendReactionRequest->prevEmoji = selfEmoji; + SendProtocolRequest(profileId, sendReactionRequest); + + UpdateHistory(); + } + } + + ReinitView(); +} + +void UiModel::GetAvailableEmojis(std::set& p_AvailableEmojis, bool& p_Pending) +{ + std::unique_lock lock(m_ModelMutex); + + std::string profileId = m_CurrentChat.first; + std::string chatId = m_CurrentChat.second; + + p_AvailableEmojis.clear(); + p_Pending = m_AvailableReactionsPending[profileId][chatId]; + auto it = m_AvailableReactions[profileId].find(chatId); + if (it != m_AvailableReactions[profileId].end()) + { + p_AvailableEmojis = it->second; + } + + LOG_DEBUG("get available reactions %d pending %d", p_AvailableEmojis.size(), p_Pending); +} diff --git a/src/uimodel.h b/src/uimodel.h index 4effb29c..e366e17d 100644 --- a/src/uimodel.h +++ b/src/uimodel.h @@ -40,7 +40,8 @@ class UiModel void Home(); void HomeFetchNext(const std::string& p_ProfileId, const std::string& p_ChatId, int p_MsgCount); void End(); - void MarkRead(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId); + void MarkRead(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId, + bool p_WasUnread); void DownloadAttachment(const std::string& p_ProfileId, const std::string& p_ChatId, const std::string& p_MsgId, const std::string& p_FileId, DownloadFileAction p_DownloadFileAction); void DeleteMessage(); @@ -112,6 +113,7 @@ class UiModel bool IsMultipleProfiles(); std::string GetProfileDisplayName(const std::string& p_ProfileId); + void GetAvailableEmojis(std::set& p_AvailableEmojis, bool& p_Pending); static bool IsAttachmentDownloaded(const FileInfo& p_FileInfo); static bool IsAttachmentDownloadable(const FileInfo& p_FileInfo); @@ -159,6 +161,7 @@ class UiModel void EntryConvertEmojiEnabled(); void SetProtocolUiControl(const std::string& p_ProfileId, bool& p_IsTakeControl); void HandleProtocolUiControl(std::unique_lock& p_Lock); + void React(); private: bool m_Running = true; @@ -199,6 +202,9 @@ class UiModel std::unordered_map> m_UserOnline; std::unordered_map> m_UserTimeSeen; + std::unordered_map>> m_AvailableReactions; + std::unordered_map> m_AvailableReactionsPending; + bool m_SelectMessageActive = false; bool m_ListDialogActive = false; bool m_MessageDialogActive = false; diff --git a/themes/basic-color/color.conf b/themes/basic-color/color.conf index caa0c88b..88217799 100644 --- a/themes/basic-color/color.conf +++ b/themes/basic-color/color.conf @@ -1,7 +1,11 @@ +default_color_bg= +default_color_fg= dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg= +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -22,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=gray +history_text_reaction_color_bg=0x222222 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/catppuccin-mocha/color.conf b/themes/catppuccin-mocha/color.conf index bb2d66fb..74350288 100644 --- a/themes/catppuccin-mocha/color.conf +++ b/themes/catppuccin-mocha/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xbac2de +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xbac2de +history_text_reaction_color_bg=0x2e2e3e +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/default/color.conf b/themes/default/color.conf index e8f1fb8d..297c0dee 100644 --- a/themes/default/color.conf +++ b/themes/default/color.conf @@ -1,7 +1,11 @@ +default_color_bg= +default_color_fg= dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg= +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -22,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=gray +history_text_reaction_color_bg= +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/dracula/color.conf b/themes/dracula/color.conf index 395ade39..d9e6b099 100644 --- a/themes/dracula/color.conf +++ b/themes/dracula/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xbbbbbb +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xbbbbbb +history_text_reaction_color_bg=0x2b2f42 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/espresso/color.conf b/themes/espresso/color.conf index c61a045e..887af693 100644 --- a/themes/espresso/color.conf +++ b/themes/espresso/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xeeeeec +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xeeeeec +history_text_reaction_color_bg=0x424242 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/gruvbox-dark/color.conf b/themes/gruvbox-dark/color.conf index c0b0bb80..81ca287f 100644 --- a/themes/gruvbox-dark/color.conf +++ b/themes/gruvbox-dark/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xa89984 +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xa89984 +history_text_reaction_color_bg=0x383838 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/solarized-dark-higher-contrast/color.conf b/themes/solarized-dark-higher-contrast/color.conf index cb491e80..c0cd4069 100644 --- a/themes/solarized-dark-higher-contrast/color.conf +++ b/themes/solarized-dark-higher-contrast/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xeae3cb +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xeae3cb +history_text_reaction_color_bg=0x102e37 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/tokyo-night/color.conf b/themes/tokyo-night/color.conf index 781a420e..bbf8a285 100644 --- a/themes/tokyo-night/color.conf +++ b/themes/tokyo-night/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xa9b1d6 +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xa9b1d6 +history_text_reaction_color_bg=0x2a2b46 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/tomorrow-night/color.conf b/themes/tomorrow-night/color.conf index 00367b1a..46e38429 100644 --- a/themes/tomorrow-night/color.conf +++ b/themes/tomorrow-night/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xffffff +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xffffff +history_text_reaction_color_bg=0x2d2f31 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/zenbones-dark/color.conf b/themes/zenbones-dark/color.conf index a34dbb3c..204c8f2e 100644 --- a/themes/zenbones-dark/color.conf +++ b/themes/zenbones-dark/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xb4bdc3 +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xb4bdc3 +history_text_reaction_color_bg=0x2c2927 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg= diff --git a/themes/zenburned/color.conf b/themes/zenburned/color.conf index 2d25133a..4a9635d3 100644 --- a/themes/zenburned/color.conf +++ b/themes/zenburned/color.conf @@ -4,6 +4,8 @@ dialog_attr= dialog_attr_selected=reverse dialog_color_bg= dialog_color_fg=0xf0e4cf +dialog_shaded_color_bg= +dialog_shaded_color_fg=gray entry_attr= entry_color_bg= entry_color_fg= @@ -24,6 +26,8 @@ history_text_attr= history_text_attr_selected=reverse history_text_quoted_color_bg= history_text_quoted_color_fg=0xf0e4cf +history_text_reaction_color_bg=0x494949 +history_text_reaction_color_fg=gray history_text_recv_color_bg= history_text_recv_color_fg= history_text_recv_group_color_bg=