From ba92455240bf88ee0004274e971f8ec639a0a307 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 16:43:11 +0200 Subject: [PATCH 01/73] feat(groups): added NGC group support prototype --- CMakeLists.txt | 24 + img/group.svg | 20 + img/group_dark.svg | 20 + res.qrc | 2 + src/core/conferenceid.cpp | 10 +- src/core/conferenceid.h | 2 +- src/core/core.cpp | 670 +++++++++++++++++- src/core/core.h | 92 ++- src/core/groupid.cpp | 61 ++ src/core/groupid.h | 22 + src/core/icoregroupmessagesender.cpp | 7 + src/core/icoregroupmessagesender.h | 25 + src/core/icoregroupquery.cpp | 7 + src/core/icoregroupquery.h | 66 ++ src/core/icoresettings.h | 2 + src/core/toxid.cpp | 25 +- src/core/toxid.h | 7 - src/core/toxpk.cpp | 16 +- src/core/toxpk.h | 3 +- src/grouplist.cpp | 74 ++ src/grouplist.h | 32 + src/mainwindow.ui | 41 ++ src/model/chathistory.cpp | 4 +- src/model/chathistory.h | 3 +- src/model/chatmanager.cpp | 309 +++++++- src/model/chatmanager.h | 44 +- src/model/chatroom/grouproom.cpp | 82 +++ src/model/chatroom/grouproom.h | 41 ++ src/model/dialogs/idialogs.h | 2 + src/model/dialogs/idialogsmanager.h | 2 + src/model/group.cpp | 360 ++++++++++ src/model/group.h | 115 +++ src/model/groupinvite.cpp | 47 ++ src/model/groupinvite.h | 30 + src/model/groupmessagedispatcher.cpp | 68 ++ src/model/groupmessagedispatcher.h | 37 + src/model/notificationgenerator.cpp | 36 + src/model/notificationgenerator.h | 4 + src/model/sessionchatlog.cpp | 21 +- src/model/sessionchatlog.h | 6 +- src/nexus.cpp | 2 + src/persistence/ifriendsettings.h | 4 + src/persistence/personalsettingsupgrader.cpp | 2 +- src/persistence/settings.cpp | 100 ++- src/persistence/settings.h | 17 + src/widget/chatformheader.cpp | 5 + src/widget/chatformheader.h | 1 + src/widget/circlewidget.cpp | 6 +- src/widget/circlewidget.h | 4 +- src/widget/conferencewidget.cpp | 12 +- src/widget/contentdialog.cpp | 114 ++- src/widget/contentdialog.h | 14 +- src/widget/contentdialogmanager.cpp | 42 ++ src/widget/contentdialogmanager.h | 6 + src/widget/form/chatform.cpp | 4 +- src/widget/form/chatform.h | 4 +- src/widget/form/conferenceform.cpp | 6 +- src/widget/form/conferenceform.h | 3 +- src/widget/form/genericchatform.cpp | 13 +- src/widget/form/genericchatform.h | 5 +- src/widget/form/groupform.cpp | 561 +++++++++++++++ src/widget/form/groupform.h | 84 +++ src/widget/form/groupinviteform.cpp | 191 +++++ src/widget/form/groupinviteform.h | 57 ++ src/widget/form/groupinvitewidget.cpp | 68 ++ src/widget/form/groupinvitewidget.h | 38 + src/widget/friendlistwidget.cpp | 24 +- src/widget/friendlistwidget.h | 8 +- src/widget/genericchatroomwidget.h | 5 + src/widget/groupwidget.cpp | 295 ++++++++ src/widget/groupwidget.h | 68 ++ src/widget/widget.cpp | 335 ++++++++- src/widget/widget.h | 42 +- test/core/chatid_test.cpp | 6 +- test/mock/include/mock/mockconferencequery.h | 2 +- test/mock/include/mock/mockcoreidhandler.h | 2 +- test/mock/include/mock/mockcoresettings.h | 7 + test/model/chathistory_test.cpp | 8 +- .../conferencemessagedispatcher_test.cpp | 6 +- test/model/sessionchatlog_test.cpp | 8 +- themes/dark/chatArea/chatHead.qss | 10 + themes/default/chatArea/chatHead.qss | 10 + translations/ru.ts | 212 ++++++ util/include/util/toxcoreerrorparser.h | 11 + util/src/toxcoreerrorparser.cpp | 326 +++++++++ 85 files changed, 5073 insertions(+), 114 deletions(-) create mode 100644 img/group.svg create mode 100644 img/group_dark.svg create mode 100644 src/core/groupid.cpp create mode 100644 src/core/groupid.h create mode 100644 src/core/icoregroupmessagesender.cpp create mode 100644 src/core/icoregroupmessagesender.h create mode 100644 src/core/icoregroupquery.cpp create mode 100644 src/core/icoregroupquery.h create mode 100644 src/grouplist.cpp create mode 100644 src/grouplist.h create mode 100644 src/model/chatroom/grouproom.cpp create mode 100644 src/model/chatroom/grouproom.h create mode 100644 src/model/group.cpp create mode 100644 src/model/group.h create mode 100644 src/model/groupinvite.cpp create mode 100644 src/model/groupinvite.h create mode 100644 src/model/groupmessagedispatcher.cpp create mode 100644 src/model/groupmessagedispatcher.h create mode 100644 src/widget/form/groupform.cpp create mode 100644 src/widget/form/groupform.h create mode 100644 src/widget/form/groupinviteform.cpp create mode 100644 src/widget/form/groupinviteform.h create mode 100644 src/widget/form/groupinvitewidget.cpp create mode 100644 src/widget/form/groupinvitewidget.h create mode 100644 src/widget/groupwidget.cpp create mode 100644 src/widget/groupwidget.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a1924c25d..f1b98734a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,6 +253,8 @@ set(${BINARY_NAME}_SOURCES src/friendlist.h src/conferencelist.cpp src/conferencelist.h + src/grouplist.cpp + src/grouplist.h src/ipc.cpp src/ipc.h src/nexus.cpp @@ -309,6 +311,10 @@ set(${BINARY_NAME}_SOURCES src/core/icoreconferencemessagesender.h src/core/icoreconferencequery.cpp src/core/icoreconferencequery.h + src/core/icoregroupmessagesender.cpp + src/core/icoregroupmessagesender.h + src/core/icoregroupquery.cpp + src/core/icoregroupquery.h src/core/icoreidhandler.cpp src/core/icoreidhandler.h src/core/idebugsettings.cpp @@ -324,6 +330,8 @@ set(${BINARY_NAME}_SOURCES src/core/toxid.h src/core/conferenceid.cpp src/core/conferenceid.h + src/core/groupid.cpp + src/core/groupid.h src/core/toxlogger.cpp src/core/toxlogger.h src/core/toxoptions.cpp @@ -348,6 +356,8 @@ set(${BINARY_NAME}_SOURCES src/model/chatroom/friendchatroom.h src/model/chatroom/conferenceroom.cpp src/model/chatroom/conferenceroom.h + src/model/chatroom/grouproom.cpp + src/model/chatroom/grouproom.h src/model/chat.cpp src/model/chat.h src/model/debug/debuglogmodel.cpp @@ -374,6 +384,12 @@ set(${BINARY_NAME}_SOURCES src/model/conferencemessagedispatcher.h src/model/conference.cpp src/model/conference.h + src/model/group.cpp + src/model/group.h + src/model/groupinvite.cpp + src/model/groupinvite.h + src/model/groupmessagedispatcher.cpp + src/model/groupmessagedispatcher.h src/model/ibootstraplistgenerator.cpp src/model/ibootstraplistgenerator.h src/model/ichatlog.h @@ -537,6 +553,12 @@ set(${BINARY_NAME}_SOURCES src/widget/form/conferenceinviteform.h src/widget/form/conferenceinvitewidget.cpp src/widget/form/conferenceinvitewidget.h + src/widget/form/groupform.cpp + src/widget/form/groupform.h + src/widget/form/groupinviteform.cpp + src/widget/form/groupinviteform.h + src/widget/form/groupinvitewidget.cpp + src/widget/form/groupinvitewidget.h src/widget/form/loadhistorydialog.cpp src/widget/form/loadhistorydialog.h src/widget/form/profileform.cpp @@ -581,6 +603,8 @@ set(${BINARY_NAME}_SOURCES src/widget/genericchatroomwidget.h src/widget/conferencewidget.cpp src/widget/conferencewidget.h + src/widget/groupwidget.cpp + src/widget/groupwidget.h src/widget/loginscreen.cpp src/widget/loginscreen.h src/widget/maskablepixmapwidget.cpp diff --git a/img/group.svg b/img/group.svg new file mode 100644 index 0000000000..c380771532 --- /dev/null +++ b/img/group.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/img/group_dark.svg b/img/group_dark.svg new file mode 100644 index 0000000000..8cc315753c --- /dev/null +++ b/img/group_dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/res.qrc b/res.qrc index 9dae976b22..233421de13 100644 --- a/res.qrc +++ b/res.qrc @@ -14,6 +14,8 @@ img/contact_dark.svg img/contact.svg img/debug.svg + img/group_dark.svg + img/group.svg img/icons/qtox.svg img/login_logo.svg img/others/logout-icon.svg diff --git a/src/core/conferenceid.cpp b/src/core/conferenceid.cpp index 5eea8b740f..efb7e4ddcb 100644 --- a/src/core/conferenceid.cpp +++ b/src/core/conferenceid.cpp @@ -26,11 +26,11 @@ ConferenceId::ConferenceId() /** * @brief Constructs a ConferenceId from bytes. * @param rawId The bytes to construct the ConferenceId from. The length must be exactly - * ConferenceId::size, else the ConferenceId will be empty. + * TOX_CONFERENCE_ID_SIZE, else the ConferenceId will be empty. */ ConferenceId::ConferenceId(const QByteArray& rawId) : ChatId([rawId]() { - assert(rawId.length() == size); + assert(rawId.length() == TOX_CONFERENCE_ID_SIZE); return rawId; }()) { @@ -39,10 +39,10 @@ ConferenceId::ConferenceId(const QByteArray& rawId) /** * @brief Constructs a ConferenceId from bytes. * @param rawId The bytes to construct the ConferenceId from, will read exactly - * ConferenceId::size from the specified buffer. + * TOX_CONFERENCE_ID_SIZE from the specified buffer. */ ConferenceId::ConferenceId(const uint8_t* rawId) - : ChatId(QByteArray(reinterpret_cast(rawId), size)) + : ChatId(QByteArray(reinterpret_cast(rawId), TOX_CONFERENCE_ID_SIZE)) { } @@ -52,7 +52,7 @@ ConferenceId::ConferenceId(const uint8_t* rawId) */ int ConferenceId::getSize() const { - return size; + return TOX_CONFERENCE_ID_SIZE; } std::unique_ptr ConferenceId::clone() const diff --git a/src/core/conferenceid.h b/src/core/conferenceid.h index 00249e1f56..1d76d773ce 100644 --- a/src/core/conferenceid.h +++ b/src/core/conferenceid.h @@ -10,11 +10,11 @@ #include #include +#include class ConferenceId : public ChatId { public: - static constexpr int size = 32; ConferenceId(); explicit ConferenceId(const QByteArray& rawId); explicit ConferenceId(const uint8_t* rawId); diff --git a/src/core/core.cpp b/src/core/core.cpp index 7edff5ebd9..07f63e33d9 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -14,6 +14,7 @@ #include "src/core/toxoptions.h" #include "src/core/toxstring.h" #include "src/model/conferenceinvite.h" +#include "src/model/groupinvite.h" #include "src/model/ibootstraplistgenerator.h" #include "src/model/status.h" #include "util/toxcoreerrorparser.h" @@ -57,12 +58,15 @@ Core::Core(QThread* coreThread_, IBootstrapListGenerator& bootstrapListGenerator { assert(toxTimer); // need to migrate Settings and History if this changes - assert(ToxPk::size == tox_public_key_size()); - assert(ConferenceId::size == tox_conference_id_size()); - assert(ToxId::size == tox_address_size()); + assert(TOX_PUBLIC_KEY_SIZE == tox_public_key_size()); + assert(TOX_CONFERENCE_ID_SIZE == tox_conference_id_size()); + assert(TOX_GROUP_CHAT_ID_SIZE == tox_group_chat_id_size()); + assert(TOX_ADDRESS_SIZE == tox_address_size()); toxTimer->setSingleShot(true); connect(toxTimer, &QTimer::timeout, this, &Core::process); connect(coreThread_, &QThread::finished, toxTimer, &QTimer::stop); + connect(qApp, &QCoreApplication::aboutToQuit, this, &Core::leaveAllGroups, + Qt::DirectConnection); } Core::~Core() @@ -96,6 +100,21 @@ void Core::registerCallbacks(Tox* tox) tox_callback_conference_peer_list_changed(tox, onConferencePeerListChange); tox_callback_conference_peer_name(tox, onConferencePeerNameChange); tox_callback_conference_title(tox, onConferenceTitleChange); + + tox_callback_group_invite(tox, onGroupInvite); + tox_callback_group_message(tox, onGroupMessage); + tox_callback_group_peer_join(tox, onGroupPeerJoin); + tox_callback_group_peer_exit(tox, onGroupPeerExit); + tox_callback_group_peer_name(tox, onGroupPeerNameChange); + tox_callback_group_self_join(tox, onGroupSelfJoin); + tox_callback_group_topic(tox, onGroupTopic); + tox_callback_group_join_fail(tox, onGroupJoinFail); + tox_callback_group_moderation(tox, onGroupModeration); + tox_callback_group_password(tox, onGroupPassword); + tox_callback_group_peer_limit(tox, onGroupPeerLimit); + tox_callback_group_topic_lock(tox, onGroupTopicLock); + tox_callback_group_voice_state(tox, onGroupVoiceState); + tox_callback_group_privacy_state(tox, onGroupPrivacyState); } /** @@ -214,6 +233,7 @@ void Core::onStarted() loadFriends(); loadConferences(); + loadGroups(); process(); // starts its own timer } @@ -530,6 +550,152 @@ void Core::onConferenceTitleChange(Tox* tox, uint32_t conferenceId, uint32_t pee } +void Core::onGroupInvite(Tox* tox, uint32_t friendId, const uint8_t* inviteData, size_t length, + const uint8_t* groupName, size_t groupNameLength, void* vCore) +{ + std::ignore = tox; + Core* core = static_cast(vCore); + const QByteArray data(reinterpret_cast(inviteData), length); + const QString name = ToxString(groupName, groupNameLength).getQString(); + const GroupInvite inviteInfo(friendId, data, name); + qDebug() << "Group invite by friend" << friendId << "to group" << name; + emit core->groupInviteReceived(inviteInfo); +} + +void Core::onGroupMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_Message_Type type, + const uint8_t* cMessage, size_t length, Tox_Group_Message_Id messageId, + void* vCore) +{ + std::ignore = tox; + std::ignore = messageId; + Core* core = static_cast(vCore); + const bool isAction = type == TOX_MESSAGE_TYPE_ACTION; + const QString message = ToxString(cMessage, length).getQString(); + emit core->groupMessageReceived(groupNumber, peerId, message, isAction); +} + +void Core::onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qWarning("Group %u peer %u joined", groupNumber, peerId); + emit core->groupPeerJoined(groupNumber, peerId); +} + +void Core::onGroupPeerExit(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_Group_Exit_Type exitType, + const uint8_t* name, size_t nameLength, const uint8_t* partMessage, + size_t partMessageLength, void* vCore) +{ + std::ignore = tox; + std::ignore = name; + std::ignore = nameLength; + std::ignore = partMessage; + std::ignore = partMessageLength; + auto* const core = static_cast(vCore); + qWarning("Group %u peer %u left, exit type %d", groupNumber, peerId, static_cast(exitType)); + emit core->groupPeerExited(groupNumber, peerId); +} + +void Core::onGroupPeerNameChange(Tox* tox, uint32_t groupNumber, uint32_t peerId, + const uint8_t* name, size_t length, void* vCore) +{ + std::ignore = tox; + const auto newName = ToxString(name, length).getQString(); + qDebug().nospace() << "Group " << groupNumber << ", peer " << peerId << ", name " << newName; + auto* core = static_cast(vCore); + emit core->groupPeerNameChanged(groupNumber, peerId, newName); +} + +void Core::onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qWarning("Joined group %u", groupNumber); + const GroupId groupId = core->getGroupPersistentId(groupNumber); + if (!groupId.isEmpty()) { + core->numberToGroupId[groupNumber] = groupId; + core->groupIdToNumber[groupId] = groupNumber; + } + emit core->groupSelfJoined(groupNumber); +} + +void Core::onGroupTopic(Tox* tox, uint32_t groupNumber, uint32_t peerId, const uint8_t* topic, + size_t length, void* vCore) +{ + std::ignore = tox; + std::ignore = peerId; + auto* const core = static_cast(vCore); + const QString newTopic = ToxString(topic, length).getQString(); + qDebug().nospace() << "Group " << groupNumber << " topic changed to " << newTopic; + emit core->saveRequest(); + emit core->groupTopicChanged(groupNumber, newTopic); +} + +void Core::onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail failType, void* vCore) +{ + std::ignore = tox; + std::ignore = failType; + auto* const core = static_cast(vCore); + qWarning() << "Group join failed for group" << groupNumber; + emit core->groupJoinFailed(groupNumber); +} + +void Core::onGroupModeration(Tox* tox, uint32_t groupNumber, uint32_t sourcePeerId, + uint32_t targetPeerId, Tox_Group_Mod_Event modType, void* vCore) +{ + std::ignore = tox; + std::ignore = sourcePeerId; + std::ignore = targetPeerId; + std::ignore = modType; + auto* const core = static_cast(vCore); + qWarning() << "Group" << groupNumber << "moderation event, refreshing peer roles"; + emit core->groupPeerRolesChanged(groupNumber); +} + +void Core::onGroupPassword(Tox* tox, uint32_t groupNumber, const uint8_t* password, size_t length, + void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qDebug() << "Group" << groupNumber << "password changed, has password:" << (length != 0); + emit core->groupPasswordChanged(groupNumber, length != 0); +} + +void Core::onGroupPeerLimit(Tox* tox, uint32_t groupNumber, uint32_t peerLimit, void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qDebug() << "Group" << groupNumber << "peer limit changed to" << peerLimit; + emit core->groupPeerLimitChanged(groupNumber, static_cast(peerLimit)); +} + +void Core::onGroupTopicLock(Tox* tox, uint32_t groupNumber, Tox_Group_Topic_Lock topicLock, + void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qDebug() << "Group" << groupNumber << "topic lock changed to" << topicLock; + emit core->groupTopicLockChanged(groupNumber, static_cast(topicLock)); +} + +void Core::onGroupVoiceState(Tox* tox, uint32_t groupNumber, Tox_Group_Voice_State voiceState, + void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qDebug() << "Group" << groupNumber << "voice state changed to" << voiceState; + emit core->groupVoiceStateChanged(groupNumber, static_cast(voiceState)); +} + +void Core::onGroupPrivacyState(Tox* tox, uint32_t groupNumber, Tox_Group_Privacy_State privacyState, + void* vCore) +{ + std::ignore = tox; + auto* const core = static_cast(vCore); + qDebug() << "Group" << groupNumber << "privacy state changed to" << privacyState; + emit core->groupPrivacyStateChanged(groupNumber, static_cast(privacyState)); +} + void Core::onReadReceiptCallback(Tox* tox, uint32_t friendId, uint32_t receipt, void* core) { std::ignore = tox; @@ -696,6 +862,76 @@ void Core::changeConferenceTitle(uint32_t conferenceId, const QString& title) } } +void Core::sendGroupMessageWithType(uint32_t groupNumber, const QString& message, + Tox_Message_Type type) +{ + const QMutexLocker ml{&coreLoopLock}; + + const int size = message.toUtf8().size(); + const auto maxSize = static_cast(tox_group_max_message_length()); + if (size > maxSize) { + qCritical() << "Core::sendGroupMessageWithType called with message of size:" << size + << "when max is:" << maxSize << ". Ignoring."; + return; + } + + const ToxString cMsg(message); + Tox_Err_Group_Send_Message error; + tox_group_send_message(tox.get(), groupNumber, type, cMsg.data(), cMsg.size(), &error); + if (!PARSE_ERR(error)) { + emit groupSentFailed(groupNumber); + return; + } +} + +void Core::sendGroupMessage(uint32_t groupNumber, const QString& message) +{ + const QMutexLocker ml{&coreLoopLock}; + + sendGroupMessageWithType(groupNumber, message, TOX_MESSAGE_TYPE_NORMAL); +} + +void Core::sendGroupAction(uint32_t groupNumber, const QString& message) +{ + const QMutexLocker ml{&coreLoopLock}; + + sendGroupMessageWithType(groupNumber, message, TOX_MESSAGE_TYPE_ACTION); +} + +void Core::sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, const QString& message) +{ + const QMutexLocker ml{&coreLoopLock}; + + const int size = message.toUtf8().size(); + const auto maxSize = static_cast(tox_group_max_message_length()); + if (size > maxSize) { + qCritical() << "Core::sendGroupPrivateMessage called with message of size:" << size + << "when max is:" << maxSize << ". Ignoring."; + return; + } + + const ToxString cMsg(message); + Tox_Err_Group_Send_Private_Message error; + tox_group_send_private_message(tox.get(), groupNumber, peerId, TOX_MESSAGE_TYPE_NORMAL, + cMsg.data(), cMsg.size(), &error); + if (!PARSE_ERR(error)) { + emit groupSentFailed(groupNumber); + } +} + +void Core::changeGroupTopic(uint32_t groupNumber, const QString& topic) +{ + const QMutexLocker ml{&coreLoopLock}; + + const ToxString cTopic(topic); + Tox_Err_Group_Topic_Set error; + tox_group_set_topic(tox.get(), groupNumber, cTopic.data(), cTopic.size(), &error); + if (PARSE_ERR(error)) { + emit saveRequest(); + emit groupTopicChanged(groupNumber, topic); + } +} + void Core::removeFriend(uint32_t friendId) { const QMutexLocker ml{&coreLoopLock}; @@ -980,6 +1216,41 @@ void Core::loadConferences() } } +void Core::loadGroups() +{ + const QMutexLocker ml{&coreLoopLock}; + + const QStringList saved = settings.getSavedGroups(); + for (const QString& groupIdHex : saved) { + if (groupIdHex.isEmpty()) { + continue; + } + const QByteArray rawId = QByteArray::fromHex(groupIdHex.toLatin1()); + if (rawId.size() != TOX_GROUP_CHAT_ID_SIZE) { + qWarning() << "loadGroups: invalid saved group id" << groupIdHex; + continue; + } + const GroupId groupId(rawId); + if (groupIdToNumber.contains(groupId)) { + continue; + } + + const ToxString cSelfName(getUsername()); + Tox_Err_Group_Join error; + const uint32_t groupNumber = + tox_group_join(tox.get(), groupId.getData(), cSelfName.data(), cSelfName.size(), nullptr, + 0, &error); + if (!PARSE_ERR(error)) { + qWarning() << "loadGroups: failed to rejoin group" << groupIdHex; + continue; + } + + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + emit groupJoined(groupNumber, groupId); + } +} + void Core::checkLastOnline(uint32_t friendId) { const QMutexLocker ml{&coreLoopLock}; @@ -1126,6 +1397,251 @@ bool Core::getConferenceAvEnabled(int conferenceId) const return type == TOX_CONFERENCE_TYPE_AV; } +GroupId Core::getGroupPersistentId(uint32_t groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + std::vector idBuff(tox_group_chat_id_size()); + Tox_Err_Group_State_Query error; + if (tox_group_get_chat_id(tox.get(), groupNumber, idBuff.data(), &error)) { + return GroupId{idBuff.data()}; + } + qCritical() << "Failed to get chat id of group" << groupNumber; + return {}; +} + +/** + * @brief Get the number of peers in a group. + * @return The number of peers in the group. UINT32_MAX on failure. + */ +uint32_t Core::getGroupNumberPeers(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + // NGC has no public peer enumeration API. The peer count is tracked + // client-side by the Group model. + std::ignore = groupNumber; + return std::numeric_limits::max(); +} + +/** + * @brief Get the self peer id of a group. + * @return The self peer id on success, UINT32_MAX on failure. + */ +uint32_t Core::getGroupSelfPeerId(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Self_Query error; + const uint32_t peerId = tox_group_self_get_peer_id(tox.get(), groupNumber, &error); + if (!PARSE_ERR(error)) { + return std::numeric_limits::max(); + } + + return peerId; +} + +/** + * @brief Get the name of a peer of a group + */ +QString Core::getGroupPeerName(int groupNumber, int peerId) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Peer_Query error; + const size_t length = tox_group_peer_get_name_size(tox.get(), groupNumber, peerId, &error); + if (!PARSE_ERR(error) || (length == 0u)) { + return QString{}; + } + + std::vector nameBuf(length); + tox_group_peer_get_name(tox.get(), groupNumber, peerId, nameBuf.data(), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(nameBuf.data(), length).getQString(); +} + +/** + * @brief Get the public key of a peer of a group + */ +ToxPk Core::getGroupPeerPk(int groupNumber, int peerId) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Self_Query selfError; + const uint32_t selfPeerId = tox_group_self_get_peer_id(tox.get(), groupNumber, &selfError); + if (PARSE_ERR(selfError) && selfPeerId == static_cast(peerId)) { + return getSelfPublicKey(); + } + + std::vector peerPk(tox_public_key_size()); + Tox_Err_Group_Peer_Query error; + tox_group_peer_get_public_key(tox.get(), groupNumber, peerId, peerPk.data(), &error); + if (!PARSE_ERR(error)) { + return ToxPk{}; + } + + return ToxPk(peerPk.data()); +} + +/** + * @brief Get the role of a peer in a group + */ +GroupRole Core::getGroupPeerRole(int groupNumber, int peerId) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Peer_Query error; + const Tox_Group_Role role = tox_group_peer_get_role(tox.get(), groupNumber, peerId, &error); + if (!PARSE_ERR(error)) { + return GroupRole::Unknown; + } + + return static_cast(role); +} + +bool Core::setGroupPeerRole(int groupNumber, int peerId, GroupRole role) +{ + const QMutexLocker ml{&coreLoopLock}; + + const Tox_Group_Role toxRole = static_cast(role); + Tox_Err_Group_Set_Role error; + const bool success = tox_group_set_role(tox.get(), groupNumber, peerId, toxRole, &error); + if (!success) { + qWarning() << "Failed to set role" << static_cast(role) << "for peer" << peerId + << "in group" << groupNumber << ":" << tox_err_group_set_role_to_string(error); + return false; + } + + emit groupPeerRolesChanged(groupNumber); + return true; +} + +bool Core::kickGroupPeer(int groupNumber, int peerId) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Kick_Peer error; + const bool success = tox_group_kick_peer(tox.get(), groupNumber, peerId, &error); + if (!success) { + qWarning() << "Failed to kick peer" << peerId << "from group" << groupNumber << ":" + << tox_err_group_kick_peer_to_string(error); + return false; + } + + return true; +} + +bool Core::setGroupPassword(int groupNumber, const QByteArray& password) +{ + const QMutexLocker ml{&coreLoopLock}; + + if (password.size() > TOX_GROUP_MAX_PASSWORD_SIZE) { + qWarning() << "Failed to set password for group" << groupNumber << ": password too long"; + return false; + } + + const auto* const passwordData = + password.isEmpty() ? nullptr : reinterpret_cast(password.constData()); + Tox_Err_Group_Set_Password error; + const bool success = tox_group_set_password(tox.get(), groupNumber, passwordData, password.size(), + &error); + if (!success) { + qWarning() << "Failed to set password for group" << groupNumber << ":" + << tox_err_group_set_password_to_string(error); + return false; + } + + return true; +} + +bool Core::setGroupPeerLimit(int groupNumber, uint16_t peerLimit) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Set_Peer_Limit error; + const bool success = tox_group_set_peer_limit(tox.get(), groupNumber, peerLimit, &error); + if (!success) { + qWarning() << "Failed to set peer limit" << peerLimit << "for group" << groupNumber << ":" + << tox_err_group_set_peer_limit_to_string(error); + return false; + } + + return true; +} + +bool Core::setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) +{ + const QMutexLocker ml{&coreLoopLock}; + + const auto toxTopicLock = static_cast(topicLock); + Tox_Err_Group_Set_Topic_Lock error; + const bool success = tox_group_set_topic_lock(tox.get(), groupNumber, toxTopicLock, &error); + if (!success) { + qWarning() << "Failed to set topic lock" << static_cast(topicLock) << "for group" + << groupNumber << ":" << tox_err_group_set_topic_lock_to_string(error); + return false; + } + + return true; +} + +bool Core::setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) +{ + const QMutexLocker ml{&coreLoopLock}; + + const auto toxVoiceState = static_cast(voiceState); + Tox_Err_Group_Set_Voice_State error; + const bool success = tox_group_set_voice_state(tox.get(), groupNumber, toxVoiceState, &error); + if (!success) { + qWarning() << "Failed to set voice state" << static_cast(voiceState) << "for group" + << groupNumber << ":" << tox_err_group_set_voice_state_to_string(error); + return false; + } + + return true; +} + +bool Core::setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) +{ + const QMutexLocker ml{&coreLoopLock}; + + const auto toxPrivacyState = static_cast(privacyState); + Tox_Err_Group_Set_Privacy_State error; + const bool success = tox_group_set_privacy_state(tox.get(), groupNumber, toxPrivacyState, &error); + if (!success) { + qWarning() << "Failed to set privacy state" << static_cast(privacyState) << "for group" + << groupNumber << ":" << tox_err_group_set_privacy_state_to_string(error); + return false; + } + + return true; +} + +/** + * @brief Get the name of a group + */ +QString Core::getGroupTitle(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const size_t length = tox_group_get_name_size(tox.get(), groupNumber, &error); + if (!PARSE_ERR(error) || (length == 0u)) { + return QString{}; + } + + std::vector nameBuf(length); + tox_group_get_name(tox.get(), groupNumber, nameBuf.data(), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(nameBuf.data(), length).getQString(); +} + /** * @brief Accept a conference invite. * @param inviteInfo Object which contains info about conference invitation @@ -1208,6 +1724,154 @@ int Core::createConference(uint8_t type) return -1; } +void Core::groupInviteFriend(uint32_t friendId, int groupNumber) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Invite_Friend error; + tox_group_invite_friend(tox.get(), friendId, groupNumber, &error); + if (!PARSE_ERR(error)) { + qWarning() << "Failed to invite friend" << friendId << "to group" << groupNumber; + } +} + +int Core::createGroup(const QString& groupName) +{ + const QMutexLocker ml{&coreLoopLock}; + + const ToxString cName(groupName); + const ToxString cSelfName(getUsername()); + Tox_Err_Group_New error; + const uint32_t groupNumber = + tox_group_new(tox.get(), TOX_GROUP_PRIVACY_STATE_PUBLIC, cName.data(), cName.size(), + cSelfName.data(), cSelfName.size(), &error); + if (!PARSE_ERR(error)) { + qCritical() << "Failed to create group"; + return -1; + } + + const GroupId groupId = getGroupPersistentId(groupNumber); + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + + emit saveRequest(); + emit emptyGroupCreated(groupNumber, groupId, groupName); + return groupNumber; +} + +/** + * @brief Accept a group invite. + * @param inviteInfo Object which contains info about group invitation + * + * @return Group number on success, UINT32_MAX on failure. + */ +uint32_t Core::joinGroup(const GroupInvite& inviteInfo) +{ + const QMutexLocker ml{&coreLoopLock}; + + const uint32_t friendId = inviteInfo.getFriendId(); + const QByteArray invite = inviteInfo.getInviteData(); + const auto* const inviteData = reinterpret_cast(invite.constData()); + const size_t inviteLength = invite.size(); + const ToxString cSelfName(getUsername()); + + qDebug() << "Trying to accept invite for group sent by friend" << friendId; + Tox_Err_Group_Invite_Accept error; + const uint32_t groupNumber = + tox_group_invite_accept(tox.get(), friendId, inviteData, inviteLength, cSelfName.data(), + cSelfName.size(), nullptr, 0, &error); + if (!PARSE_ERR(error)) { + qWarning() << "Failed to accept group invite from friend" << friendId; + return std::numeric_limits::max(); + } + + const GroupId groupId = getGroupPersistentId(groupNumber); + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + + emit saveRequest(); + emit groupJoined(groupNumber, groupId); + return groupNumber; +} + +/** + * @brief Join an NGC group by its chat id. + * @param groupId Chat ID of the group to join. + * @return Group number on success, -1 on failure. + */ +int Core::joinGroup(const GroupId& groupId) +{ + const QMutexLocker ml{&coreLoopLock}; + + if (groupId.isEmpty()) { + qWarning() << "joinGroup: empty group id"; + return -1; + } + + if (groupIdToNumber.contains(groupId)) { + return groupIdToNumber[groupId]; + } + + const ToxString cSelfName(getUsername()); + Tox_Err_Group_Join error; + const uint32_t groupNumber = + tox_group_join(tox.get(), groupId.getData(), cSelfName.data(), cSelfName.size(), nullptr, 0, + &error); + if (!PARSE_ERR(error)) { + qWarning() << "Failed to join group" << groupId.toString(); + return -1; + } + + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + + emit saveRequest(); + emit groupJoined(groupNumber, groupId); + return groupNumber; +} + +void Core::quitGroup(int groupNumber) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Leave error; + tox_group_leave(tox.get(), groupNumber, nullptr, 0, &error); + if (PARSE_ERR(error)) { + const auto groupIdIt = numberToGroupId.find(groupNumber); + if (groupIdIt != numberToGroupId.end()) { + const GroupId& groupId = *groupIdIt; + numberToGroupId.erase(groupIdIt); + groupIdToNumber.remove(groupId); + } + emit saveRequest(); + emit groupSelfDisconnected(groupNumber); + } +} + +void Core::leaveAllGroups() +{ + const QMutexLocker ml{&coreLoopLock}; + + if (numberToGroupId.isEmpty()) { + return; + } + + for (auto it = numberToGroupId.cbegin(); it != numberToGroupId.cend(); ++it) { + Tox_Err_Group_Leave error; + tox_group_leave(tox.get(), it.key(), nullptr, 0, &error); + if (!PARSE_ERR(error)) { + qWarning() << "Failed to leave group" << it.value().toString(); + } + } + numberToGroupId.clear(); + groupIdToNumber.clear(); + + // Let toxcore send out the leave packets before the Tox instance is torn down. + for (int i = 0; i < 10; ++i) { + tox_iterate(tox.get(), this); + } +} + /** * @brief Checks if a friend is online. Unknown friends are considered offline. */ diff --git a/src/core/core.h b/src/core/core.h index 1ebb6c4678..2b9f5d6cc5 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -7,9 +7,12 @@ #pragma once #include "conferenceid.h" +#include "groupid.h" #include "icoreconferencemessagesender.h" #include "icoreconferencequery.h" #include "icorefriendmessagesender.h" +#include "icoregroupmessagesender.h" +#include "icoregroupquery.h" #include "icoreidhandler.h" #include "receiptnum.h" #include "toxfile.h" @@ -31,6 +34,7 @@ class CoreFile; class IAudioControl; class ICoreSettings; class ConferenceInvite; +class GroupInvite; class Profile; class Core; class IBootstrapListGenerator; @@ -42,7 +46,9 @@ class Core : public QObject, public ICoreFriendMessageSender, public ICoreIdHandler, public ICoreConferenceMessageSender, - public ICoreConferenceQuery + public ICoreConferenceQuery, + public ICoreGroupMessageSender, + public ICoreGroupQuery { Q_OBJECT public: @@ -81,11 +87,24 @@ class Core : public QObject, ToxPk getFriendPublicKey(uint32_t friendNumber) const; QString getFriendUsername(uint32_t friendNumber) const; + uint32_t getGroupNumberPeers(int groupNumber) const; + uint32_t getGroupSelfPeerId(int groupNumber) const; + QString getGroupPeerName(int groupNumber, int peerId) const override; + ToxPk getGroupPeerPk(int groupNumber, int peerId) const override; + QString getGroupTitle(int groupNumber) const override; + GroupRole getGroupPeerRole(int groupNumber, int peerId) const override; + bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override; + bool kickGroupPeer(int groupNumber, int peerId) override; + GroupId getGroupPersistentId(uint32_t groupNumber) const; + bool isFriendOnline(uint32_t friendId) const; bool hasFriendWithPublicKey(const ToxPk& publicKey) const; uint32_t joinConference(const ConferenceInvite& inviteInfo); void quitConference(int conferenceId) const; + uint32_t joinGroup(const GroupInvite& inviteInfo); + int joinGroup(const GroupId& groupId); + QString getUsername() const override; Status::Status getStatus() const; QString getStatusMessage() const; @@ -105,6 +124,17 @@ public slots: void conferenceInviteFriend(uint32_t friendId, int conferenceId); int createConference(uint8_t type = TOX_CONFERENCE_TYPE_AV); + void groupInviteFriend(uint32_t friendId, int groupNumber); + int createGroup(const QString& groupName); + void quitGroup(int groupNumber); + void leaveAllGroups(); + void changeGroupTopic(uint32_t groupNumber, const QString& topic); + bool setGroupPassword(int groupNumber, const QByteArray& password) override; + bool setGroupPeerLimit(int groupNumber, uint16_t peerLimit) override; + bool setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) override; + bool setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) override; + bool setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) override; + void removeFriend(uint32_t friendId); void removeConference(int conferenceId); @@ -119,6 +149,11 @@ public slots: bool sendAction(uint32_t friendId, const QString& action, ReceiptNum& receipt) override; void sendTyping(uint32_t friendId, bool typing); + void sendGroupMessage(uint32_t groupNumber, const QString& message) override; + void sendGroupAction(uint32_t groupNumber, const QString& message) override; + void sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, + const QString& message) override; + void setNospam(uint32_t nospam); signals: @@ -177,6 +212,27 @@ public slots: void conferenceJoined(uint32_t conferencenumber, ConferenceId conferenceId); void actionSentResult(uint32_t friendId, const QString& action, int success); + void emptyGroupCreated(uint32_t groupNumber, GroupId groupId, const QString& groupName); + void groupInviteReceived(const GroupInvite& inviteInfo); + void groupMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, + bool isAction); + void groupPeerJoined(uint32_t groupNumber, uint32_t peerId); + void groupPeerExited(uint32_t groupNumber, uint32_t peerId); + void groupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName); + void groupTitleChanged(uint32_t groupNumber, const QString& author, const QString& title); + void groupTopicChanged(uint32_t groupNumber, const QString& topic); + void groupSentFailed(uint32_t groupNumber); + void groupJoined(uint32_t groupNumber, GroupId groupId); + void groupSelfJoined(uint32_t groupNumber); + void groupSelfDisconnected(uint32_t groupNumber); + void groupJoinFailed(uint32_t groupNumber); + void groupPeerRolesChanged(uint32_t groupNumber); + void groupPasswordChanged(uint32_t groupNumber, bool hasPassword); + void groupPeerLimitChanged(uint32_t groupNumber, uint16_t peerLimit); + void groupTopicLockChanged(uint32_t groupNumber, GroupTopicLock topicLock); + void groupVoiceStateChanged(uint32_t groupNumber, GroupVoiceState voiceState); + void groupPrivacyStateChanged(uint32_t groupNumber, GroupPrivacyState privacyState); + void receiptReceived(uint32_t friendId, ReceiptNum receipt); void failedToRemoveFriend(uint32_t friendId); @@ -209,9 +265,39 @@ public slots: static void onConferenceTitleChange(Tox* tox, uint32_t conferenceId, uint32_t peerId, const uint8_t* cTitle, size_t length, void* vCore); + static void onGroupInvite(Tox* tox, uint32_t friendId, const uint8_t* inviteData, + size_t length, const uint8_t* groupName, size_t groupNameLength, + void* vCore); + static void onGroupMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, + Tox_Message_Type type, const uint8_t* cMessage, size_t length, + Tox_Group_Message_Id messageId, void* vCore); + static void onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void* vCore); + static void onGroupPeerExit(Tox* tox, uint32_t groupNumber, uint32_t peerId, + Tox_Group_Exit_Type exitType, const uint8_t* name, size_t nameLength, + const uint8_t* partMessage, size_t partMessageLength, void* vCore); + static void onGroupPeerNameChange(Tox* tox, uint32_t groupNumber, uint32_t peerId, + const uint8_t* name, size_t length, void* vCore); + static void onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore); + static void onGroupTopic(Tox* tox, uint32_t groupNumber, uint32_t peerId, const uint8_t* topic, + size_t length, void* vCore); + static void onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail failType, + void* vCore); + static void onGroupModeration(Tox* tox, uint32_t groupNumber, uint32_t sourcePeerId, + uint32_t targetPeerId, Tox_Group_Mod_Event modType, void* vCore); + static void onGroupPassword(Tox* tox, uint32_t groupNumber, const uint8_t* password, + size_t length, void* vCore); + static void onGroupPeerLimit(Tox* tox, uint32_t groupNumber, uint32_t peerLimit, void* vCore); + static void onGroupTopicLock(Tox* tox, uint32_t groupNumber, Tox_Group_Topic_Lock topicLock, + void* vCore); + static void onGroupVoiceState(Tox* tox, uint32_t groupNumber, Tox_Group_Voice_State voiceState, + void* vCore); + static void onGroupPrivacyState(Tox* tox, uint32_t groupNumber, + Tox_Group_Privacy_State privacyState, void* vCore); + static void onReadReceiptCallback(Tox* tox, uint32_t friendId, uint32_t receipt, void* core); void sendConferenceMessageWithType(int conferenceId, const QString& message, Tox_Message_Type type); + void sendGroupMessageWithType(uint32_t groupNumber, const QString& message, Tox_Message_Type type); bool sendMessageWithType(uint32_t friendId, const QString& message, Tox_Message_Type type, ReceiptNum& receipt); bool checkConnection(); @@ -219,6 +305,7 @@ public slots: void makeTox(QByteArray savedata, ICoreSettings* s); void loadFriends(); void loadConferences(); + void loadGroups(); void bootstrapDht(); void checkLastOnline(uint32_t friendId); @@ -261,4 +348,7 @@ private slots: const ICoreSettings& settings; bool isConnected = false; int tolerance = CORE_DISCONNECT_TOLERANCE; + + QHash numberToGroupId; + QHash groupIdToNumber; }; diff --git a/src/core/groupid.cpp b/src/core/groupid.cpp new file mode 100644 index 0000000000..47a538c48d --- /dev/null +++ b/src/core/groupid.cpp @@ -0,0 +1,61 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupid.h" + +#include +#include + +#include + +/** + * @class GroupId + * @brief This class represents a long term persistent group chat identifier + * (the "Chat ID" of an NGC group). + */ + +/** + * @brief The default constructor. Creates an empty Tox group ID. + */ +GroupId::GroupId() + : ChatId() +{ +} + +/** + * @brief Constructs a GroupId from bytes. + * @param rawId The bytes to construct the GroupId from. The length must be exactly + * TOX_GROUP_CHAT_ID_SIZE, else the GroupId will be empty. + */ +GroupId::GroupId(const QByteArray& rawId) + : ChatId([rawId]() { + assert(rawId.length() == TOX_GROUP_CHAT_ID_SIZE); + return rawId; + }()) +{ +} + +/** + * @brief Constructs a GroupId from bytes. + * @param rawId The bytes to construct the GroupId from, will read exactly + * TOX_GROUP_CHAT_ID_SIZE from the specified buffer. + */ +GroupId::GroupId(const uint8_t* rawId) + : ChatId(QByteArray(reinterpret_cast(rawId), TOX_GROUP_CHAT_ID_SIZE)) +{ +} + +/** + * @brief Get size of public id in bytes. + * @return Size of public id in bytes. + */ +int GroupId::getSize() const +{ + return TOX_GROUP_CHAT_ID_SIZE; +} + +std::unique_ptr GroupId::clone() const +{ + return std::make_unique(*this); +} diff --git a/src/core/groupid.h b/src/core/groupid.h new file mode 100644 index 0000000000..ff64715456 --- /dev/null +++ b/src/core/groupid.h @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/core/chatid.h" + +#include + +#include +#include + +class GroupId : public ChatId +{ +public: + GroupId(); + explicit GroupId(const QByteArray& rawId); + explicit GroupId(const uint8_t* rawId); + int getSize() const override; + std::unique_ptr clone() const override; +}; diff --git a/src/core/icoregroupmessagesender.cpp b/src/core/icoregroupmessagesender.cpp new file mode 100644 index 0000000000..92111a7683 --- /dev/null +++ b/src/core/icoregroupmessagesender.cpp @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "icoregroupmessagesender.h" + +ICoreGroupMessageSender::~ICoreGroupMessageSender() = default; diff --git a/src/core/icoregroupmessagesender.h b/src/core/icoregroupmessagesender.h new file mode 100644 index 0000000000..229e4463c7 --- /dev/null +++ b/src/core/icoregroupmessagesender.h @@ -0,0 +1,25 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include + +#include + +class ICoreGroupMessageSender +{ +public: + ICoreGroupMessageSender() = default; + virtual ~ICoreGroupMessageSender(); + ICoreGroupMessageSender(const ICoreGroupMessageSender&) = default; + ICoreGroupMessageSender& operator=(const ICoreGroupMessageSender&) = default; + ICoreGroupMessageSender(ICoreGroupMessageSender&&) = default; + ICoreGroupMessageSender& operator=(ICoreGroupMessageSender&&) = default; + + virtual void sendGroupAction(uint32_t groupNumber, const QString& message) = 0; + virtual void sendGroupMessage(uint32_t groupNumber, const QString& message) = 0; + virtual void sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, + const QString& message) = 0; +}; diff --git a/src/core/icoregroupquery.cpp b/src/core/icoregroupquery.cpp new file mode 100644 index 0000000000..d817067002 --- /dev/null +++ b/src/core/icoregroupquery.cpp @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "icoregroupquery.h" + +ICoreGroupQuery::~ICoreGroupQuery() = default; diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h new file mode 100644 index 0000000000..b46060cc25 --- /dev/null +++ b/src/core/icoregroupquery.h @@ -0,0 +1,66 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "toxpk.h" + +#include +#include + +#include + +enum class GroupRole +{ + Founder = 0, + Moderator = 1, + User = 2, + Observer = 3, + Unknown = -1, +}; + +enum class GroupTopicLock +{ + Enabled = 0, + Disabled = 1, + Unknown = -1, +}; + +enum class GroupVoiceState +{ + All = 0, + Moderator = 1, + Founder = 2, + Unknown = -1, +}; + +enum class GroupPrivacyState +{ + Public = 0, + Private = 1, + Unknown = -1, +}; + +class ICoreGroupQuery +{ +public: + ICoreGroupQuery() = default; + virtual ~ICoreGroupQuery(); + ICoreGroupQuery(const ICoreGroupQuery&) = default; + ICoreGroupQuery& operator=(const ICoreGroupQuery&) = default; + ICoreGroupQuery(ICoreGroupQuery&&) = default; + ICoreGroupQuery& operator=(ICoreGroupQuery&&) = default; + + virtual QString getGroupPeerName(int groupNumber, int peerId) const = 0; + virtual ToxPk getGroupPeerPk(int groupNumber, int peerId) const = 0; + virtual QString getGroupTitle(int groupNumber) const = 0; + virtual GroupRole getGroupPeerRole(int groupNumber, int peerId) const = 0; + virtual bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) = 0; + virtual bool kickGroupPeer(int groupNumber, int peerId) = 0; + virtual bool setGroupPassword(int groupNumber, const QByteArray& password) = 0; + virtual bool setGroupPeerLimit(int groupNumber, uint16_t peerLimit) = 0; + virtual bool setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) = 0; + virtual bool setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) = 0; + virtual bool setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) = 0; +}; diff --git a/src/core/icoresettings.h b/src/core/icoresettings.h index ceef5e6450..d0f490b5b9 100644 --- a/src/core/icoresettings.h +++ b/src/core/icoresettings.h @@ -48,6 +48,8 @@ class ICoreSettings virtual QNetworkProxy getProxy() const = 0; + virtual QStringList getSavedGroups() const = 0; + DECLARE_SIGNAL(enableIPv6Changed, bool enabled); DECLARE_SIGNAL(forceTCPChanged, bool enabled); DECLARE_SIGNAL(enableLanDiscoveryChanged, bool enabled); diff --git a/src/core/toxid.cpp b/src/core/toxid.cpp index 1a0094128d..a444ec4c01 100644 --- a/src/core/toxid.cpp +++ b/src/core/toxid.cpp @@ -15,7 +15,7 @@ #include const QRegularExpression - ToxId::ToxIdRegEx(QString("(^|\\s)[A-Fa-f0-9]{%1}($|\\s)").arg(ToxId::numHexChars)); + ToxId::ToxIdRegEx(QString("(^|\\s)[A-Fa-f0-9]{%1}($|\\s)").arg(TOX_ADDRESS_SIZE * 2)); /** * @class ToxId @@ -92,8 +92,8 @@ ToxId::ToxId(const QByteArray& rawId) * If the given rawId isn't a valid Public Key or Tox ID a ToxId with all zero bytes is created. * * @param rawId Pointer to bytes to convert to ToxId object - * @param len Number of bytes to read. Must be ToxPk::size for a Public Key or - * ToxId::size for a Tox ID. + * @param len Number of bytes to read. Must be TOX_PUBLIC_KEY_SIZE for a Public Key or + * TOX_ADDRESS_SIZE for a Tox ID. */ ToxId::ToxId(const uint8_t* rawId, int len) { @@ -104,7 +104,7 @@ ToxId::ToxId(const uint8_t* rawId, int len) void ToxId::constructToxId(const QByteArray& rawId) { - if (rawId.length() == ToxId::size && isToxId(QString::fromUtf8(rawId.toHex()).toUpper())) { + if (rawId.length() == TOX_ADDRESS_SIZE && isToxId(QString::fromUtf8(rawId.toHex()).toUpper())) { toxId = QByteArray(rawId); // construct from full tox id } else { assert(!"ToxId constructed with invalid input"); @@ -169,7 +169,7 @@ const uint8_t* ToxId::getBytes() const */ ToxPk ToxId::getPublicKey() const { - const auto pkBytes = toxId.left(ToxPk::size); + const auto pkBytes = toxId.left(TOX_PUBLIC_KEY_SIZE); if (pkBytes.isEmpty()) { return ToxPk{}; } @@ -182,8 +182,8 @@ ToxPk ToxId::getPublicKey() const */ QString ToxId::getNoSpamString() const { - if (toxId.length() == ToxId::size) { - return QString::fromUtf8(toxId.mid(ToxPk::size, ToxId::nospamSize).toHex()).toUpper(); + if (toxId.length() == TOX_ADDRESS_SIZE) { + return QString::fromUtf8(toxId.mid(TOX_PUBLIC_KEY_SIZE, TOX_NOSPAM_SIZE).toHex()).toUpper(); } return {}; @@ -208,7 +208,7 @@ bool ToxId::isValidToxId(const QString& id) */ bool ToxId::isToxId(const QString& id) { - return id.length() == ToxId::numHexChars && id.contains(ToxIdRegEx); + return id.length() == TOX_ADDRESS_SIZE * 2 && id.contains(ToxIdRegEx); } /** @@ -217,15 +217,16 @@ bool ToxId::isToxId(const QString& id) */ bool ToxId::isValid() const { - if (toxId.length() != ToxId::size) { + if (toxId.length() != TOX_ADDRESS_SIZE) { return false; } - const int pkAndChecksum = ToxPk::size + ToxId::nospamSize; + constexpr int checksumSize = TOX_ADDRESS_SIZE - TOX_PUBLIC_KEY_SIZE - TOX_NOSPAM_SIZE; + const int pkAndChecksum = TOX_PUBLIC_KEY_SIZE + TOX_NOSPAM_SIZE; QByteArray data = toxId.left(pkAndChecksum); - const QByteArray checksum = toxId.right(ToxId::checksumSize); - QByteArray calculated(ToxId::checksumSize, 0x00); + const QByteArray checksum = toxId.right(checksumSize); + QByteArray calculated(checksumSize, 0x00); for (int i = 0; i < pkAndChecksum; i++) { calculated[i % 2] = calculated[i % 2] ^ data[i]; diff --git a/src/core/toxid.h b/src/core/toxid.h index 2a85ebeca3..136f5f25e6 100644 --- a/src/core/toxid.h +++ b/src/core/toxid.h @@ -16,13 +16,6 @@ class ToxId { public: - static constexpr int nospamSize = 4; - static constexpr int nospamNumHexChars = nospamSize * 2; - static constexpr int checksumSize = 2; - static constexpr int checksumNumHexChars = checksumSize * 2; - static constexpr int size = 38; - static constexpr int numHexChars = size * 2; - ToxId(); ToxId(const ToxId& other); ToxId(ToxId&& other); diff --git a/src/core/toxpk.cpp b/src/core/toxpk.cpp index bba26ddbc7..0fd00949d3 100644 --- a/src/core/toxpk.cpp +++ b/src/core/toxpk.cpp @@ -23,24 +23,24 @@ ToxPk::ToxPk() = default; /** * @brief Constructs a ToxPk from bytes. * @param rawId The bytes to construct the ToxPk from. The length must be exactly - * ToxPk::size, else the ToxPk will be empty. + * TOX_PUBLIC_KEY_SIZE, else the ToxPk will be empty. */ ToxPk::ToxPk(QByteArray rawId) : ChatId(std::move(rawId)) { - if (id.length() != size) { + if (id.length() != TOX_PUBLIC_KEY_SIZE) { qCritical("ToxPk constructed with invalid length (%u instead of %d)", - static_cast(id.length()), size); + static_cast(id.length()), TOX_PUBLIC_KEY_SIZE); } } /** * @brief Constructs a ToxPk from bytes. * @param rawId The bytes to construct the ToxPk from, will read exactly - * ToxPk::size from the specified buffer. + * TOX_PUBLIC_KEY_SIZE from the specified buffer. */ ToxPk::ToxPk(const uint8_t* rawId) - : ToxPk(QByteArray(reinterpret_cast(rawId), size)) + : ToxPk(QByteArray(reinterpret_cast(rawId), TOX_PUBLIC_KEY_SIZE)) { } @@ -53,9 +53,9 @@ ToxPk::ToxPk(const uint8_t* rawId) */ ToxPk::ToxPk(const QString& pk) : ToxPk([&pk]() { - if (pk.length() != numHexChars) { + if (pk.length() != TOX_PUBLIC_KEY_SIZE * 2) { qCritical("ToxPk constructed with invalid length string (%u instead of %d)", - static_cast(pk.length()), numHexChars); + static_cast(pk.length()), TOX_PUBLIC_KEY_SIZE * 2); } return QByteArray::fromHex(pk.toLatin1()); }()) @@ -68,7 +68,7 @@ ToxPk::ToxPk(const QString& pk) */ int ToxPk::getSize() const { - return size; + return TOX_PUBLIC_KEY_SIZE; } std::unique_ptr ToxPk::clone() const diff --git a/src/core/toxpk.h b/src/core/toxpk.h index a67842f0b8..b1bc6e137d 100644 --- a/src/core/toxpk.h +++ b/src/core/toxpk.h @@ -10,12 +10,11 @@ #include #include +#include class ToxPk : public ChatId { public: - static constexpr int size = 32; - static constexpr int numHexChars = size * 2; ToxPk(); explicit ToxPk(QByteArray rawId); explicit ToxPk(const uint8_t* rawId); diff --git a/src/grouplist.cpp b/src/grouplist.cpp new file mode 100644 index 0000000000..cb32c557cb --- /dev/null +++ b/src/grouplist.cpp @@ -0,0 +1,74 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "grouplist.h" + +#include "src/core/core.h" +#include "src/model/group.h" + +#include +#include + +Group* GroupList::addGroup(Core& core, uint32_t groupNum, const GroupId& groupId, + const QString& groupName, const QString& selfName, FriendList& friendList) +{ + auto checker = groupList.find(groupId); + if (checker != groupList.end()) { + qWarning() << "addGroup: groupId already taken"; + } + + auto* newGroup = new Group(groupNum, groupId, groupName, selfName, core, core, friendList); + groupList[groupId] = newGroup; + id2key[groupNum] = groupId; + return newGroup; +} + +Group* GroupList::findGroup(const GroupId& groupId) +{ + auto g_it = groupList.find(groupId); + if (g_it != groupList.end()) { + return *g_it; + } + + return nullptr; +} + +const GroupId& GroupList::id2Key(uint32_t groupNum) +{ + return id2key[groupNum]; +} + +void GroupList::removeGroup(const GroupId& groupId, bool /*fake*/) +{ + auto g_it = groupList.find(groupId); + if (g_it != groupList.end()) { + groupList.erase(g_it); + } + for (auto it = id2key.begin(); it != id2key.end();) { + if (it.value() == groupId) { + it = id2key.erase(it); + } else { + ++it; + } + } +} + +QList GroupList::getAllGroups() +{ + QList res; + + for (auto* it : groupList) { + res.append(it); + } + + return res; +} + +void GroupList::clear() +{ + for (auto* groupptr : groupList) { + delete groupptr; + } + groupList.clear(); +} diff --git a/src/grouplist.h b/src/grouplist.h new file mode 100644 index 0000000000..22bc72924b --- /dev/null +++ b/src/grouplist.h @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/core/groupid.h" + +class Core; +template +class QHash; +template +class QList; +class Group; +class QString; +class FriendList; + +class GroupList +{ +public: + Group* addGroup(Core& core, uint32_t groupNum, const GroupId& persistentGroupId, + const QString& groupName, const QString& selfName, FriendList& friendList); + Group* findGroup(const GroupId& groupId); + const GroupId& id2Key(uint32_t groupNum); + void removeGroup(const GroupId& groupId, bool fake = false); + QList getAllGroups(); + void clear(); + +private: + QHash groupList; + QHash id2key; +}; diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 5b631b841d..3b819a5411 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -966,6 +966,47 @@ + + + + + 55 + 35 + + + + Qt::NoFocus + + + Create a group + + + Group + + + Open group management page + + + false + + + + + + + :/img/group.svg:/img/group.svg + + + + 15 + 15 + + + + true + + + diff --git a/src/model/chathistory.cpp b/src/model/chathistory.cpp index 3392a6c886..04265d8c60 100644 --- a/src/model/chathistory.cpp +++ b/src/model/chathistory.cpp @@ -62,12 +62,12 @@ bool handleActionPrefix(QString& content) ChatHistory::ChatHistory(Chat& chat_, History* history_, const ICoreIdHandler& coreIdHandler_, const Settings& settings_, IMessageDispatcher& messageDispatcher, - FriendList& friendList, ConferenceList& conferenceList) + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList) : chat(chat_) , history(history_) , settings(settings_) , coreIdHandler(coreIdHandler_) - , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList) + , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList, groupList) { connect(&messageDispatcher, &IMessageDispatcher::messageComplete, this, &ChatHistory::onMessageComplete); diff --git a/src/model/chathistory.h b/src/model/chathistory.h index dcc39b2602..55c2a1e386 100644 --- a/src/model/chathistory.h +++ b/src/model/chathistory.h @@ -17,6 +17,7 @@ class ICoreIdHandler; class Settings; class FriendList; class ConferenceList; +class GroupList; class ChatHistory : public IChatLog { @@ -24,7 +25,7 @@ class ChatHistory : public IChatLog public: ChatHistory(Chat& chat_, History* history_, const ICoreIdHandler& coreIdHandler_, const Settings& settings_, IMessageDispatcher& messageDispatcher, - FriendList& friendList, ConferenceList& conferenceList); + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList); const ChatLogItem& at(ChatLogIdx idx) const override; SearchResult searchForward(SearchPos startIdx, const QString& phrase, const ParameterSearch& parameter) const override; diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index f2f63c7941..fa5279d4b7 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -9,26 +9,31 @@ #include "src/core/core.h" #include "src/core/coreav.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/model/chathistory.h" #include "src/model/chatroom/conferenceroom.h" #include "src/model/chatroom/friendchatroom.h" +#include "src/model/chatroom/grouproom.h" #include "src/model/conference.h" #include "src/model/friend.h" +#include "src/model/group.h" #include "src/persistence/profile.h" #include "src/persistence/settings.h" #include #include +#include ChatManager::ChatManager(Profile& profile_, Settings& settings_, FriendList& friendList_, - ConferenceList& conferenceList_, IDialogsManager* dialogsManager_, - QObject* parent) + ConferenceList& conferenceList_, GroupList& groupList_, + IDialogsManager* dialogsManager_, QObject* parent) : QObject(parent) , profile(profile_) , settings(settings_) , friendList(friendList_) , conferenceList(conferenceList_) + , groupList(groupList_) , dialogsManager(dialogsManager_) , sharedMessageProcessorParams( std::make_unique(Core::getMaxMessageSize())) @@ -53,6 +58,22 @@ void ChatManager::connectToCore(Core& core_) connect(core, &Core::conferencePeerlistChanged, this, &ChatManager::onConferencePeerlistChanged); connect(core, &Core::conferencePeerNameChanged, this, &ChatManager::onConferencePeerNameChanged); connect(core, &Core::conferenceTitleChanged, this, &ChatManager::onConferenceTitleChanged); + connect(core, &Core::groupMessageReceived, this, &ChatManager::onGroupMessageReceived); + connect(core, &Core::emptyGroupCreated, this, &ChatManager::onEmptyGroupCreated); + connect(core, &Core::groupJoined, this, &ChatManager::onGroupJoined); + connect(core, &Core::groupPeerJoined, this, &ChatManager::onGroupPeerJoined); + connect(core, &Core::groupPeerExited, this, &ChatManager::onGroupPeerExited); + connect(core, &Core::groupPeerNameChanged, this, &ChatManager::onGroupPeerNameChanged); + connect(core, &Core::groupTopicChanged, this, &ChatManager::onGroupTopicChanged); + connect(core, &Core::groupSelfJoined, this, &ChatManager::onGroupSelfJoined); + connect(core, &Core::groupSelfDisconnected, this, &ChatManager::onGroupSelfDisconnected); + connect(core, &Core::groupJoinFailed, this, &ChatManager::onGroupJoinFailed); + connect(core, &Core::groupPeerRolesChanged, this, &ChatManager::onGroupPeerRolesChanged); + connect(core, &Core::groupPasswordChanged, this, &ChatManager::onGroupPasswordChanged); + connect(core, &Core::groupPeerLimitChanged, this, &ChatManager::onGroupPeerLimitChanged); + connect(core, &Core::groupTopicLockChanged, this, &ChatManager::onGroupTopicLockChanged); + connect(core, &Core::groupVoiceStateChanged, this, &ChatManager::onGroupVoiceStateChanged); + connect(core, &Core::groupPrivacyStateChanged, this, &ChatManager::onGroupPrivacyStateChanged); } FriendMessageDispatcher* ChatManager::getFriendDispatcher(const ToxPk& friendPk) const @@ -109,6 +130,33 @@ std::shared_ptr ChatManager::getConferenceRoom(const ConferenceI return *it; } +GroupMessageDispatcher* ChatManager::getGroupDispatcher(const GroupId& groupId) const +{ + auto it = groupMessageDispatchers.find(groupId); + if (it == groupMessageDispatchers.end()) { + return nullptr; + } + return it->get(); +} + +IChatLog* ChatManager::getGroupChatLog(const GroupId& groupId) const +{ + auto it = groupLogs.find(groupId); + if (it == groupLogs.end()) { + return nullptr; + } + return it->get(); +} + +std::shared_ptr ChatManager::getGroupRoom(const GroupId& groupId) const +{ + auto it = groupRooms.find(groupId); + if (it == groupRooms.end()) { + return nullptr; + } + return *it; +} + MessageProcessor::SharedParams& ChatManager::getSharedMessageProcessorParams() { return *sharedMessageProcessorParams; @@ -163,6 +211,28 @@ void ChatManager::removeConferenceModel(const ConferenceId& conferenceId) conferenceRooms.remove(conferenceId); } +void ChatManager::removeGroup(const GroupId& groupId) +{ + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + return; + } + + core->quitGroup(g->getId()); + + groupMessageDispatchers.remove(groupId); + groupLogs.remove(groupId); + groupRooms.remove(groupId); + settings.removeSavedGroup(groupId.toString()); +} + +void ChatManager::removeGroupModel(const GroupId& groupId) +{ + groupMessageDispatchers.remove(groupId); + groupLogs.remove(groupId); + groupRooms.remove(groupId); +} + void ChatManager::onFriendAdded(uint32_t friendId, const ToxPk& friendPk) { assert(core != nullptr); @@ -179,7 +249,7 @@ void ChatManager::onFriendAdded(uint32_t friendId, const ToxPk& friendPk) auto* history = profile.getHistory(); auto chatHistory = std::make_shared(*newFriend, history, *core, settings, - *friendMessageDispatcher, friendList, conferenceList); + *friendMessageDispatcher, friendList, conferenceList, groupList); friendMessageDispatchers[friendPk] = friendMessageDispatcher; friendChatLogs[friendPk] = chatHistory; @@ -306,6 +376,195 @@ void ChatManager::onConferenceTitleChanged(uint32_t conferencenumber, const QStr c->setTitle(author, title); } +void ChatManager::onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, + bool isAction) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + const ToxPk author = core->getGroupPeerPk(groupNumber, peerId); + + groupMessageDispatchers[groupId]->onMessageReceived(author, isAction, message); +} + +void ChatManager::onEmptyGroupCreated(uint32_t groupNumber, const GroupId& groupId, + const QString& groupName) +{ + Group* group = createGroup(groupNumber, groupId, groupName); + if (group == nullptr) { + return; + } + if (!groupId.isEmpty()) { + settings.addSavedGroup(groupId.toString()); + if (!groupName.isEmpty()) { + settings.setGroupName(groupId.toString(), groupName); + } + } + addSelfToGroup(group); +} + +void ChatManager::onGroupJoined(uint32_t groupNumber, const GroupId& groupId) +{ + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + QString groupName = core->getGroupTitle(groupNumber); + if (groupName.isEmpty()) { + groupName = settings.getGroupName(groupId.toString()); + } + g = createGroup(groupNumber, groupId, groupName); + } + if (g != nullptr) { + addSelfToGroup(g); + } + if (!groupId.isEmpty()) { + settings.addSavedGroup(groupId.toString()); + } +} + +void ChatManager::onGroupPeerJoined(uint32_t groupNumber, uint32_t peerId) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + g->onPeerJoin(peerId); +} + +void ChatManager::onGroupPeerExited(uint32_t groupNumber, uint32_t peerId) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + g->onPeerExit(peerId); +} + +void ChatManager::onGroupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + g->onPeerNameChanged(peerId, newName); +} + +void ChatManager::onGroupTopicChanged(uint32_t groupNumber, const QString& topic) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + g->setTopic(QString(), topic); +} + +void ChatManager::onGroupSelfJoined(uint32_t groupNumber) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + const GroupId persistentId = core->getGroupPersistentId(groupNumber); + if (!persistentId.isEmpty()) { + g = groupList.findGroup(persistentId); + } + if (g == nullptr) { + QString groupName = core->getGroupTitle(groupNumber); + if (groupName.isEmpty()) { + groupName = settings.getGroupName(persistentId.toString()); + } + g = createGroup(groupNumber, persistentId, groupName); + } + } + if (g != nullptr) { + addSelfToGroup(g); + const QString groupName = core->getGroupTitle(groupNumber); + if (!groupName.isEmpty()) { + g->updateName(groupName); + settings.setGroupName(groupId.toString(), groupName); + } + } +} + +void ChatManager::addSelfToGroup(Group* g) +{ + const uint32_t groupNumber = g->getId(); + const uint32_t selfPeerId = core->getGroupSelfPeerId(groupNumber); + if (selfPeerId == std::numeric_limits::max()) { + return; + } + g->onPeerJoin(selfPeerId); +} + +void ChatManager::onGroupSelfDisconnected(uint32_t groupNumber) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); +} + +void ChatManager::onGroupJoinFailed(uint32_t groupNumber) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + removeGroup(groupId); +} + +void ChatManager::onGroupPeerRolesChanged(uint32_t groupNumber) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->updatePeerRoles(); + } +} + +void ChatManager::onGroupPasswordChanged(uint32_t groupNumber, bool hasPassword) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->setPasswordSet(hasPassword); + } +} + +void ChatManager::onGroupPeerLimitChanged(uint32_t groupNumber, uint16_t peerLimit) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->setPeerLimit(peerLimit); + } +} + +void ChatManager::onGroupTopicLockChanged(uint32_t groupNumber, GroupTopicLock topicLock) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->setTopicLock(topicLock); + } +} + +void ChatManager::onGroupVoiceStateChanged(uint32_t groupNumber, GroupVoiceState voiceState) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->setVoiceState(voiceState); + } +} + +void ChatManager::onGroupPrivacyStateChanged(uint32_t groupNumber, GroupPrivacyState privacyState) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->setPrivacyState(privacyState); + } +} + Conference* ChatManager::createConference(uint32_t conferencenumber, const ConferenceId& conferenceId) { assert(core != nullptr); @@ -340,7 +599,8 @@ Conference* ChatManager::createConference(uint32_t conferencenumber, const Confe auto* history = profile.getHistory(); auto chatHistory = std::make_shared(*newConference, history, *core, settings, - *messageDispatcher, friendList, conferenceList); + *messageDispatcher, friendList, conferenceList, + groupList); connect(core, &Core::usernameSet, newConference, &Conference::setSelfName); @@ -352,3 +612,44 @@ Conference* ChatManager::createConference(uint32_t conferencenumber, const Confe return newConference; } + +Group* ChatManager::createGroup(uint32_t groupNumber, const GroupId& groupId, const QString& groupName) +{ + assert(core != nullptr); + + QString name = groupName; + if (name.isEmpty() && !groupId.isEmpty()) { + name = tr("Group %1").arg(groupId.toString().left(8)); + } + + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + qWarning() << "Group already exists"; + return g; + } + + Group* newGroup = groupList.addGroup(*core, groupNumber, groupId, name, + core->getUsername(), friendList); + assert(newGroup); + + auto chatroom = std::make_shared(newGroup, dialogsManager, *core, friendList); + auto messageDispatcher = + std::make_shared(*newGroup, + MessageProcessor(*sharedMessageProcessorParams), + *core, *core, settings); + + auto* history = profile.getHistory(); + auto chatHistory = std::make_shared(*newGroup, history, *core, settings, + *messageDispatcher, friendList, conferenceList, + groupList); + + connect(core, &Core::usernameSet, newGroup, &Group::setSelfName); + + groupMessageDispatchers[groupId] = messageDispatcher; + groupLogs[groupId] = chatHistory; + groupRooms[groupId] = chatroom; + + emit groupAdded(newGroup, chatroom, messageDispatcher, chatHistory); + + return newGroup; +} diff --git a/src/model/chatmanager.h b/src/model/chatmanager.h index 5dbfddd08b..037e291175 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -6,10 +6,13 @@ #pragma once #include "src/core/conferenceid.h" +#include "src/core/groupid.h" +#include "src/core/icoregroupquery.h" #include "src/core/receiptnum.h" #include "src/core/toxpk.h" #include "src/model/conferencemessagedispatcher.h" #include "src/model/friendmessagedispatcher.h" +#include "src/model/groupmessagedispatcher.h" #include "src/model/message.h" #include @@ -25,6 +28,9 @@ class Core; class Friend; class FriendChatroom; class FriendList; +class Group; +class GroupList; +class GroupRoom; class IChatLog; class IDialogsManager; class Profile; @@ -36,8 +42,8 @@ class ChatManager : public QObject public: ChatManager(Profile& profile, Settings& settings, FriendList& friendList, - ConferenceList& conferenceList, IDialogsManager* dialogsManager, - QObject* parent = nullptr); + ConferenceList& conferenceList, GroupList& groupList, + IDialogsManager* dialogsManager, QObject* parent = nullptr); void connectToCore(Core& core); @@ -47,6 +53,9 @@ class ChatManager : public QObject ConferenceMessageDispatcher* getConferenceDispatcher(const ConferenceId& id) const; IChatLog* getConferenceChatLog(const ConferenceId& id) const; std::shared_ptr getConferenceRoom(const ConferenceId& id) const; + GroupMessageDispatcher* getGroupDispatcher(const GroupId& groupId) const; + IChatLog* getGroupChatLog(const GroupId& groupId) const; + std::shared_ptr getGroupRoom(const GroupId& groupId) const; MessageProcessor::SharedParams& getSharedMessageProcessorParams(); @@ -54,6 +63,8 @@ class ChatManager : public QObject void removeConference(const ConferenceId& conferenceId); void removeFriendModel(const ToxPk& friendPk); void removeConferenceModel(const ConferenceId& conferenceId); + void removeGroup(const GroupId& groupId); + void removeGroupModel(const GroupId& groupId); signals: void friendAdded(Friend* f, std::shared_ptr chatroom, @@ -65,6 +76,10 @@ class ChatManager : public QObject std::shared_ptr chatLog); void conferenceRemoved(const ConferenceId& conferenceId); void conferenceNeedsName(const ConferenceId& conferenceId); + void groupAdded(Group* g, std::shared_ptr chatroom, + std::shared_ptr dispatcher, + std::shared_ptr chatLog); + void groupRemoved(const GroupId& groupId); private slots: void onFriendAdded(uint32_t friendId, const ToxPk& friendPk); @@ -84,14 +99,35 @@ private slots: const QString& newName); void onConferenceTitleChanged(uint32_t conferenceNum, const QString& author, const QString& title); + void onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, + bool isAction); + void onEmptyGroupCreated(uint32_t groupNumber, const GroupId& groupId, const QString& groupName); + void onGroupJoined(uint32_t groupNumber, const GroupId& groupId); + void onGroupPeerJoined(uint32_t groupNumber, uint32_t peerId); + void onGroupPeerExited(uint32_t groupNumber, uint32_t peerId); + void onGroupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName); + void onGroupTopicChanged(uint32_t groupNumber, const QString& topic); + void onGroupSelfJoined(uint32_t groupNumber); + void onGroupSelfDisconnected(uint32_t groupNumber); + void onGroupJoinFailed(uint32_t groupNumber); + void onGroupPeerRolesChanged(uint32_t groupNumber); + void onGroupPasswordChanged(uint32_t groupNumber, bool hasPassword); + void onGroupPeerLimitChanged(uint32_t groupNumber, uint16_t peerLimit); + void onGroupTopicLockChanged(uint32_t groupNumber, GroupTopicLock topicLock); + void onGroupVoiceStateChanged(uint32_t groupNumber, GroupVoiceState voiceState); + void onGroupPrivacyStateChanged(uint32_t groupNumber, GroupPrivacyState privacyState); + private: Conference* createConference(uint32_t conferenceNum, const ConferenceId& conferenceId); + Group* createGroup(uint32_t groupNumber, const GroupId& groupId, const QString& groupName); + void addSelfToGroup(Group* g); Profile& profile; Core* core = nullptr; Settings& settings; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; IDialogsManager* dialogsManager; std::unique_ptr sharedMessageProcessorParams; @@ -103,4 +139,8 @@ private slots: QMap> conferenceMessageDispatchers; QMap> conferenceLogs; QMap> conferenceRooms; + + QMap> groupMessageDispatchers; + QMap> groupLogs; + QMap> groupRooms; }; diff --git a/src/model/chatroom/grouproom.cpp b/src/model/chatroom/grouproom.cpp new file mode 100644 index 0000000000..fb5d234536 --- /dev/null +++ b/src/model/chatroom/grouproom.cpp @@ -0,0 +1,82 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "grouproom.h" + +#include "src/core/core.h" +#include "src/core/toxpk.h" +#include "src/friendlist.h" +#include "src/model/chatroom/friendchatroom.h" +#include "src/model/dialogs/idialogsmanager.h" +#include "src/model/friend.h" +#include "src/model/group.h" +#include "src/model/status.h" + +GroupRoom::GroupRoom(Group* group_, IDialogsManager* dialogsManager_, Core& core_, + FriendList& friendList_) + : group{group_} + , dialogsManager{dialogsManager_} + , core{core_} + , friendList{friendList_} +{ +} + +Chat* GroupRoom::getChat() +{ + return group; +} + +Group* GroupRoom::getGroup() +{ + return group; +} + +bool GroupRoom::hasNewMessage() const +{ + return group->getEventFlag(); +} + +void GroupRoom::resetEventFlags() +{ + group->setEventFlag(false); + group->setMentionedFlag(false); +} + +bool GroupRoom::friendExists(const ToxPk& pk) +{ + return friendList.findFriend(pk) != nullptr; +} + +void GroupRoom::inviteFriend(const ToxPk& pk) +{ + const Friend* frnd = friendList.findFriend(pk); + const auto friendId = frnd->getId(); + const auto groupNumber = group->getId(); + const auto canInvite = Status::isOnline(frnd->getStatus()); + + if (canInvite) { + core.groupInviteFriend(friendId, groupNumber); + } +} + +bool GroupRoom::possibleToOpenInNewWindow() const +{ + const auto groupId = group->getPersistentId(); + auto* const dialogs = dialogsManager->getGroupDialogs(groupId); + return (dialogs == nullptr) || dialogs->chatroomCount() > 1; +} + +bool GroupRoom::canBeRemovedFromWindow() const +{ + const auto groupId = group->getPersistentId(); + auto* const dialogs = dialogsManager->getGroupDialogs(groupId); + return (dialogs != nullptr) && dialogs->hasChat(groupId); +} + +void GroupRoom::removeGroupFromDialogs() +{ + const auto groupId = group->getPersistentId(); + auto* dialogs = dialogsManager->getGroupDialogs(groupId); + dialogs->removeGroup(groupId); +} diff --git a/src/model/chatroom/grouproom.h b/src/model/chatroom/grouproom.h new file mode 100644 index 0000000000..4e7de8f831 --- /dev/null +++ b/src/model/chatroom/grouproom.h @@ -0,0 +1,41 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include + +class Chat; +class Core; +class IDialogsManager; +class Group; +class ToxPk; +class FriendList; + +class GroupRoom final : public QObject +{ + Q_OBJECT +public: + GroupRoom(Group* group_, IDialogsManager* dialogsManager_, Core& core_, FriendList& friendList); + + Chat* getChat(); + + Group* getGroup(); + + bool hasNewMessage() const; + void resetEventFlags(); + + bool friendExists(const ToxPk& pk); + void inviteFriend(const ToxPk& pk); + + bool possibleToOpenInNewWindow() const; + bool canBeRemovedFromWindow() const; + void removeGroupFromDialogs(); + +private: + Group* group{nullptr}; + IDialogsManager* dialogsManager{nullptr}; + Core& core; + FriendList& friendList; +}; diff --git a/src/model/dialogs/idialogs.h b/src/model/dialogs/idialogs.h index 911214747b..cef48d463e 100644 --- a/src/model/dialogs/idialogs.h +++ b/src/model/dialogs/idialogs.h @@ -7,6 +7,7 @@ class ChatId; class ConferenceId; +class GroupId; class ToxPk; class IDialogs @@ -24,6 +25,7 @@ class IDialogs virtual void removeFriend(const ToxPk& friendPk) = 0; virtual void removeConference(const ConferenceId& conferenceId) = 0; + virtual void removeGroup(const GroupId& groupId) = 0; virtual int chatroomCount() const = 0; }; diff --git a/src/model/dialogs/idialogsmanager.h b/src/model/dialogs/idialogsmanager.h index 6b25426540..cebebd9c68 100644 --- a/src/model/dialogs/idialogsmanager.h +++ b/src/model/dialogs/idialogsmanager.h @@ -8,6 +8,7 @@ #include "idialogs.h" class ConferenceId; +class GroupId; class ToxPk; class IDialogsManager @@ -22,4 +23,5 @@ class IDialogsManager virtual IDialogs* getFriendDialogs(const ToxPk& friendPk) const = 0; virtual IDialogs* getConferenceDialogs(const ConferenceId& conferenceId) const = 0; + virtual IDialogs* getGroupDialogs(const GroupId& groupId) const = 0; }; diff --git a/src/model/group.cpp b/src/model/group.cpp new file mode 100644 index 0000000000..5e5d410b28 --- /dev/null +++ b/src/model/group.cpp @@ -0,0 +1,360 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "group.h" + +#include "src/core/chatid.h" +#include "src/core/groupid.h" +#include "src/core/toxpk.h" +#include "src/friendlist.h" + +#include + +#include +#include + +namespace { +const int MAX_GROUP_NAME_LENGTH = 48; +const int MAX_GROUP_TOPIC_LENGTH = 512; +} // namespace + +Group::Group(int groupId_, const GroupId persistentGroupId, QString name, QString selfName_, + ICoreGroupQuery& groupQuery_, ICoreIdHandler& idHandler_, FriendList& friendList_) + : groupQuery(groupQuery_) + , idHandler(idHandler_) + , selfName{std::move(selfName_)} + , groupName{std::move(name)} + , toxGroupNum(groupId_) + , groupId{persistentGroupId} + , friendList{friendList_} +{ + hasNewMessages = false; + userWasMentioned = false; +} + +void Group::setName(const QString& newTitle) +{ + const QString shortTitle = newTitle.left(MAX_GROUP_NAME_LENGTH); + if (!shortTitle.isEmpty() && groupName != shortTitle) { + groupName = shortTitle; + emit displayedNameChanged(groupName); + emit titleChanged(selfName, groupName); + } +} + +void Group::updateName(const QString& newTitle) +{ + const QString shortTitle = newTitle.left(MAX_GROUP_NAME_LENGTH); + if (!shortTitle.isEmpty() && groupName != shortTitle) { + groupName = shortTitle; + emit displayedNameChanged(groupName); + emit titleChanged(selfName, groupName); + } +} + +QString Group::getName() const +{ + return groupName; +} + +QString Group::getDisplayedName() const +{ + return getName(); +} + +QString Group::getDisplayedName(const ToxPk& contact) const +{ + return resolveToxPk(contact); +} + +uint32_t Group::getId() const +{ + return toxGroupNum; +} + +const GroupId& Group::getPersistentId() const +{ + return groupId; +} + +int Group::getPeersCount() const +{ + return peerDisplayNames.size(); +} + +/** + * @brief Gets the PKs and names of all peers + * @return PKs and names of all peers, including our own PK and name + */ +const QMap& Group::getPeerList() const +{ + return peerDisplayNames; +} + +bool Group::peerHasNickname(ToxPk pk) +{ + return peerDisplayNames.contains(pk); +} + +void Group::setEventFlag(bool f) +{ + hasNewMessages = f; +} + +bool Group::getEventFlag() const +{ + return hasNewMessages; +} + +void Group::setMentionedFlag(bool f) +{ + userWasMentioned = f; +} + +bool Group::getMentionedFlag() const +{ + return userWasMentioned; +} + +QString Group::resolveToxPk(const ToxPk& id) const +{ + auto it = peerDisplayNames.find(id); + + if (it != peerDisplayNames.end()) { + return *it; + } + + return {}; +} + +void Group::setSelfName(const QString& name) +{ + selfName = name; +} + +QString Group::getSelfName() const +{ + return selfName; +} + +void Group::setTopic(const QString& author, const QString& newTopic) +{ + const QString shortTopic = newTopic.left(MAX_GROUP_TOPIC_LENGTH); + if (topic != shortTopic) { + topic = shortTopic; + emit topicChanged(author, topic); + } +} + +QString Group::getTopic() const +{ + return topic; +} + +void Group::setPasswordSet(bool hasPassword_) +{ + if (hasPassword != hasPassword_) { + hasPassword = hasPassword_; + emit passwordSetChanged(hasPassword); + } +} + +bool Group::isPasswordSet() const +{ + return hasPassword; +} + +void Group::setPeerLimit(uint16_t peerLimit_) +{ + if (peerLimit != peerLimit_) { + peerLimit = peerLimit_; + emit peerLimitChanged(peerLimit); + } +} + +uint16_t Group::getPeerLimit() const +{ + return peerLimit; +} + +void Group::setTopicLock(GroupTopicLock topicLock_) +{ + if (topicLock != topicLock_) { + topicLock = topicLock_; + emit topicLockChanged(topicLock); + } +} + +GroupTopicLock Group::getTopicLock() const +{ + return topicLock; +} + +void Group::setVoiceState(GroupVoiceState voiceState_) +{ + if (voiceState != voiceState_) { + voiceState = voiceState_; + emit voiceStateChanged(voiceState); + } +} + +GroupVoiceState Group::getVoiceState() const +{ + return voiceState; +} + +void Group::setPrivacyState(GroupPrivacyState privacyState_) +{ + if (privacyState != privacyState_) { + privacyState = privacyState_; + emit privacyStateChanged(privacyState); + } +} + +GroupPrivacyState Group::getPrivacyState() const +{ + return privacyState; +} + +bool Group::setGroupPassword(const QByteArray& password) +{ + return groupQuery.setGroupPassword(toxGroupNum, password); +} + +bool Group::setGroupPeerLimit(uint16_t peerLimit_) +{ + return groupQuery.setGroupPeerLimit(toxGroupNum, peerLimit_); +} + +bool Group::setGroupTopicLock(GroupTopicLock topicLock_) +{ + return groupQuery.setGroupTopicLock(toxGroupNum, topicLock_); +} + +bool Group::setGroupVoiceState(GroupVoiceState voiceState_) +{ + return groupQuery.setGroupVoiceState(toxGroupNum, voiceState_); +} + +bool Group::setGroupPrivacyState(GroupPrivacyState privacyState_) +{ + return groupQuery.setGroupPrivacyState(toxGroupNum, privacyState_); +} + +QString Group::resolvePeerName(uint32_t peerId) const +{ + const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); + if (pk == idHandler.getSelfPublicKey()) { + return idHandler.getUsername(); + } + + const QString peerName = groupQuery.getGroupPeerName(toxGroupNum, peerId); + return friendList.decideNickname(pk, peerName); +} + +void Group::onPeerJoin(uint32_t peerId) +{ + const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); + peerIdToPk[peerId] = pk; + peerRoles[pk] = groupQuery.getGroupPeerRole(toxGroupNum, peerId); + const QString name = resolvePeerName(peerId); + if (peerDisplayNames.contains(pk)) { + if (peerDisplayNames[pk] != name) { + const QString oldName = peerDisplayNames[pk]; + peerDisplayNames[pk] = name; + emit peerNameChanged(pk, oldName, name); + } + return; + } + + peerDisplayNames[pk] = name; + emit userJoined(pk, name); + emit numPeersChanged(peerDisplayNames.size()); +} + +void Group::onPeerExit(uint32_t peerId) +{ + const ToxPk pk = resolvePeerPk(peerId); + peerIdToPk.remove(peerId); + auto it = peerDisplayNames.find(pk); + if (it == peerDisplayNames.end()) { + return; + } + + const QString name = it.value(); + peerDisplayNames.erase(it); + emit userLeft(pk, name); + emit numPeersChanged(peerDisplayNames.size()); +} + +void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) +{ + const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); + if (pk == idHandler.getSelfPublicKey()) { + return; + } + + peerIdToPk[peerId] = pk; + + const QString displayName = friendList.decideNickname(pk, newName); + if (!peerDisplayNames.contains(pk)) { + peerDisplayNames[pk] = displayName; + emit userJoined(pk, displayName); + emit numPeersChanged(peerDisplayNames.size()); + return; + } + + if (peerDisplayNames[pk] != displayName) { + const auto oldName = peerDisplayNames[pk]; + peerDisplayNames[pk] = displayName; + emit peerNameChanged(pk, oldName, displayName); + } +} + +void Group::updatePeerRoles() +{ + bool changed = false; + for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { + const GroupRole role = groupQuery.getGroupPeerRole(toxGroupNum, it.key()); + if (peerRoles.value(it.value(), GroupRole::Unknown) != role) { + peerRoles[it.value()] = role; + changed = true; + } + } + if (changed) { + emit peerRolesChanged(); + } +} + +GroupRole Group::getPeerRole(const ToxPk& pk) const +{ + return peerRoles.value(pk, GroupRole::User); +} + +bool Group::setPeerRole(const ToxPk& pk, GroupRole role) +{ + for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { + if (it.value() == pk) { + return groupQuery.setGroupPeerRole(toxGroupNum, it.key(), role); + } + } + qWarning() << "setPeerRole: unknown peer" << pk.toString(); + return false; +} + +bool Group::kickPeer(const ToxPk& pk) +{ + for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { + if (it.value() == pk) { + return groupQuery.kickGroupPeer(toxGroupNum, it.key()); + } + } + qWarning() << "kickPeer: unknown peer" << pk.toString(); + return false; +} + +ToxPk Group::resolvePeerPk(uint32_t peerId) const +{ + return peerIdToPk.value(peerId, ToxPk{}); +} diff --git a/src/model/group.h b/src/model/group.h new file mode 100644 index 0000000000..21fbf45097 --- /dev/null +++ b/src/model/group.h @@ -0,0 +1,115 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "chat.h" + +#include "src/core/chatid.h" +#include "src/core/groupid.h" +#include "src/core/icoregroupquery.h" +#include "src/core/icoreidhandler.h" +#include "src/core/toxpk.h" + +#include +#include +#include +#include +#include + +class FriendList; + +class Group : public Chat +{ + Q_OBJECT +public: + Group(int groupId_, GroupId persistentGroupId, QString groupName, QString selfName_, + ICoreGroupQuery& groupQuery_, ICoreIdHandler& idHandler_, FriendList& friendList); + uint32_t getId() const override; + const GroupId& getPersistentId() const override; + int getPeersCount() const; + const QMap& getPeerList() const; + bool peerHasNickname(ToxPk pk); + + void setEventFlag(bool f) override; + bool getEventFlag() const override; + + void setMentionedFlag(bool f); + bool getMentionedFlag() const; + + void setName(const QString& newTitle) override; + void updateName(const QString& newTitle); + QString getName() const; + QString getDisplayedName() const override; + QString getDisplayedName(const ToxPk& contact) const override; + QString resolveToxPk(const ToxPk& id) const; + void setSelfName(const QString& name); + QString getSelfName() const; + + void setTopic(const QString& author, const QString& newTopic); + QString getTopic() const; + + void setPasswordSet(bool hasPassword); + bool isPasswordSet() const; + void setPeerLimit(uint16_t peerLimit); + uint16_t getPeerLimit() const; + void setTopicLock(GroupTopicLock topicLock); + GroupTopicLock getTopicLock() const; + void setVoiceState(GroupVoiceState voiceState); + GroupVoiceState getVoiceState() const; + void setPrivacyState(GroupPrivacyState privacyState); + GroupPrivacyState getPrivacyState() const; + + bool setGroupPassword(const QByteArray& password); + bool setGroupPeerLimit(uint16_t peerLimit); + bool setGroupTopicLock(GroupTopicLock topicLock); + bool setGroupVoiceState(GroupVoiceState voiceState); + bool setGroupPrivacyState(GroupPrivacyState privacyState); + + void onPeerJoin(uint32_t peerId); + void onPeerExit(uint32_t peerId); + void onPeerNameChanged(uint32_t peerId, const QString& newName); + void updatePeerRoles(); + GroupRole getPeerRole(const ToxPk& pk) const; + bool setPeerRole(const ToxPk& pk, GroupRole role); + bool kickPeer(const ToxPk& pk); + ToxPk resolvePeerPk(uint32_t peerId) const; + +signals: + void titleChanged(const QString& author, const QString& title); + void topicChanged(const QString& author, const QString& topic); + void userJoined(const ToxPk& user, const QString& name); + void userLeft(const ToxPk& user, const QString& name); + void numPeersChanged(int numPeers); + void peerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName); + void peerRolesChanged(); + void passwordSetChanged(bool hasPassword); + void peerLimitChanged(uint16_t peerLimit); + void topicLockChanged(GroupTopicLock topicLock); + void voiceStateChanged(GroupVoiceState voiceState); + void privacyStateChanged(GroupPrivacyState privacyState); + +private: + QString resolvePeerName(uint32_t peerId) const; + +private: + ICoreGroupQuery& groupQuery; + ICoreIdHandler& idHandler; + QString selfName; + QString groupName; + QString topic; + bool hasPassword = false; + uint16_t peerLimit = 0; + GroupTopicLock topicLock = GroupTopicLock::Unknown; + GroupVoiceState voiceState = GroupVoiceState::Unknown; + GroupPrivacyState privacyState = GroupPrivacyState::Unknown; + QMap peerDisplayNames; + QMap peerIdToPk; + QMap peerRoles; + bool hasNewMessages; + bool userWasMentioned; + int toxGroupNum; + const GroupId groupId; + FriendList& friendList; +}; diff --git a/src/model/groupinvite.cpp b/src/model/groupinvite.cpp new file mode 100644 index 0000000000..31db8e6b39 --- /dev/null +++ b/src/model/groupinvite.cpp @@ -0,0 +1,47 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupinvite.h" + +#include + +/** + * @class GroupInvite + * + * @brief This class contains information needed to accept a group invite + */ + +GroupInvite::GroupInvite(uint32_t friendId_, QByteArray inviteData_, QString groupName_) + : friendId{friendId_} + , inviteData{std::move(inviteData_)} + , groupName{std::move(groupName_)} + , date{QDateTime::currentDateTime()} +{ +} + +bool GroupInvite::operator==(const GroupInvite& other) const +{ + return friendId == other.friendId && inviteData == other.inviteData + && groupName == other.groupName && date == other.date; +} + +uint32_t GroupInvite::getFriendId() const +{ + return friendId; +} + +QByteArray GroupInvite::getInviteData() const +{ + return inviteData; +} + +QString GroupInvite::getGroupName() const +{ + return groupName; +} + +QDateTime GroupInvite::getInviteDate() const +{ + return date; +} diff --git a/src/model/groupinvite.h b/src/model/groupinvite.h new file mode 100644 index 0000000000..c81a6e352c --- /dev/null +++ b/src/model/groupinvite.h @@ -0,0 +1,30 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include +#include +#include + +#include + +class GroupInvite +{ +public: + GroupInvite() = default; + GroupInvite(uint32_t friendId_, QByteArray inviteData_, QString groupName_); + bool operator==(const GroupInvite& other) const; + + uint32_t getFriendId() const; + QByteArray getInviteData() const; + QString getGroupName() const; + QDateTime getInviteDate() const; + +private: + uint32_t friendId{0}; + QByteArray inviteData; + QString groupName; + QDateTime date; +}; diff --git a/src/model/groupmessagedispatcher.cpp b/src/model/groupmessagedispatcher.cpp new file mode 100644 index 0000000000..e16a881555 --- /dev/null +++ b/src/model/groupmessagedispatcher.cpp @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupmessagedispatcher.h" + +#include "src/persistence/settings.h" + +#include + +GroupMessageDispatcher::GroupMessageDispatcher(Group& g_, MessageProcessor processor_, + ICoreIdHandler& idHandler_, + ICoreGroupMessageSender& messageSender_, + Settings& settings_) + : group(g_) + , processor(processor_) + , idHandler(idHandler_) + , messageSender(messageSender_) + , settings(settings_) +{ + processor.enableMentions(); +} + +std::pair +GroupMessageDispatcher::sendMessage(bool isAction, const QString& content) +{ + const auto firstMessageId = nextMessageId; + auto lastMessageId = firstMessageId; + + for (const auto& message : processor.processOutgoingMessage(isAction, content)) { + auto messageId = nextMessageId++; + lastMessageId = messageId; + if (message.isAction) { + messageSender.sendGroupAction(group.getId(), message.content); + } else { + messageSender.sendGroupMessage(group.getId(), message.content); + } + + // Emit both signals since we do not have receipts for groups + emit messageSent(messageId, message); + emit messageComplete(messageId); + } + + return std::make_pair(firstMessageId, lastMessageId); +} + +/** + * @brief Processes and dispatches received message from toxcore + * @param[in] sender + * @param[in] isAction True if is action + * @param[in] content Message content + */ +void GroupMessageDispatcher::onMessageReceived(const ToxPk& sender, bool isAction, + const QString& content) +{ + const bool isSelf = sender == idHandler.getSelfPublicKey(); + + if (isSelf) { + return; + } + + if (settings.getBlockList().contains(sender.toString())) { + qDebug() << "onGroupMessageReceived: Filtered:" << sender.toString(); + return; + } + + emit messageReceived(sender, processor.processIncomingCoreMessage(isAction, content)); +} diff --git a/src/model/groupmessagedispatcher.h b/src/model/groupmessagedispatcher.h new file mode 100644 index 0000000000..52d0d1dc84 --- /dev/null +++ b/src/model/groupmessagedispatcher.h @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/core/icoregroupmessagesender.h" +#include "src/core/icoreidhandler.h" +#include "src/model/group.h" +#include "src/model/imessagedispatcher.h" +#include "src/model/message.h" + +#include +#include + +class Settings; + +class GroupMessageDispatcher : public IMessageDispatcher +{ + Q_OBJECT +public: + GroupMessageDispatcher(Group& g_, MessageProcessor processor, ICoreIdHandler& idHandler, + ICoreGroupMessageSender& messageSender, Settings& settings); + + std::pair sendMessage(bool isAction, + const QString& content) override; + + void onMessageReceived(const ToxPk& sender, bool isAction, const QString& content); + +private: + Group& group; + MessageProcessor processor; + ICoreIdHandler& idHandler; + ICoreGroupMessageSender& messageSender; + Settings& settings; + DispatchedMessageId nextMessageId{0}; +}; diff --git a/src/model/notificationgenerator.cpp b/src/model/notificationgenerator.cpp index ccf7344d9f..69e74dfcbb 100644 --- a/src/model/notificationgenerator.cpp +++ b/src/model/notificationgenerator.cpp @@ -97,6 +97,25 @@ NotificationData NotificationGenerator::conferenceMessageNotification(const Conf return ret; } +NotificationData NotificationGenerator::groupMessageNotification(const Group* g, + const ToxPk& sender, + const QString& message) +{ + NotificationData ret; + ret.category = "transfer"; + + if (notificationSettings.getNotifyHide()) { + ret.title = tr("New group message"); + return ret; + } + + ret.title = g->getName(); + ret.message = message; + ret.pixmap = getSenderAvatar(profile, sender); + + return ret; +} + NotificationData NotificationGenerator::fileTransferNotification(const Friend* f, const QString& filename, size_t fileSize) @@ -136,6 +155,23 @@ NotificationData NotificationGenerator::conferenceInvitationNotification(const F return ret; } +NotificationData NotificationGenerator::groupInvitationNotification(const Friend* from) +{ + NotificationData ret; + ret.category = "im"; + + if (notificationSettings.getNotifyHide()) { + ret.title = tr("Group invite received"); + return ret; + } + + ret.title = tr("%1 invites you to join a group.").arg(from->getDisplayedName()); + ret.message = ""; + ret.pixmap = getSenderAvatar(profile, from->getPublicKey()); + + return ret; +} + NotificationData NotificationGenerator::friendRequestNotification(const ToxPk& sender, const QString& message) { diff --git a/src/model/notificationgenerator.h b/src/model/notificationgenerator.h index 6a6d3e63c8..705313fc36 100644 --- a/src/model/notificationgenerator.h +++ b/src/model/notificationgenerator.h @@ -8,6 +8,7 @@ #include "conference.h" #include "friend.h" +#include "group.h" #include "notificationdata.h" #include "src/persistence/inotificationsettings.h" @@ -36,9 +37,12 @@ class NotificationGenerator : public QObject NotificationData incomingCallNotification(const Friend* f); NotificationData conferenceMessageNotification(const Conference* c, const ToxPk& sender, const QString& message); + NotificationData groupMessageNotification(const Group* g, const ToxPk& sender, + const QString& message); NotificationData fileTransferNotification(const Friend* f, const QString& filename, size_t fileSize); NotificationData conferenceInvitationNotification(const Friend* from); + NotificationData groupInvitationNotification(const Friend* from); NotificationData friendRequestNotification(const ToxPk& sender, const QString& message); public slots: diff --git a/src/model/sessionchatlog.cpp b/src/model/sessionchatlog.cpp index 650a9231a4..f667e525b0 100644 --- a/src/model/sessionchatlog.cpp +++ b/src/model/sessionchatlog.cpp @@ -7,8 +7,10 @@ #include "src/conferencelist.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/model/conference.h" #include "src/model/friend.h" +#include "src/model/group.h" #include #include @@ -80,7 +82,8 @@ firstItemAfterDate(QDate date, const std::map& items) const QDateTime& b) { return toDate(a) < b.date(); }); } -QString resolveToxPk(FriendList& friendList, ConferenceList& conferenceList, const ToxPk& pk) +QString resolveToxPk(FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList, + const ToxPk& pk) { Friend* f = friendList.findFriend(pk); if (f != nullptr) { @@ -94,15 +97,23 @@ QString resolveToxPk(FriendList& friendList, ConferenceList& conferenceList, con } } + for (Group* it : groupList.getAllGroups()) { + QString res = it->resolveToxPk(pk); + if (!res.isEmpty()) { + return res; + } + } + return pk.toString(); } } // namespace SessionChatLog::SessionChatLog(const ICoreIdHandler& coreIdHandler_, FriendList& friendList_, - ConferenceList& conferenceList_) + ConferenceList& conferenceList_, GroupList& groupList_) : coreIdHandler(coreIdHandler_) , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} { } @@ -110,11 +121,13 @@ SessionChatLog::SessionChatLog(const ICoreIdHandler& coreIdHandler_, FriendList& * @brief Alternate constructor that allows for an initial index to be set */ SessionChatLog::SessionChatLog(ChatLogIdx initialIdx, const ICoreIdHandler& coreIdHandler_, - FriendList& friendList_, ConferenceList& conferenceList_) + FriendList& friendList_, ConferenceList& conferenceList_, + GroupList& groupList_) : coreIdHandler(coreIdHandler_) , nextIdx(initialIdx) , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} { } @@ -126,7 +139,7 @@ QString SessionChatLog::resolveSenderNameFromSender(const ToxPk& sender) const QString myNickName = coreIdHandler.getUsername().isEmpty() ? sender.toString() : coreIdHandler.getUsername(); - return isSelf ? myNickName : resolveToxPk(friendList, conferenceList, sender); + return isSelf ? myNickName : resolveToxPk(friendList, conferenceList, groupList, sender); } const ChatLogItem& SessionChatLog::at(ChatLogIdx idx) const diff --git a/src/model/sessionchatlog.h b/src/model/sessionchatlog.h index c437f7bfc7..929e73bf8d 100644 --- a/src/model/sessionchatlog.h +++ b/src/model/sessionchatlog.h @@ -15,15 +15,16 @@ struct SessionChatLogMetadata; class ICoreIdHandler; class FriendList; class ConferenceList; +class GroupList; class SessionChatLog : public IChatLog { Q_OBJECT public: SessionChatLog(const ICoreIdHandler& coreIdHandler_, FriendList& friendList, - ConferenceList& conferenceList); + ConferenceList& conferenceList, GroupList& groupList); SessionChatLog(ChatLogIdx initialIdx, const ICoreIdHandler& coreIdHandler_, - FriendList& friendList, ConferenceList& conferenceList); + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList); ~SessionChatLog() override; const ChatLogItem& at(ChatLogIdx idx) const override; @@ -88,4 +89,5 @@ public slots: QMap outgoingMessages; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; }; diff --git a/src/nexus.cpp b/src/nexus.cpp index bbad7c88b2..4dd90fadc2 100644 --- a/src/nexus.cpp +++ b/src/nexus.cpp @@ -12,6 +12,7 @@ #include "src/core/coreav.h" #include "src/ipc.h" #include "src/model/conferenceinvite.h" +#include "src/model/groupinvite.h" #include "src/model/status.h" #include "src/persistence/profile.h" #include "src/widget/style.h" @@ -106,6 +107,7 @@ void Nexus::start() qRegisterMetaType("ConferenceId"); qRegisterMetaType("ChatId"); qRegisterMetaType("ConferenceInvite"); + qRegisterMetaType("GroupInvite"); qRegisterMetaType("ReceiptNum"); qRegisterMetaType("RowId"); qRegisterMetaType("uint64_t"); diff --git a/src/persistence/ifriendsettings.h b/src/persistence/ifriendsettings.h index baf735f2cd..a7e5be9ccc 100644 --- a/src/persistence/ifriendsettings.h +++ b/src/persistence/ifriendsettings.h @@ -43,6 +43,9 @@ class IFriendSettings virtual bool getAutoConferenceInvite(const ToxPk& pk) const = 0; virtual void setAutoConferenceInvite(const ToxPk& pk, bool accept) = 0; + virtual bool getAutoGroupInvite(const ToxPk& pk) const = 0; + virtual void setAutoGroupInvite(const ToxPk& pk, bool accept) = 0; + virtual QString getFriendAlias(const ToxPk& pk) const = 0; virtual void setFriendAlias(const ToxPk& pk, const QString& alias) = 0; @@ -58,6 +61,7 @@ class IFriendSettings signals: DECLARE_SIGNAL(autoAcceptCallChanged, const ToxPk& pk, AutoAcceptCallFlags accept); DECLARE_SIGNAL(autoConferenceInviteChanged, const ToxPk& pk, bool accept); + DECLARE_SIGNAL(autoGroupInviteChanged, const ToxPk& pk, bool accept); DECLARE_SIGNAL(autoAcceptDirChanged, const ToxPk& pk, const QString& dir); DECLARE_SIGNAL(contactNoteChanged, const ToxPk& pk, const QString& note); }; diff --git a/src/persistence/personalsettingsupgrader.cpp b/src/persistence/personalsettingsupgrader.cpp index 5ab87d4462..3f96227669 100644 --- a/src/persistence/personalsettingsupgrader.cpp +++ b/src/persistence/personalsettingsupgrader.cpp @@ -22,7 +22,7 @@ bool version0to1(SettingsSerializer& ps) for (int i = 0; i < size; i++) { ps.setArrayIndex(i); const auto oldFriendAddr = ps.value("addr").toString(); - auto newFriendAddr = oldFriendAddr.left(ToxPk::numHexChars); + auto newFriendAddr = oldFriendAddr.left(TOX_PUBLIC_KEY_SIZE * 2); ps.setValue("addr", newFriendAddr); } ps.endArray(); diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index 59f9d48803..b3492a1cf4 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -29,6 +29,8 @@ #include #include #include +#include +#include #include #include #include @@ -644,6 +646,16 @@ void Settings::loadPersonal(const Profile& profile, bool newProfile) blockList = ps.value("blackList").toString().split('\n'); }); + inGroup(ps, "Groups", [this, &ps] { + savedGroups = ps.value("groupList").toString().split('\n'); + const QJsonObject names = + QJsonDocument::fromJson(ps.value("groupNames").toString().toUtf8()).object(); + groupNames.clear(); + for (auto it = names.constBegin(); it != names.constEnd(); ++it) { + groupNames.insert(it.key(), it.value().toString()); + } + }); + inGroup(ps, "Friends", [this, &ps] { inArray(ps, "Friend", &friendLst, [this, &ps] { FriendProp fp{ps.value("addr").toString()}; @@ -657,11 +669,12 @@ void Settings::loadPersonal(const Profile& profile, bool newProfile) fp.autoAcceptCall = Settings::AutoAcceptCallFlags(QFlag(ps.value("autoAcceptCall", 0).toInt())); fp.autoConferenceInvite = ps.value("autoConferenceInvite").toBool(); + fp.autoGroupInvite = ps.value("autoGroupInvite").toBool(); fp.circleID = ps.value("circle", -1).toInt(); if (getEnableLogging()) fp.activity = ps.value("activity", QDateTime()).toDateTime(); - friendLst.insert(ToxPk(fp.addr.mid(0, ToxPk::numHexChars)).getByteArray(), fp); + friendLst.insert(ToxPk(fp.addr.mid(0, TOX_PUBLIC_KEY_SIZE * 2)).getByteArray(), fp); }); }); @@ -870,6 +883,7 @@ void Settings::savePersonal(QString profileName, const ToxEncrypt* passkey) ps.setValue("autoAcceptDir", frnd.autoAcceptDir); ps.setValue("autoAcceptCall", static_cast(frnd.autoAcceptCall)); ps.setValue("autoConferenceInvite", frnd.autoConferenceInvite); + ps.setValue("autoGroupInvite", frnd.autoGroupInvite); ps.setValue("circle", frnd.circleID); if (getEnableLogging()) @@ -909,6 +923,16 @@ void Settings::savePersonal(QString profileName, const ToxEncrypt* passkey) ps.setValue("blackList", blockList.join('\n')); }); + inGroup(ps, "Groups", [this, &ps] { + ps.setValue("groupList", savedGroups.join('\n')); + QJsonObject names; + for (auto it = groupNames.cbegin(); it != groupNames.cend(); ++it) { + names.insert(it.key(), it.value()); + } + ps.setValue("groupNames", + QString::fromUtf8(QJsonDocument(names).toJson(QJsonDocument::Compact))); + }); + inGroup(ps, "Version", [this, &ps] { // ps.setValue("settingsVersion", personalSettingsVersion); }); @@ -1493,6 +1517,37 @@ void Settings::setAutoConferenceInvite(const ToxPk& id, bool accept) } } +bool Settings::getAutoGroupInvite(const ToxPk& id) const +{ + const QMutexLocker locker{&bigLock}; + + auto it = friendLst.find(id.getByteArray()); + if (it != friendLst.end()) { + return it->autoGroupInvite; + } + + return false; +} + +void Settings::setAutoGroupInvite(const ToxPk& id, bool accept) +{ + bool updated = false; + { + const QMutexLocker locker{&bigLock}; + + auto& frnd = getOrInsertFriendPropRef(id); + + if (frnd.autoGroupInvite != accept) { + frnd.autoGroupInvite = accept; + updated = true; + } + } + + if (updated) { + emit autoGroupInviteChanged(id, accept); + } +} + QString Settings::getContactNote(const ToxPk& id) const { const QMutexLocker locker{&bigLock}; @@ -1844,6 +1899,49 @@ void Settings::setBlockList(const QStringList& blist) } } +QStringList Settings::getSavedGroups() const +{ + const QMutexLocker locker{&bigLock}; + return savedGroups; +} + +void Settings::setSavedGroups(const QStringList& glist) +{ + const QMutexLocker locker{&bigLock}; + savedGroups = glist; +} + +void Settings::addSavedGroup(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + if (!savedGroups.contains(groupIdHex)) { + savedGroups.append(groupIdHex); + } +} + +void Settings::removeSavedGroup(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + savedGroups.removeAll(groupIdHex); + groupNames.remove(groupIdHex); +} + +QString Settings::getGroupName(const QString& groupIdHex) const +{ + const QMutexLocker locker{&bigLock}; + return groupNames.value(groupIdHex); +} + +void Settings::setGroupName(const QString& groupIdHex, const QString& name) +{ + const QMutexLocker locker{&bigLock}; + if (name.isEmpty()) { + groupNames.remove(groupIdHex); + } else { + groupNames.insert(groupIdHex, name); + } +} + QString Settings::getInDev() const { const QMutexLocker locker{&bigLock}; diff --git a/src/persistence/settings.h b/src/persistence/settings.h index 70c527c2e4..eb981192e4 100644 --- a/src/persistence/settings.h +++ b/src/persistence/settings.h @@ -446,6 +446,9 @@ public slots: bool getAutoConferenceInvite(const ToxPk& id) const override; void setAutoConferenceInvite(const ToxPk& id, bool accept) override; + bool getAutoGroupInvite(const ToxPk& id) const override; + void setAutoGroupInvite(const ToxPk& id, bool accept) override; + // ChatView const QFont& getChatMessageFont() const; void setChatMessageFont(const QFont& font); @@ -480,6 +483,14 @@ public slots: void setShowConferenceJoinLeaveMessages(bool newValue) override; SIGNAL_IMPL(Settings, showConferenceJoinLeaveMessagesChanged, bool show) + // Groups + QStringList getSavedGroups() const; + void setSavedGroups(const QStringList& glist); + void addSavedGroup(const QString& groupIdHex); + void removeSavedGroup(const QString& groupIdHex); + QString getGroupName(const QString& groupIdHex) const; + void setGroupName(const QString& groupIdHex, const QString& name); + // State QByteArray getWindowGeometry() const; void setWindowGeometry(const QByteArray& value); @@ -516,6 +527,7 @@ public slots: SIGNAL_IMPL(Settings, autoAcceptCallChanged, const ToxPk& id, IFriendSettings::AutoAcceptCallFlags accept) SIGNAL_IMPL(Settings, autoConferenceInviteChanged, const ToxPk& id, bool accept) + SIGNAL_IMPL(Settings, autoGroupInviteChanged, const ToxPk& id, bool accept) SIGNAL_IMPL(Settings, autoAcceptDirChanged, const ToxPk& id, const QString& dir) SIGNAL_IMPL(Settings, contactNoteChanged, const ToxPk& id, const QString& note) @@ -691,6 +703,10 @@ private slots: Db::syncType dbSyncType; QStringList blockList; + // Groups + QStringList savedGroups; + QHash groupNames; + // Audio QString inDev; bool audioInDevEnabled; @@ -724,6 +740,7 @@ private slots: QDateTime activity = QDateTime(); AutoAcceptCallFlags autoAcceptCall; bool autoConferenceInvite = false; + bool autoGroupInvite = false; }; struct CircleProp diff --git a/src/widget/chatformheader.cpp b/src/widget/chatformheader.cpp index bcabe7deeb..13d44cf43f 100644 --- a/src/widget/chatformheader.cpp +++ b/src/widget/chatformheader.cpp @@ -288,6 +288,11 @@ void ChatFormHeader::reloadTheme() micButton->setStyleSheet(style.getStylesheet(STYLE_PATH, settings)); } +void ChatFormHeader::setNameEditable(bool editable) +{ + nameLabel->setEditable(editable); +} + void ChatFormHeader::addWidget(QWidget* widget, int stretch, Qt::Alignment alignment) { headTextLayout->addWidget(widget, stretch, alignment); diff --git a/src/widget/chatformheader.h b/src/widget/chatformheader.h index a6f182bc30..0c2e54da40 100644 --- a/src/widget/chatformheader.h +++ b/src/widget/chatformheader.h @@ -50,6 +50,7 @@ class ChatFormHeader : public QWidget ~ChatFormHeader() override; void setName(const QString& newName); + void setNameEditable(bool editable); void setMode(Mode mode_); void showOutgoingCall(bool video); diff --git a/src/widget/circlewidget.cpp b/src/widget/circlewidget.cpp index f61b56b203..303bb70d7f 100644 --- a/src/widget/circlewidget.cpp +++ b/src/widget/circlewidget.cpp @@ -29,7 +29,8 @@ QHash CircleWidget::circleList; CircleWidget::CircleWidget(const Core& core_, FriendListWidget* parent, int id_, Settings& settings_, Style& style_, IMessageBoxManager& messageBoxManager_, - FriendList& friendList_, ConferenceList& conferenceList_, Profile& profile_) + FriendList& friendList_, ConferenceList& conferenceList_, + GroupList& groupList_, Profile& profile_) : CategoryWidget(settings_.getCompactLayout(), settings_, style_, parent) , id(id_) , core{core_} @@ -38,6 +39,7 @@ CircleWidget::CircleWidget(const Core& core_, FriendListWidget* parent, int id_, , messageBoxManager{messageBoxManager_} , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} , profile{profile_} { setName(settings.getCircleName(id), false); @@ -110,7 +112,7 @@ void CircleWidget::contextMenuEvent(QContextMenuEvent* event) circleList.remove(replacedCircle); } else if (selectedItem == openAction) { auto* dialog = new ContentDialog(core, settings, style, messageBoxManager, friendList, - conferenceList, profile); + conferenceList, groupList, profile); emit newContentDialog(*dialog); for (int i = 0; i < friendOnlineLayout()->count(); ++i) { QWidget* const widget = friendOnlineLayout()->itemAt(i)->widget(); diff --git a/src/widget/circlewidget.h b/src/widget/circlewidget.h index 9549340c36..cf816d303f 100644 --- a/src/widget/circlewidget.h +++ b/src/widget/circlewidget.h @@ -14,6 +14,7 @@ class Style; class IMessageBoxManager; class FriendList; class ConferenceList; +class GroupList; class Profile; class CircleWidget final : public CategoryWidget @@ -22,7 +23,7 @@ class CircleWidget final : public CategoryWidget public: CircleWidget(const Core& core_, FriendListWidget* parent, int id_, Settings& settings, Style& style, IMessageBoxManager& messageboxManager, FriendList& friendList, - ConferenceList& conferenceList, Profile& profile); + ConferenceList& conferenceList, GroupList& groupList, Profile& profile); ~CircleWidget() override; void editName(); @@ -53,5 +54,6 @@ class CircleWidget final : public CategoryWidget IMessageBoxManager& messageBoxManager; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; Profile& profile; }; diff --git a/src/widget/conferencewidget.cpp b/src/widget/conferencewidget.cpp index ecffbae52e..b66be9ebe3 100644 --- a/src/widget/conferencewidget.cpp +++ b/src/widget/conferencewidget.cpp @@ -68,7 +68,7 @@ void ConferenceWidget::contextMenuEvent(QContextMenuEvent* event) installEventFilter(this); // Disable leave event. - QMenu menu(this); + QMenu menu; QAction* openChatWindow = nullptr; if (chatroom->possibleToOpenInNewWindow()) { @@ -83,7 +83,11 @@ void ConferenceWidget::contextMenuEvent(QContextMenuEvent* event) menu.addSeparator(); QAction* setTitle = menu.addAction(tr("Set title...")); - QAction* quitConference = menu.addAction(tr("Quit conference", "Menu to quit a conference")); + auto* quitConference = menu.addAction(tr("Quit conference", "Menu to quit a conference")); + // Deleting the widget from inside the menu handler would destroy the + // stack-allocated QMenu while it is still a child, so defer the removal. + connect(quitConference, &QAction::triggered, this, [this]() { emit removeConference(conferenceId); }, + Qt::QueuedConnection); QAction* selectedItem = menu.exec(event->globalPos()); @@ -97,9 +101,7 @@ void ConferenceWidget::contextMenuEvent(QContextMenuEvent* event) return; } - if (selectedItem == quitConference) { - emit removeConference(conferenceId); - } else if (selectedItem == openChatWindow) { + if (selectedItem == openChatWindow) { emit newWindowOpened(this); } else if (selectedItem == removeChatWindow) { chatroom->removeConferenceFromDialogs(); diff --git a/src/widget/contentdialog.cpp b/src/widget/contentdialog.cpp index 804bef6537..8aec3323e4 100644 --- a/src/widget/contentdialog.cpp +++ b/src/widget/contentdialog.cpp @@ -10,9 +10,11 @@ #include "src/conferencelist.h" #include "src/core/core.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/model/chatroom/friendchatroom.h" #include "src/model/conference.h" #include "src/model/friend.h" +#include "src/model/group.h" #include "src/model/status.h" #include "src/persistence/settings.h" #include "src/widget/conferencewidget.h" @@ -20,6 +22,7 @@ #include "src/widget/form/chatform.h" #include "src/widget/friendlistlayout.h" #include "src/widget/friendwidget.h" +#include "src/widget/groupwidget.h" #include "src/widget/style.h" #include "src/widget/translator.h" #include "src/widget/widget.h" @@ -32,6 +35,8 @@ #include #include +#include + namespace { const int minWidget = 220; const int minHeight = 220; @@ -41,7 +46,8 @@ const QSize defaultSize(720, 400); ContentDialog::ContentDialog(const Core& core, Settings& settings_, Style& style_, IMessageBoxManager& messageBoxManager_, FriendList& friendList_, - ConferenceList& conferenceList_, Profile& profile_, QWidget* parent) + ConferenceList& conferenceList_, GroupList& groupList_, Profile& profile_, + QWidget* parent) : ActivateDialog(style_, parent, Qt::Window) , splitter{new QSplitter(this)} , friendLayout{new FriendListLayout(this)} @@ -52,13 +58,14 @@ ContentDialog::ContentDialog(const Core& core, Settings& settings_, Style& style , messageBoxManager{messageBoxManager_} , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} , profile{profile_} { friendLayout->setContentsMargins(0, 0, 0, 0); friendLayout->setSpacing(0); layouts = {friendLayout->getLayoutOnline(), conferenceLayout.getLayout(), - friendLayout->getLayoutOffline()}; + friendLayout->getLayoutOffline(), groupLayout.getLayout()}; if (settings.getConferencePosition()) { layouts.swapItemsAt(0, 1); @@ -71,6 +78,8 @@ ContentDialog::ContentDialog(const Core& core, Settings& settings_, Style& style onConferencePositionChanged(settings.getConferencePosition()); + friendLayout->addLayout(groupLayout.getLayout()); + friendScroll = new QScrollArea(this); friendScroll->setMinimumWidth(minWidget); friendScroll->setFrameStyle(QFrame::NoFrame); @@ -183,6 +192,24 @@ ConferenceWidget* ContentDialog::addConference(std::shared_ptr c return conferenceWidget; } +GroupWidget* ContentDialog::addGroup(std::shared_ptr chatroom, GenericChatForm* form) +{ + auto* const g = chatroom->getGroup(); + const auto& groupId = g->getPersistentId(); + const auto compact = settings.getCompactLayout(); + auto* groupWidget = new GroupWidget(chatroom, compact, settings, style, this); + chatWidgets[groupId] = groupWidget; + groupLayout.addSortedWidget(groupWidget); + chatForms[groupId] = form; + + connect(groupWidget, &GroupWidget::chatroomWidgetClicked, this, &ContentDialog::activate); + + // FIXME: emit should be removed + emit groupWidget->chatroomWidgetClicked(groupWidget); + + return groupWidget; +} + void ContentDialog::removeFriend(const ToxPk& friendPk) { auto* chatroomWidget = qobject_cast(chatWidgets[friendPk]); @@ -236,6 +263,30 @@ void ContentDialog::removeConference(const ConferenceId& conferenceId) closeIfEmpty(); } +void ContentDialog::removeGroup(const GroupId& groupId) +{ + auto* chatroomWidget = qobject_cast(chatWidgets[groupId]); + // Need to find replacement to show here instead. + if (activeChatroomWidget == chatroomWidget) { + cycleChats(true, false); + } + + groupLayout.removeSortedWidget(chatroomWidget); + chatroomWidget->deleteLater(); + + if (chatroomCount() == 0) { + contentLayout->clear(); + activeChatroomWidget = nullptr; + deleteLater(); + } else { + update(); + } + + chatWidgets.remove(groupId); + chatForms.remove(groupId); + closeIfEmpty(); +} + void ContentDialog::closeIfEmpty() { if (chatWidgets.isEmpty()) { @@ -245,7 +296,8 @@ void ContentDialog::closeIfEmpty() int ContentDialog::chatroomCount() const { - return friendLayout->friendTotalCount() + conferenceLayout.getLayout()->count(); + return friendLayout->friendTotalCount() + conferenceLayout.getLayout()->count() + + groupLayout.getLayout()->count(); } void ContentDialog::ensureSplitterVisible() @@ -284,6 +336,12 @@ int ContentDialog::getCurrentLayout(QLayout*& layout) return index; } + layout = groupLayout.getLayout(); + index = groupLayout.indexOfSortedWidget(activeChatroomWidget); + if (index != -1) { + return index; + } + layout = nullptr; return -1; } @@ -302,19 +360,11 @@ void ContentDialog::cycleChats(bool forward, bool inverse) } if (!inverse && index == currentLayout->count() - 1) { - const bool conferencesOnTop = settings.getConferencePosition(); - const bool offlineEmpty = friendLayout->getLayoutOffline()->isEmpty(); - const bool onlineEmpty = friendLayout->getLayoutOnline()->isEmpty(); - const bool conferencesEmpty = conferenceLayout.getLayout()->isEmpty(); - const bool isCurOffline = currentLayout == friendLayout->getLayoutOffline(); - const bool isCurOnline = currentLayout == friendLayout->getLayoutOnline(); - const bool isCurConference = currentLayout == conferenceLayout.getLayout(); - const bool nextIsEmpty = - (isCurOnline && offlineEmpty && (conferencesEmpty || conferencesOnTop)) - || (isCurConference && offlineEmpty && (onlineEmpty || !conferencesOnTop)) - || (isCurOffline); - - if (nextIsEmpty) { + const bool allOthersEmpty = + std::all_of(layouts.begin(), layouts.end(), [currentLayout](const QLayout* l) { + return l == currentLayout || l->count() == 0; + }); + if (allOthersEmpty) { forward = !forward; } } @@ -387,6 +437,12 @@ void ContentDialog::updateTitleAndStatusIcon() return; } + const bool isGroup = activeChatroomWidget->getGroup() != nullptr; + if (isGroup) { + setWindowIcon(QIcon(":/img/group.svg")); + return; + } + const Status::Status currentStatus = activeChatroomWidget->getFriend()->getStatus(); setWindowIcon(QIcon{Status::getIconPath(currentStatus)}); } @@ -444,11 +500,14 @@ bool ContentDialog::event(QEvent* event) const Friend* frnd = activeChatroomWidget->getFriend(); Conference* conference = activeChatroomWidget->getConference(); + Group* group = activeChatroomWidget->getGroup(); if (frnd != nullptr) { emit friendDialogShown(frnd); } else if (conference != nullptr) { emit conferenceDialogShown(conference); + } else if (group != nullptr) { + emit groupDialogShown(group); } } @@ -465,6 +524,7 @@ void ContentDialog::dragEnterEvent(QDragEnterEvent* event) QObject* o = event->source(); auto* frnd = qobject_cast(o); auto* conference = qobject_cast(o); + auto* group = qobject_cast(o); if (frnd != nullptr) { assert(event->mimeData()->hasFormat("toxPk")); const ToxPk toxPk{event->mimeData()->data("toxPk")}; @@ -490,6 +550,17 @@ void ContentDialog::dragEnterEvent(QDragEnterEvent* event) if (!hasChat(conferenceId)) { event->acceptProposedAction(); } + } else if (group != nullptr) { + assert(event->mimeData()->hasFormat("groupId")); + const GroupId groupId = GroupId{event->mimeData()->data("groupId")}; + Group* contact = groupList.findGroup(groupId); + if (contact == nullptr) { + return; + } + + if (!hasChat(groupId)) { + event->acceptProposedAction(); + } } } @@ -498,6 +569,7 @@ void ContentDialog::dropEvent(QDropEvent* event) QObject* o = event->source(); auto* frnd = qobject_cast(o); auto* conference = qobject_cast(o); + auto* group = qobject_cast(o); if (frnd != nullptr) { assert(event->mimeData()->hasFormat("toxPk")); const ToxPk toxId(event->mimeData()->data("toxPk")); @@ -518,6 +590,16 @@ void ContentDialog::dropEvent(QDropEvent* event) emit addConferenceDialog(contact, this); ensureSplitterVisible(); + } else if (group != nullptr) { + assert(event->mimeData()->hasFormat("groupId")); + const GroupId groupId(event->mimeData()->data("groupId")); + Group* contact = groupList.findGroup(groupId); + if (contact == nullptr) { + return; + } + + emit addGroupDialog(contact, this); + ensureSplitterVisible(); } } diff --git a/src/widget/contentdialog.h b/src/widget/contentdialog.h index ac3c90204f..613bdb013b 100644 --- a/src/widget/contentdialog.h +++ b/src/widget/contentdialog.h @@ -6,6 +6,7 @@ #pragma once #include "src/core/conferenceid.h" +#include "src/core/groupid.h" #include "src/core/toxpk.h" #include "src/model/dialogs/idialogs.h" #include "src/model/status.h" @@ -28,6 +29,9 @@ class GenericChatroomWidget; class Conference; class ConferenceRoom; class ConferenceWidget; +class Group; +class GroupRoom; +class GroupWidget; class QCloseEvent; class QSplitter; class QScrollArea; @@ -36,6 +40,7 @@ class Style; class IMessageBoxManager; class FriendList; class ConferenceList; +class GroupList; class Profile; class ContentDialog : public ActivateDialog, public IDialogs @@ -44,13 +49,16 @@ class ContentDialog : public ActivateDialog, public IDialogs public: ContentDialog(const Core& core, Settings& settings, Style& style, IMessageBoxManager& messageBoxManager, FriendList& friendList, - ConferenceList& conferenceList, Profile& profile, QWidget* parent = nullptr); + ConferenceList& conferenceList, GroupList& groupList, Profile& profile, + QWidget* parent = nullptr); ~ContentDialog() override; FriendWidget* addFriend(std::shared_ptr chatroom, GenericChatForm* form); ConferenceWidget* addConference(std::shared_ptr chatroom, GenericChatForm* form); + GroupWidget* addGroup(std::shared_ptr chatroom, GenericChatForm* form); void removeFriend(const ToxPk& friendPk) override; void removeConference(const ConferenceId& conferenceId) override; + void removeGroup(const GroupId& groupId) override; int chatroomCount() const override; void ensureSplitterVisible(); void updateTitleAndStatusIcon(); @@ -74,8 +82,10 @@ class ContentDialog : public ActivateDialog, public IDialogs signals: void friendDialogShown(const Friend* f); void conferenceDialogShown(Conference* c); + void groupDialogShown(Group* g); void addFriendDialog(Friend* frnd, ContentDialog* contentDialog); void addConferenceDialog(Conference* conference, ContentDialog* contentDialog); + void addGroupDialog(Group* group, ContentDialog* contentDialog); void activated(); void willClose(); void connectFriendWidget(FriendWidget& friendWidget); @@ -121,6 +131,7 @@ private slots: QScrollArea* friendScroll; FriendListLayout* friendLayout; GenericChatItemLayout conferenceLayout; + GenericChatItemLayout groupLayout; ContentLayout* contentLayout; GenericChatroomWidget* activeChatroomWidget; QSize videoSurfaceSize; @@ -135,5 +146,6 @@ private slots: IMessageBoxManager& messageBoxManager; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; Profile& profile; }; diff --git a/src/widget/contentdialogmanager.cpp b/src/widget/contentdialogmanager.cpp index b1b015fd87..abff86842e 100644 --- a/src/widget/contentdialogmanager.cpp +++ b/src/widget/contentdialogmanager.cpp @@ -7,10 +7,13 @@ #include "src/conferencelist.h" #include "src/friendlist.h" +#include "src/model/chatroom/grouproom.h" #include "src/model/conference.h" #include "src/model/friend.h" +#include "src/model/group.h" #include "src/widget/conferencewidget.h" #include "src/widget/friendwidget.h" +#include "src/widget/groupwidget.h" namespace { void removeDialog(ContentDialog* dialog, @@ -78,6 +81,22 @@ ConferenceWidget* ContentDialogManager::addConferenceToDialog(ContentDialog* dia return conferenceWidget; } +GroupWidget* ContentDialogManager::addGroupToDialog(ContentDialog* dialog, + std::shared_ptr chatroom, + GenericChatForm* form) +{ + auto* groupWidget = dialog->addGroup(chatroom, form); + const auto& groupId = groupWidget->getGroup()->getPersistentId(); + + ContentDialog* lastDialog = getGroupDialog(groupId); + if (lastDialog != nullptr) { + lastDialog->removeGroup(groupId); + } + + chatDialogs[groupId] = dialog; + return groupWidget; +} + void ContentDialogManager::focusChat(const ChatId& chatId) { auto* dialog = focusDialog(chatId, chatDialogs); @@ -139,6 +158,19 @@ void ContentDialogManager::updateConferenceStatus(const ConferenceId& conference } } +void ContentDialogManager::updateGroupStatus(const GroupId& groupId) +{ + auto* dialog = chatDialogs.value(groupId); + if (dialog == nullptr) { + return; + } + + dialog->updateChatStatusLight(groupId); + if (dialog->isChatActive(groupId)) { + dialog->updateTitleAndStatusIcon(); + } +} + bool ContentDialogManager::isChatActive(const ChatId& chatId) { auto* const dialog = chatDialogs.value(chatId); @@ -159,6 +191,11 @@ ContentDialog* ContentDialogManager::getConferenceDialog(const ConferenceId& con return chatDialogs.value(conferenceId); } +ContentDialog* ContentDialogManager::getGroupDialog(const GroupId& groupId) const +{ + return chatDialogs.value(groupId); +} + void ContentDialogManager::addContentDialog(ContentDialog& dialog) { currentDialog = &dialog; @@ -191,3 +228,8 @@ IDialogs* ContentDialogManager::getConferenceDialogs(const ConferenceId& confere { return getConferenceDialog(conferenceId); } + +IDialogs* ContentDialogManager::getGroupDialogs(const GroupId& groupId) const +{ + return getGroupDialog(groupId); +} diff --git a/src/widget/contentdialogmanager.h b/src/widget/contentdialogmanager.h index 1173292a06..3ec83433fa 100644 --- a/src/widget/contentdialogmanager.h +++ b/src/widget/contentdialogmanager.h @@ -9,6 +9,7 @@ #include "src/core/chatid.h" #include "src/core/conferenceid.h" +#include "src/core/groupid.h" #include "src/core/toxpk.h" #include "src/model/dialogs/idialogsmanager.h" @@ -27,18 +28,23 @@ class ContentDialogManager : public QObject, public IDialogsManager void focusChat(const ChatId& chatId); void updateFriendStatus(const ToxPk& friendPk); void updateConferenceStatus(const ConferenceId& conferenceId); + void updateGroupStatus(const GroupId& groupId); bool isChatActive(const ChatId& chatId); ContentDialog* getFriendDialog(const ToxPk& friendPk) const; ContentDialog* getConferenceDialog(const ConferenceId& conferenceId) const; + ContentDialog* getGroupDialog(const GroupId& groupId) const; IDialogs* getFriendDialogs(const ToxPk& friendPk) const override; IDialogs* getConferenceDialogs(const ConferenceId& conferenceId) const override; + IDialogs* getGroupDialogs(const GroupId& groupId) const override; FriendWidget* addFriendToDialog(ContentDialog* dialog, std::shared_ptr chatroom, GenericChatForm* form); ConferenceWidget* addConferenceToDialog(ContentDialog* dialog, std::shared_ptr chatroom, GenericChatForm* form); + GroupWidget* addGroupToDialog(ContentDialog* dialog, std::shared_ptr chatroom, + GenericChatForm* form); void addContentDialog(ContentDialog& dialog); diff --git a/src/widget/form/chatform.cpp b/src/widget/form/chatform.cpp index b83b12fce9..de1719e0df 100644 --- a/src/widget/form/chatform.cpp +++ b/src/widget/form/chatform.cpp @@ -99,10 +99,10 @@ ChatForm::ChatForm(Profile& profile_, Friend* chatFriend, IChatLog& chatLog_, SmileyPack& smileyPack_, CameraSource& cameraSource_, Settings& settings_, Style& style_, IMessageBoxManager& messageBoxManager, ContentDialogManager& contentDialogManager_, FriendList& friendList_, - ConferenceList& conferenceList_, QWidget* parent_) + ConferenceList& conferenceList_, GroupList& groupList_, QWidget* parent_) : GenericChatForm(profile_.getCore(), chatFriend, chatLog_, messageDispatcher_, documentCache_, smileyPack_, settings_, style_, messageBoxManager, friendList_, - conferenceList_, parent_) + conferenceList_, groupList_, parent_) , core{profile_.getCore()} , f(chatFriend) , isTyping{false} diff --git a/src/widget/form/chatform.h b/src/widget/form/chatform.h index 99f41b8683..bec470dfcf 100644 --- a/src/widget/form/chatform.h +++ b/src/widget/form/chatform.h @@ -26,6 +26,7 @@ class FileTransferInstance; class Friend; class FriendList; class ConferenceList; +class GroupList; class History; class ImagePreviewButton; class IMessageBoxManager; @@ -46,7 +47,8 @@ class ChatForm : public GenericChatForm IMessageDispatcher& messageDispatcher_, DocumentCache& documentCache, SmileyPack& smileyPack, CameraSource& cameraSource, Settings& settings, Style& style, IMessageBoxManager& messageBoxManager, ContentDialogManager& contentDialogManager, - FriendList& friendList, ConferenceList& conferenceList, QWidget* parent = nullptr); + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList, + QWidget* parent = nullptr); ~ChatForm() override; void setStatusMessage(const QString& newMessage); diff --git a/src/widget/form/conferenceform.cpp b/src/widget/form/conferenceform.cpp index f1d526718a..868305e26e 100644 --- a/src/widget/form/conferenceform.cpp +++ b/src/widget/form/conferenceform.cpp @@ -71,9 +71,11 @@ ConferenceForm::ConferenceForm(Core& core_, Conference* chatConference, IChatLog IMessageDispatcher& messageDispatcher_, Settings& settings_, DocumentCache& documentCache_, SmileyPack& smileyPack_, Style& style_, IMessageBoxManager& messageBoxManager, - FriendList& friendList_, ConferenceList& conferenceList_) + FriendList& friendList_, ConferenceList& conferenceList_, + GroupList& groupList_) : GenericChatForm(core_, chatConference, chatLog_, messageDispatcher_, documentCache_, - smileyPack_, settings_, style_, messageBoxManager, friendList_, conferenceList_) + smileyPack_, settings_, style_, messageBoxManager, friendList_, + conferenceList_, groupList_) , core{core_} , conference(chatConference) , inCall(false) diff --git a/src/widget/form/conferenceform.h b/src/widget/form/conferenceform.h index 5559205c7a..c7c9cddc66 100644 --- a/src/widget/form/conferenceform.h +++ b/src/widget/form/conferenceform.h @@ -28,6 +28,7 @@ class Style; class IMessageBoxManager; class FriendList; class ConferenceList; +class GroupList; class ConferenceForm : public GenericChatForm { @@ -37,7 +38,7 @@ class ConferenceForm : public GenericChatForm IMessageDispatcher& messageDispatcher_, Settings& settings_, DocumentCache& documentCache, SmileyPack& smileyPack, Style& style, IMessageBoxManager& messageBoxManager, FriendList& friendList, - ConferenceList& conferenceList); + ConferenceList& conferenceList, GroupList& groupList); ~ConferenceForm() override; void peerAudioPlaying(ToxPk peerPk); diff --git a/src/widget/form/genericchatform.cpp b/src/widget/form/genericchatform.cpp index 4ba9befbbe..5b6070d2ba 100644 --- a/src/widget/form/genericchatform.cpp +++ b/src/widget/form/genericchatform.cpp @@ -11,8 +11,10 @@ #include "src/conferencelist.h" #include "src/core/core.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/model/conference.h" #include "src/model/friend.h" +#include "src/model/group.h" #include "src/persistence/settings.h" #include "src/persistence/smileypack.h" #include "src/widget/chatformheader.h" @@ -91,6 +93,13 @@ QString GenericChatForm::resolveToxPk(const ToxPk& pk) } } + for (Group* it : groupList.getAllGroups()) { + QString res = it->resolveToxPk(pk); + if (!res.isEmpty()) { + return res; + } + } + return pk.toString(); } @@ -120,7 +129,8 @@ GenericChatForm::GenericChatForm(const Core& core_, const Chat* chat, IChatLog& IMessageDispatcher& messageDispatcher_, DocumentCache& documentCache, SmileyPack& smileyPack_, Settings& settings_, Style& style_, IMessageBoxManager& messageBoxManager, FriendList& friendList_, - ConferenceList& conferenceList_, QWidget* parent_) + ConferenceList& conferenceList_, GroupList& groupList_, + QWidget* parent_) : QWidget(parent_, Qt::Window) , core{core_} , audioInputFlag(false) @@ -132,6 +142,7 @@ GenericChatForm::GenericChatForm(const Core& core_, const Chat* chat, IChatLog& , style{style_} , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} { curRow = 0; headWidget = new ChatFormHeader(settings, style); diff --git a/src/widget/form/genericchatform.h b/src/widget/form/genericchatform.h index 8fab8ab48b..3ea85246be 100644 --- a/src/widget/form/genericchatform.h +++ b/src/widget/form/genericchatform.h @@ -44,6 +44,8 @@ class Settings; class Style; class IMessageBoxManager; class FriendList; +class ConferenceList; +class GroupList; namespace Ui { class MainWindow; @@ -63,7 +65,7 @@ class GenericChatForm : public QWidget IMessageDispatcher& messageDispatcher_, DocumentCache& documentCache, SmileyPack& smileyPack, Settings& settings, Style& style, IMessageBoxManager& messageBoxmanager, FriendList& friendList, - ConferenceList& conferenceList, QWidget* parent_ = nullptr); + ConferenceList& conferenceList, GroupList& groupList, QWidget* parent_ = nullptr); ~GenericChatForm() override; void setName(const QString& newName); @@ -159,4 +161,5 @@ protected slots: Style& style; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; }; diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp new file mode 100644 index 0000000000..e90da56e7f --- /dev/null +++ b/src/widget/form/groupform.cpp @@ -0,0 +1,561 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupform.h" + +#include "src/chatlog/chatwidget.h" +#include "src/core/core.h" +#include "src/friendlist.h" +#include "src/model/friend.h" +#include "src/model/group.h" +#include "src/persistence/settings.h" +#include "src/widget/chatformheader.h" +#include "src/widget/flowlayout.h" +#include "src/widget/form/chatform.h" +#include "src/widget/style.h" +#include "src/widget/tool/chattextedit.h" +#include "src/widget/tool/croppinglabel.h" +#include "src/widget/translator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const auto LABEL_PEER_TYPE_OUR = QVariant(QStringLiteral("our")); +const auto LABEL_PEER_TYPE_MUTED = QVariant(QStringLiteral("muted")); +const auto PEER_LABEL_STYLE_SHEET_PATH = QStringLiteral("chatArea/chatHead.qss"); + +/** + * @brief Edit name for correct representation if it is needed + * @param name Editing string + * @return Source name if it does not contain any newline character, otherwise it chops characters + * starting with first newline character and appends "..." + */ +QString editName(const QString& name) +{ + const int pos = name.indexOf(QRegularExpression(QStringLiteral("[\n\r]"))); + if (pos == -1) { + return name; + } + + QString result = name; + const int len = result.length(); + result.chop(len - pos); + result.append(QStringLiteral("…")); // \u2026 Unicode symbol, not just three separate dots + return result; +} +} // namespace + +GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, + IMessageDispatcher& messageDispatcher_, Settings& settings_, + DocumentCache& documentCache_, SmileyPack& smileyPack_, Style& style_, + IMessageBoxManager& messageBoxManager, FriendList& friendList_, + ConferenceList& conferenceList_, GroupList& groupList_) + : GenericChatForm(core_, chatGroup, chatLog_, messageDispatcher_, documentCache_, + smileyPack_, settings_, style_, messageBoxManager, friendList_, + conferenceList_, groupList_) + , core{core_} + , group(chatGroup) + , settings(settings_) + , style{style_} + , friendList{friendList_} +{ + nusersLabel = new QLabel(); + + fileButton->setEnabled(false); + fileButton->setProperty("state", ""); + headWidget->setMode(ChatFormHeader::Mode::None); + headWidget->setNameEditable(false); + setName(group->getName()); + + nusersLabel->setFont(Style::getFont(Style::Font::Medium)); + nusersLabel->setObjectName("statusLabel"); + + const QSize& size = headWidget->getAvatarSize(); + headWidget->setAvatar(Style::scaleSvgImage(":/img/group_dark.svg", size.width(), size.height())); + + msgEdit->setObjectName("conference"); + + namesListLayout = new FlowLayout(0, 5, 0); + headWidget->addWidget(nusersLabel); + headWidget->addLayout(namesListLayout); + + topicLabel = new CroppingLabel(this); + topicLabel->setObjectName("topicLabel"); + topicLabel->setContextMenuPolicy(Qt::CustomContextMenu); + headWidget->addWidget(topicLabel); + updateTopicLabel(); + + headWidget->addStretch(); + + nusersLabel->setMinimumHeight(12); + + connect(headWidget, &ChatFormHeader::nameChanged, chatGroup, &Group::setName); + connect(group, &Group::titleChanged, this, &GroupForm::onTitleChanged); + connect(group, &Group::topicChanged, this, &GroupForm::onTopicChanged); + connect(group, &Group::userJoined, this, &GroupForm::onUserJoined); + connect(group, &Group::userLeft, this, &GroupForm::onUserLeft); + connect(group, &Group::peerNameChanged, this, &GroupForm::onPeerNameChanged); + connect(group, &Group::numPeersChanged, this, &GroupForm::updateUserCount); + connect(group, &Group::peerRolesChanged, this, &GroupForm::updateUserNames); + connect(topicLabel, &CroppingLabel::customContextMenuRequested, this, + &GroupForm::onTopicContextMenuRequested); + connect(topicLabel, &CroppingLabel::clicked, this, &GroupForm::editTopic); + settings.connectTo_blockListChanged(this, [this](const QStringList&) { updateUserNames(); }); + + if (settings.getShowConferenceJoinLeaveMessages()) { + addSystemInfoMessage(QDateTime::currentDateTime(), SystemMessageType::selfJoinedConference, {}); + } + + updateUserNames(); + retranslateUi(); + setAcceptDrops(true); + Translator::registerHandler([this] { retranslateUi(); }, this); +} + +GroupForm::~GroupForm() +{ + if (settings.getShowConferenceJoinLeaveMessages()) { + addSystemInfoMessage(QDateTime::currentDateTime(), SystemMessageType::selfLeftConference, {}); + } + Translator::unregister(this); +} + +QString GroupForm::roleIcon(GroupRole role) +{ + switch (role) { + case GroupRole::Founder: + return QStringLiteral(" "); + case GroupRole::Moderator: + return QStringLiteral(" "); + case GroupRole::Observer: + return QStringLiteral(" "); + default: + return {}; + } +} + +void GroupForm::onTitleChanged(const QString& author, const QString& title) +{ + if (author.isEmpty()) { + return; + } + + const QDateTime curTime = QDateTime::currentDateTime(); + addSystemInfoMessage(curTime, SystemMessageType::titleChanged, {author, title}); +} + +void GroupForm::onTopicChanged(const QString& author, const QString& topic) +{ + updateTopicLabel(); + + if (author.isEmpty()) { + return; + } + + const QDateTime curTime = QDateTime::currentDateTime(); + addSystemInfoMessage(curTime, SystemMessageType::titleChanged, {author, topic}); +} + +void GroupForm::updateTopicLabel() +{ + const QString topic = group->getTopic(); + const bool empty = topic.isEmpty(); + topicLabel->setText(empty ? tr("No topic") : topic); + topicLabel->setProperty("empty", empty); + Style::repolish(topicLabel); +} + +void GroupForm::onScreenshotClicked() +{ + // Unsupported +} + +void GroupForm::onAttachClicked() +{ + // Unsupported +} + +/** + * @brief Updates user names' labels at the top of the group + */ +void GroupForm::updateUserNames() +{ + QLayoutItem* child; + while ((child = namesListLayout->takeAt(0)) != nullptr) { + child->widget()->hide(); + delete child->widget(); + delete child; + } + + peerLabels.clear(); + const auto peers = group->getPeerList(); + + // no need to do anything without any peers + if (peers.isEmpty()) { + return; + } + + /* we store the peer labels by their ToxPk, but the namelist layout + * needs it in alphabetical order, so we first create and store the labels + * and then sort them by their text and add them to the layout in that order */ + const auto selfPk = core.getSelfPublicKey(); + for (const auto& peerPk : peers.keys()) { + const QString peerName = peers.value(peerPk); + const QString editedName = editName(peerName); + const QString icon = roleIcon(group->getPeerRole(peerPk)); + auto* const label = new QLabel(icon + editedName.toHtmlEscaped() + QLatin1String(", ")); + label->setProperty("peerSortName", editedName.toLower()); + if (icon.isEmpty()) { + label->setTextFormat(Qt::PlainText); + } else { + label->setTextFormat(Qt::RichText); + } + if (editedName != peerName) { + label->setToolTip(peerName + " (" + peerPk.toString() + ")"); + } else if (peerName != peerPk.toString()) { + label->setToolTip(peerPk.toString()); + } // else their name is just their Pk, no tooltip needed + label->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(label, &QLabel::customContextMenuRequested, this, + &GroupForm::onLabelContextMenuRequested); + + if (peerPk == selfPk) { + label->setProperty("peerType", LABEL_PEER_TYPE_OUR); + } else if (settings.getBlockList().contains(peerPk.toString())) { + label->setProperty("peerType", LABEL_PEER_TYPE_MUTED); + } + + label->setStyleSheet(style.getStylesheet(PEER_LABEL_STYLE_SHEET_PATH, settings)); + peerLabels.insert(peerPk, label); + } + + // add the labels in alphabetical order into the layout + auto nickLabelList = peerLabels.values(); + + std::sort(nickLabelList.begin(), nickLabelList.end(), [](const QLabel* a, const QLabel* b) { + return a->property("peerSortName").toString() < b->property("peerSortName").toString(); + }); + + // remove comma from last sorted label + QLabel* const lastLabel = nickLabelList.last(); + QString labelText = lastLabel->text(); + labelText.chop(2); + lastLabel->setText(labelText); + for (QLabel* l : nickLabelList) { + namesListLayout->addWidget(l); + } +} + +void GroupForm::onUserJoined(const ToxPk& user, const QString& name) +{ + std::ignore = user; + if (settings.getShowConferenceJoinLeaveMessages()) { + addSystemInfoMessage(QDateTime::currentDateTime(), SystemMessageType::userJoinedConference, + {name}); + } + updateUserNames(); +} + +void GroupForm::onUserLeft(const ToxPk& user, const QString& name) +{ + std::ignore = user; + if (settings.getShowConferenceJoinLeaveMessages()) { + addSystemInfoMessage(QDateTime::currentDateTime(), SystemMessageType::userLeftConference, + {name}); + } + updateUserNames(); +} + +void GroupForm::onPeerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName) +{ + std::ignore = peer; + addSystemInfoMessage(QDateTime::currentDateTime(), SystemMessageType::peerNameChanged, + {oldName, newName}); + updateUserNames(); +} + +void GroupForm::dragEnterEvent(QDragEnterEvent* ev) +{ + if (!ev->mimeData()->hasFormat("toxPk")) { + return; + } + const ToxPk toxPk{ev->mimeData()->data("toxPk")}; + Friend* frnd = friendList.findFriend(toxPk); + if (frnd != nullptr) + ev->acceptProposedAction(); +} + +void GroupForm::dropEvent(QDropEvent* ev) +{ + if (!ev->mimeData()->hasFormat("toxPk")) { + return; + } + const ToxPk toxPk{ev->mimeData()->data("toxPk")}; + Friend* frnd = friendList.findFriend(toxPk); + if (frnd == nullptr) + return; + + const uint32_t friendId = frnd->getId(); + const uint32_t groupNumber = group->getId(); + if (Status::isOnline(frnd->getStatus())) { + core.groupInviteFriend(friendId, groupNumber); + } +} + +void GroupForm::keyPressEvent(QKeyEvent* ev) +{ + if (msgEdit->hasFocus()) + return; +} + +void GroupForm::keyReleaseEvent(QKeyEvent* ev) +{ + if (msgEdit->hasFocus()) + return; +} + +/** + * @brief Updates users' count label text + */ +void GroupForm::updateUserCount(int numPeers) +{ + nusersLabel->setText(tr("%n user(s) in chat", "Number of users in chat", numPeers)); +} + +void GroupForm::retranslateUi() +{ + updateUserCount(group->getPeersCount()); +} + +void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) +{ + auto* label = static_cast(QObject::sender()); + + if (label == nullptr) { + return; + } + + const QPoint pos = label->mapToGlobal(localPos); + const QString muteString = tr("mute"); + const QString unmuteString = tr("unmute"); + QStringList blockList = settings.getBlockList(); + auto* const contextMenu = new QMenu(this); + const ToxPk selfPk = core.getSelfPublicKey(); + ToxPk peerPk; + + // delete menu after it stops being used + connect(contextMenu, &QMenu::aboutToHide, contextMenu, &QObject::deleteLater); + + peerPk = peerLabels.key(label); + if (peerPk.isEmpty() || peerPk == selfPk) { + return; + } + + const bool isPeerBlocked = blockList.contains(peerPk.toString()); + QString menuTitle = label->text(); + if (menuTitle.endsWith(QLatin1String(", "))) { + menuTitle.chop(2); + } + QAction* menuTitleAction = contextMenu->addAction(menuTitle); + menuTitleAction->setEnabled(false); // make sure the title is not clickable + contextMenu->addSeparator(); + + const QAction* toggleMuteAction; + if (isPeerBlocked) { + toggleMuteAction = contextMenu->addAction(unmuteString); + } else { + toggleMuteAction = contextMenu->addAction(muteString); + } + contextMenu->setStyleSheet(style.getStylesheet(PEER_LABEL_STYLE_SHEET_PATH, settings)); + + auto* copyIdAction = contextMenu->addAction(tr("copy peer ID")); + + const GroupRole selfRole = group->getPeerRole(selfPk); + const GroupRole peerRole = group->getPeerRole(peerPk); + QAction* promoteAction = nullptr; + QAction* demoteAction = nullptr; + QAction* kickAction = nullptr; + if (selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator) { + if (peerRole == GroupRole::User || peerRole == GroupRole::Observer) { + promoteAction = contextMenu->addAction(tr("promote to moderator")); + } + if (peerRole == GroupRole::Moderator) { + demoteAction = contextMenu->addAction(tr("demote to user")); + } + kickAction = contextMenu->addAction(tr("kick from group")); + } + contextMenu->addSeparator(); + + const QAction* selectedItem = contextMenu->exec(pos); + if (selectedItem == toggleMuteAction) { + if (isPeerBlocked) { + const int index = blockList.indexOf(peerPk.toString()); + if (index != -1) { + blockList.removeAt(index); + } + } else { + blockList << peerPk.toString(); + } + + settings.setBlockList(blockList); + } else if (selectedItem == copyIdAction) { + auto* clipboard = QApplication::clipboard(); + clipboard->setText(peerPk.toString(), QClipboard::Clipboard); + if (clipboard->supportsSelection()) { + clipboard->setText(peerPk.toString(), QClipboard::Selection); + } + } else if (selectedItem == promoteAction) { + group->setPeerRole(peerPk, GroupRole::Moderator); + } else if (selectedItem == demoteAction) { + group->setPeerRole(peerPk, GroupRole::User); + } else if (selectedItem == kickAction) { + group->kickPeer(peerPk); + } +} + +void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) +{ + const QPoint pos = topicLabel->mapToGlobal(localPos); + auto* const contextMenu = new QMenu(this); + contextMenu->setStyleSheet(style.getStylesheet("chatArea/chatHead.qss", settings)); + + auto* copyTopicAction = contextMenu->addAction(tr("Copy topic")); + auto* copyIdAction = contextMenu->addAction(tr("Copy group ID")); + const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); + QAction* setTopicAction = nullptr; + if (selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator) { + setTopicAction = contextMenu->addAction(tr("Set topic...")); + } + + QAction* setPasswordAction = nullptr; + QAction* clearPasswordAction = nullptr; + QAction* setPeerLimitAction = nullptr; + QAction* setTopicLockAction = nullptr; + QMenu* voiceStateMenu = nullptr; + QMenu* privacyStateMenu = nullptr; + QAction* voiceAllAction = nullptr; + QAction* voiceModeratorAction = nullptr; + QAction* voiceFounderAction = nullptr; + QAction* privacyPublicAction = nullptr; + QAction* privacyPrivateAction = nullptr; + if (selfRole == GroupRole::Founder) { + contextMenu->addSeparator(); + if (group->isPasswordSet()) { + clearPasswordAction = contextMenu->addAction(tr("Remove group password")); + } else { + setPasswordAction = contextMenu->addAction(tr("Set group password...")); + } + setPeerLimitAction = contextMenu->addAction(tr("Set peer limit...")); + setTopicLockAction = contextMenu->addAction(tr("Lock topic")); + setTopicLockAction->setCheckable(true); + setTopicLockAction->setChecked(group->getTopicLock() == GroupTopicLock::Enabled); + + voiceStateMenu = contextMenu->addMenu(tr("Who can speak")); + const GroupVoiceState voiceState = group->getVoiceState(); + voiceAllAction = voiceStateMenu->addAction(tr("Everyone")); + voiceAllAction->setCheckable(true); + voiceAllAction->setChecked(voiceState == GroupVoiceState::All); + voiceModeratorAction = voiceStateMenu->addAction(tr("Moderators and Founder")); + voiceModeratorAction->setCheckable(true); + voiceModeratorAction->setChecked(voiceState == GroupVoiceState::Moderator); + voiceFounderAction = voiceStateMenu->addAction(tr("Only Founder")); + voiceFounderAction->setCheckable(true); + voiceFounderAction->setChecked(voiceState == GroupVoiceState::Founder); + + privacyStateMenu = contextMenu->addMenu(tr("Group visibility")); + const GroupPrivacyState privacyState = group->getPrivacyState(); + privacyPublicAction = privacyStateMenu->addAction(tr("Public (join by link)")); + privacyPublicAction->setCheckable(true); + privacyPublicAction->setChecked(privacyState == GroupPrivacyState::Public); + privacyPrivateAction = privacyStateMenu->addAction(tr("Private (invite only)")); + privacyPrivateAction->setCheckable(true); + privacyPrivateAction->setChecked(privacyState == GroupPrivacyState::Private); + } + + const QAction* selectedItem = contextMenu->exec(pos); + if (selectedItem == copyTopicAction) { + auto* clipboard = QApplication::clipboard(); + clipboard->setText(group->getTopic(), QClipboard::Clipboard); + if (clipboard->supportsSelection()) { + clipboard->setText(group->getTopic(), QClipboard::Selection); + } + } else if (selectedItem == copyIdAction) { + auto* clipboard = QApplication::clipboard(); + clipboard->setText(group->getPersistentId().toString(), QClipboard::Clipboard); + if (clipboard->supportsSelection()) { + clipboard->setText(group->getPersistentId().toString(), QClipboard::Selection); + } + } else if (selectedItem == setTopicAction) { + editTopic(); + } else if (selectedItem == setPasswordAction) { + setPassword(); + } else if (selectedItem == clearPasswordAction) { + clearPassword(); + } else if (selectedItem == setPeerLimitAction) { + setPeerLimit(); + } else if (selectedItem == setTopicLockAction) { + group->setGroupTopicLock(setTopicLockAction->isChecked() ? GroupTopicLock::Enabled + : GroupTopicLock::Disabled); + } else if (selectedItem == voiceAllAction) { + group->setGroupVoiceState(GroupVoiceState::All); + } else if (selectedItem == voiceModeratorAction) { + group->setGroupVoiceState(GroupVoiceState::Moderator); + } else if (selectedItem == voiceFounderAction) { + group->setGroupVoiceState(GroupVoiceState::Founder); + } else if (selectedItem == privacyPublicAction) { + group->setGroupPrivacyState(GroupPrivacyState::Public); + } else if (selectedItem == privacyPrivateAction) { + group->setGroupPrivacyState(GroupPrivacyState::Private); + } +} + +void GroupForm::setPassword() +{ + bool ok = false; + const QString password = QInputDialog::getText(this, tr("Set group password"), + tr("Password:"), QLineEdit::Password, QString(), + &ok); + if (ok) { + group->setGroupPassword(password.toUtf8()); + } +} + +void GroupForm::clearPassword() +{ + group->setGroupPassword({}); +} + +void GroupForm::setPeerLimit() +{ + bool ok = false; + const int peerLimit = QInputDialog::getInt(this, tr("Set peer limit"), tr("Peer limit:"), + group->getPeerLimit(), 0, 65535, 1, &ok); + if (ok) { + group->setGroupPeerLimit(static_cast(peerLimit)); + } +} + +void GroupForm::editTopic() +{ + const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); + if (selfRole != GroupRole::Founder && selfRole != GroupRole::Moderator) { + return; + } + + bool ok = false; + const QString topic = QInputDialog::getMultiLineText( + this, tr("Set group topic"), tr("Topic:"), group->getTopic(), &ok); + if (ok) { + core.changeGroupTopic(group->getId(), topic); + } +} diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h new file mode 100644 index 0000000000..2a8a3d8940 --- /dev/null +++ b/src/widget/form/groupform.h @@ -0,0 +1,84 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "genericchatform.h" + +#include "src/core/icoregroupquery.h" +#include "src/core/toxpk.h" + +#include + +namespace Ui { +class MainWindow; +} +class Group; +class FlowLayout; +class QTimer; +class IMessageDispatcher; +struct Message; +class Settings; +class DocumentCache; +class SmileyPack; +class Style; +class IMessageBoxManager; +class FriendList; +class ConferenceList; +class GroupList; +class CroppingLabel; + +class GroupForm : public GenericChatForm +{ + Q_OBJECT +public: + GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, + IMessageDispatcher& messageDispatcher_, Settings& settings_, + DocumentCache& documentCache, SmileyPack& smileyPack, Style& style, + IMessageBoxManager& messageBoxManager, FriendList& friendList, + ConferenceList& conferenceList, GroupList& groupList); + ~GroupForm() override; + +signals: + +private slots: + void onScreenshotClicked() override; + void onAttachClicked() override; + void onUserJoined(const ToxPk& user, const QString& name); + void onUserLeft(const ToxPk& user, const QString& name); + void onPeerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName); + void onTitleChanged(const QString& author, const QString& title); + void onTopicChanged(const QString& author, const QString& topic); + void onLabelContextMenuRequested(const QPoint& localPos); + void onTopicContextMenuRequested(const QPoint& localPos); + void editTopic(); + void setPassword(); + void clearPassword(); + void setPeerLimit(); + +protected: + void keyPressEvent(QKeyEvent* ev) final; + void keyReleaseEvent(QKeyEvent* ev) final; + // drag & drop + void dragEnterEvent(QDragEnterEvent* ev) final; + void dropEvent(QDropEvent* ev) final; + +private: + void retranslateUi(); + void updateUserCount(int numPeers); + void updateUserNames(); + void updateTopicLabel(); + static QString roleIcon(GroupRole role); + +private: + Core& core; + Group* group; + QMap peerLabels; + FlowLayout* namesListLayout; + QLabel* nusersLabel; + CroppingLabel* topicLabel; + Settings& settings; + Style& style; + FriendList& friendList; +}; diff --git a/src/widget/form/groupinviteform.cpp b/src/widget/form/groupinviteform.cpp new file mode 100644 index 0000000000..8df6701f4e --- /dev/null +++ b/src/widget/form/groupinviteform.cpp @@ -0,0 +1,191 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupinviteform.h" + +#include "src/core/core.h" +#include "src/core/groupid.h" +#include "src/model/groupinvite.h" +#include "src/persistence/settings.h" +#include "src/widget/contentlayout.h" +#include "src/widget/form/groupinvitewidget.h" +#include "src/widget/translator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + * @class GroupInviteForm + * + * @brief This form contains all group invites you received + */ + +GroupInviteForm::GroupInviteForm(Settings& settings_, Core& core_) + : headWidget(new QWidget(this)) + , headLabel(new QLabel(this)) + , createButton(new QPushButton(this)) + , joinButton(new QPushButton(this)) + , inviteBox(new QGroupBox(this)) + , scroll(new QScrollArea(this)) + , settings{settings_} + , core{core_} +{ + auto* layout = new QVBoxLayout(this); + connect(createButton, &QPushButton::clicked, this, [this]() { + bool ok = false; + const QString groupName = QInputDialog::getText( + this, tr("Create group"), tr("Enter a name for the group"), QLineEdit::Normal, + QString(), &ok); + if (ok && !groupName.isEmpty()) { + emit groupCreate(groupName); + } + }); + connect(joinButton, &QPushButton::clicked, this, [this]() { + bool ok = false; + const QString chatIdHex = QInputDialog::getText( + this, tr("Join group by ID"), tr("Enter the group Chat ID (64 hex characters):"), + QLineEdit::Normal, QString(), &ok); + if (!ok || chatIdHex.isEmpty()) { + return; + } + const QString clean = chatIdHex.trimmed(); + const QByteArray rawId = QByteArray::fromHex(clean.toLatin1()); + if (rawId.size() != TOX_GROUP_CHAT_ID_SIZE) { + QMessageBox::warning(this, tr("Join group by ID"), + tr("Invalid group ID. Expected 64 hex characters.")); + return; + } + core.joinGroup(GroupId(rawId)); + }); + + auto* innerWidget = new QWidget(scroll); + innerWidget->setLayout(new QVBoxLayout()); + innerWidget->layout()->setAlignment(Qt::AlignTop); + scroll->setWidget(innerWidget); + scroll->setWidgetResizable(true); + + auto* inviteLayout = new QVBoxLayout(inviteBox); + inviteLayout->addWidget(scroll); + + layout->addWidget(createButton); + layout->addWidget(joinButton); + layout->addWidget(inviteBox); + + QFont bold; + bold.setBold(true); + + headLabel->setFont(bold); + auto* headLayout = new QHBoxLayout(headWidget); + headLayout->addWidget(headLabel); + + retranslateUi(); + Translator::registerHandler([this] { retranslateUi(); }, this); +} + +GroupInviteForm::~GroupInviteForm() +{ + Translator::unregister(this); +} + +/** + * @brief Detects that form is shown + * @return True if form is visible + */ +bool GroupInviteForm::isShown() const +{ + const bool result = isVisible(); + if (result) { + headWidget->window()->windowHandle()->alert(0); + } + return result; +} + +/** + * @brief Shows the form + * @param contentLayout Main layout that contains all components of the form + */ +void GroupInviteForm::show(ContentLayout* contentLayout) +{ + contentLayout->mainContent->layout()->addWidget(this); + contentLayout->mainHead->layout()->addWidget(headWidget); + QWidget::show(); + headWidget->show(); +} + +/** + * @brief Adds group invite + * @param inviteInfo Object which contains info about group invitation + * @return true if notification is needed, false otherwise + */ +bool GroupInviteForm::addGroupInvite(const GroupInvite& inviteInfo) +{ + // supress duplicate invite messages + for (GroupInviteWidget* existing : invites) { + if (existing->getInviteInfo() == inviteInfo) { + return false; + } + } + + auto* widget = new GroupInviteWidget(this, inviteInfo, settings, core); + scroll->widget()->layout()->addWidget(widget); + invites.append(widget); + connect(widget, &GroupInviteWidget::accepted, this, + [this](const GroupInvite& inviteInfo_) { + deleteInviteWidget(inviteInfo_); + emit groupInviteAccepted(inviteInfo_); + }); + + connect(widget, &GroupInviteWidget::rejected, this, + [this](const GroupInvite& inviteInfo_) { deleteInviteWidget(inviteInfo_); }); + if (isVisible()) { + emit groupInvitesSeen(); + return false; + } + return true; +} + +void GroupInviteForm::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + emit groupInvitesSeen(); +} + +/** + * @brief Deletes accepted/declined group invite widget + * @param inviteInfo Invite information of accepted/declined widget + */ +void GroupInviteForm::deleteInviteWidget(const GroupInvite& inviteInfo) +{ + auto deletingWidget = + std::find_if(invites.begin(), invites.end(), [=](const GroupInviteWidget* widget) { + return inviteInfo == widget->getInviteInfo(); + }); + (*deletingWidget)->deleteLater(); + scroll->widget()->layout()->removeWidget(*deletingWidget); + invites.erase(deletingWidget); +} + +void GroupInviteForm::retranslateUi() +{ + headLabel->setText(tr("Groups")); + if (createButton != nullptr) { + createButton->setText(tr("Create new group")); + } + if (joinButton != nullptr) { + joinButton->setText(tr("Join group by ID")); + } + inviteBox->setTitle(tr("Group invites")); + for (GroupInviteWidget* invite : invites) { + invite->retranslateUi(); + } +} diff --git a/src/widget/form/groupinviteform.h b/src/widget/form/groupinviteform.h new file mode 100644 index 0000000000..bd5aed5a69 --- /dev/null +++ b/src/widget/form/groupinviteform.h @@ -0,0 +1,57 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include + +class ContentLayout; +class GroupInvite; +class GroupInviteWidget; + +class QGroupBox; +class QLabel; +class QPushButton; +class QScrollArea; +class Settings; +class Core; + +namespace Ui { +class MainWindow; +} + +class GroupInviteForm : public QWidget +{ + Q_OBJECT +public: + GroupInviteForm(Settings& settings, Core& core); + ~GroupInviteForm() override; + + void show(ContentLayout* contentLayout); + bool addGroupInvite(const GroupInvite& inviteInfo); + bool isShown() const; + +signals: + void groupCreate(const QString& groupName); + void groupInviteAccepted(const GroupInvite& inviteInfo); + void groupInvitesSeen(); + +protected: + void showEvent(QShowEvent* event) final; + +private: + void retranslateUi(); + void deleteInviteWidget(const GroupInvite& inviteInfo); + +private: + QWidget* headWidget; + QLabel* headLabel; + QPushButton* createButton; + QPushButton* joinButton; + QGroupBox* inviteBox; + QList invites; + QScrollArea* scroll; + Settings& settings; + Core& core; +}; diff --git a/src/widget/form/groupinvitewidget.cpp b/src/widget/form/groupinvitewidget.cpp new file mode 100644 index 0000000000..bd18085a1b --- /dev/null +++ b/src/widget/form/groupinvitewidget.cpp @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupinvitewidget.h" + +#include "src/core/core.h" +#include "src/persistence/settings.h" +#include "src/widget/tool/croppinglabel.h" + +#include +#include + +#include + +/** + * @class GroupInviteWidget + * + * @brief This class shows information about single group invite + * and provides buttons to accept/reject it + */ + +GroupInviteWidget::GroupInviteWidget(QWidget* parent, GroupInvite invite, Settings& settings_, + Core& core_) + : QWidget(parent) + , acceptButton(new QPushButton(this)) + , rejectButton(new QPushButton(this)) + , inviteMessageLabel(new CroppingLabel(this)) + , widgetLayout(new QHBoxLayout(this)) + , inviteInfo(std::move(invite)) + , settings{settings_} + , core{core_} +{ + connect(acceptButton, &QPushButton::clicked, this, [this] { emit accepted(inviteInfo); }); + connect(rejectButton, &QPushButton::clicked, this, [this] { emit rejected(inviteInfo); }); + widgetLayout->addWidget(inviteMessageLabel); + widgetLayout->addWidget(acceptButton); + widgetLayout->addWidget(rejectButton); + setLayout(widgetLayout); + retranslateUi(); +} + +/** + * @brief Retranslate all elements in the form. + */ +void GroupInviteWidget::retranslateUi() +{ + const QString name = core.getFriendUsername(inviteInfo.getFriendId()); + const QDateTime inviteDate = inviteInfo.getInviteDate(); + const QString date = inviteDate.toString(settings.getDateFormat()); + const QString time = inviteDate.toString(settings.getTimestampFormat()); + + inviteMessageLabel->setText( + tr("Invited by %1 to %2 on %3 at %4.") + .arg(QStringLiteral("%1").arg(name.toHtmlEscaped()), + inviteInfo.getGroupName().toHtmlEscaped(), date, time)); + acceptButton->setText(tr("Join")); + rejectButton->setText(tr("Decline")); +} + +/** + * @brief Returns infomation about invitation - e.g., who and when sent + * @return Invite information object + */ +GroupInvite GroupInviteWidget::getInviteInfo() const +{ + return inviteInfo; +} diff --git a/src/widget/form/groupinvitewidget.h b/src/widget/form/groupinvitewidget.h new file mode 100644 index 0000000000..32816f92fc --- /dev/null +++ b/src/widget/form/groupinvitewidget.h @@ -0,0 +1,38 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/model/groupinvite.h" + +#include + +class CroppingLabel; + +class QHBoxLayout; +class QPushButton; +class Settings; +class Core; + +class GroupInviteWidget : public QWidget +{ + Q_OBJECT +public: + GroupInviteWidget(QWidget* parent, GroupInvite invite, Settings& settings, Core& core); + void retranslateUi(); + GroupInvite getInviteInfo() const; + +signals: + void accepted(const GroupInvite& invite); + void rejected(const GroupInvite& invite); + +private: + QPushButton* acceptButton; + QPushButton* rejectButton; + CroppingLabel* inviteMessageLabel; + QHBoxLayout* widgetLayout; + GroupInvite inviteInfo; + Settings& settings; + Core& core; +}; diff --git a/src/widget/friendlistwidget.cpp b/src/widget/friendlistwidget.cpp index 58c7e02eae..8c673d2859 100644 --- a/src/widget/friendlistwidget.cpp +++ b/src/widget/friendlistwidget.cpp @@ -8,6 +8,7 @@ #include "circlewidget.h" #include "conferencewidget.h" #include "friendwidget.h" +#include "groupwidget.h" #include "widget.h" #include "src/core/core.h" @@ -92,7 +93,7 @@ qint64 timeUntilTomorrow() FriendListWidget::FriendListWidget(const Core& core_, Widget* parent, Settings& settings_, Style& style_, IMessageBoxManager& messageBoxManager_, FriendList& friendList_, ConferenceList& conferenceList_, - Profile& profile_, bool conferencesOnTop) + GroupList& groupList_, Profile& profile_, bool conferencesOnTop) : QWidget(parent) , core{core_} , settings{settings_} @@ -100,6 +101,7 @@ FriendListWidget::FriendListWidget(const Core& core_, Widget* parent, Settings& , messageBoxManager{messageBoxManager_} , friendList{friendList_} , conferenceList{conferenceList_} + , groupList{groupList_} , profile{profile_} { const int countContacts = core.getFriendList().size(); @@ -362,11 +364,29 @@ void FriendListWidget::addFriendWidget(FriendWidget* w) manager->addFriendListItem(w); } +void FriendListWidget::addGroupWidget(GroupWidget* widget) +{ + Group* g = widget->getGroup(); + connect(g, &Group::titleChanged, this, + [this, widget](const QString& author, const QString& name) { + std::ignore = author; + widget->setName(name); + itemsChanged(); + }); + + manager->addFriendListItem(widget); +} + void FriendListWidget::removeConferenceWidget(ConferenceWidget* w) { manager->removeFriendListItem(w); } +void FriendListWidget::removeGroupWidget(GroupWidget* w) +{ + manager->removeFriendListItem(w); +} + void FriendListWidget::removeFriendWidget(FriendWidget* w) { const Friend* contact = w->getFriend(); @@ -614,7 +634,7 @@ CircleWidget* FriendListWidget::createCircleWidget(int id) } auto* circleWidget = new CircleWidget(core, this, id, settings, style, messageBoxManager, - friendList, conferenceList, profile); + friendList, conferenceList, groupList, profile); emit connectCircleWidget(*circleWidget); connect(this, &FriendListWidget::onCompactChanged, circleWidget, &CircleWidget::onCompactChanged); connect(circleWidget, &CircleWidget::renameRequested, this, &FriendListWidget::renameCircleWidget); diff --git a/src/widget/friendlistwidget.h b/src/widget/friendlistwidget.h index c40c101a80..5ce7d1ab33 100644 --- a/src/widget/friendlistwidget.h +++ b/src/widget/friendlistwidget.h @@ -22,6 +22,8 @@ class FriendWidget; class GenericChatroomWidget; class ConferenceList; class ConferenceWidget; +class GroupList; +class GroupWidget; class IFriendListItem; class IMessageBoxManager; class Profile; @@ -39,15 +41,18 @@ class FriendListWidget : public QWidget using SortingMode = Settings::FriendListSortingMode; FriendListWidget(const Core& core, Widget* parent, Settings& settings, Style& style, IMessageBoxManager& messageBoxManager, FriendList& friendList, - ConferenceList& conferenceList, Profile& profile, bool conferencesOnTop = true); + ConferenceList& conferenceList, GroupList& groupList, Profile& profile, + bool conferencesOnTop = true); ~FriendListWidget() override; void setMode(SortingMode mode); [[nodiscard]] SortingMode getMode() const; void addConferenceWidget(ConferenceWidget* widget); void addFriendWidget(FriendWidget* w); + void addGroupWidget(GroupWidget* widget); void removeConferenceWidget(ConferenceWidget* w); void removeFriendWidget(FriendWidget* w); + void removeGroupWidget(GroupWidget* w); void addCircleWidget(int id); void addCircleWidget(FriendWidget* widget = nullptr); static void removeCircleWidget(CircleWidget* widget); @@ -97,5 +102,6 @@ private slots: IMessageBoxManager& messageBoxManager; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; Profile& profile; }; diff --git a/src/widget/genericchatroomwidget.h b/src/widget/genericchatroomwidget.h index e4020c6d48..173663b116 100644 --- a/src/widget/genericchatroomwidget.h +++ b/src/widget/genericchatroomwidget.h @@ -14,6 +14,7 @@ class QHBoxLayout; class ContentLayout; class Friend; class Conference; +class Group; class Settings; class Chat; class Style; @@ -39,6 +40,10 @@ public slots: { return nullptr; } + virtual Group* getGroup() const + { + return nullptr; + } bool eventFilter(QObject* object, QEvent* event) final; diff --git a/src/widget/groupwidget.cpp b/src/widget/groupwidget.cpp new file mode 100644 index 0000000000..de67fa738d --- /dev/null +++ b/src/widget/groupwidget.cpp @@ -0,0 +1,295 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#include "groupwidget.h" + +#include "maskablepixmapwidget.h" + +#include "src/model/group.h" +#include "src/model/status.h" +#include "src/widget/friendwidget.h" +#include "src/widget/style.h" +#include "src/widget/translator.h" +#include "src/widget/widget.h" +#include "tool/croppinglabel.h" + +#include +#include +#include +#include +#include +#include +#include + +GroupWidget::GroupWidget(std::shared_ptr chatroom_, bool compact_, Settings& settings_, + Style& style_, QWidget* parent) + : GenericChatroomWidget(compact_, settings_, style_, parent) + , chatroom{std::move(chatroom_)} + , groupId{chatroom->getGroup()->getPersistentId()} +{ + avatar->setPixmap(Style::scaleSvgImage(":img/group.svg", avatar->width(), avatar->height())); + statusPic.setPixmap(QPixmap(Status::getIconPath(Status::Status::Online))); + statusPic.setMargin(3); + + Group* g = chatroom->getGroup(); + nameLabel->setText(g->getName()); + + updateUserCount(g->getPeersCount()); + setAcceptDrops(true); + + connect(g, &Group::titleChanged, this, &GroupWidget::updateTitle); + connect(g, &Group::numPeersChanged, this, &GroupWidget::updateUserCount); + connect(nameLabel, &CroppingLabel::editFinished, g, &Group::setName); + Translator::registerHandler([this] { retranslateUi(); }, this); +} + +GroupWidget::~GroupWidget() +{ + Translator::unregister(this); +} + +void GroupWidget::updateTitle(const QString& author, const QString& newName) +{ + std::ignore = author; + nameLabel->setText(newName); +} + +void GroupWidget::contextMenuEvent(QContextMenuEvent* event) +{ + if (!active) { + setBackgroundRole(QPalette::Highlight); + } + + installEventFilter(this); // Disable leave event. + + QMenu menu; + + QAction* openChatWindow = nullptr; + if (chatroom->possibleToOpenInNewWindow()) { + openChatWindow = menu.addAction(tr("Open chat in new window")); + } + + QAction* removeChatWindow = nullptr; + if (chatroom->canBeRemovedFromWindow()) { + removeChatWindow = menu.addAction(tr("Remove chat from this window")); + } + + menu.addSeparator(); + + QAction* setTitle = menu.addAction(tr("Set title...")); + auto* quitGroup = menu.addAction(tr("Quit group", "Menu to quit a group")); + // Deleting the widget from inside the menu handler would destroy the + // stack-allocated QMenu while it is still a child, so defer the removal. + connect(quitGroup, &QAction::triggered, this, [this]() { emit removeGroup(groupId); }, + Qt::QueuedConnection); + + QAction* selectedItem = menu.exec(event->globalPos()); + + removeEventFilter(this); + + if (!active) { + setBackgroundRole(QPalette::Window); + } + + if (selectedItem == nullptr) { + return; + } + + if (selectedItem == openChatWindow) { + emit newWindowOpened(this); + } else if (selectedItem == removeChatWindow) { + chatroom->removeGroupFromDialogs(); + } else if (selectedItem == setTitle) { + editName(); + } +} + +void GroupWidget::mousePressEvent(QMouseEvent* ev) +{ + if (ev->button() == Qt::LeftButton) { + dragStartPos = ev->pos(); + } + + GenericChatroomWidget::mousePressEvent(ev); +} + +void GroupWidget::mouseMoveEvent(QMouseEvent* ev) +{ + if (!(ev->buttons() & Qt::LeftButton)) { + return; + } + + if ((dragStartPos - ev->pos()).manhattanLength() > QApplication::startDragDistance()) { + auto* mdata = new QMimeData; + const Group* group = getGroup(); + mdata->setText(group->getName()); + mdata->setData("groupId", group->getPersistentId().getByteArray()); + + auto* drag = new QDrag(this); + drag->setMimeData(mdata); + drag->setPixmap(avatar->getPixmap()); + drag->exec(Qt::CopyAction | Qt::MoveAction); + } +} + +void GroupWidget::updateUserCount(int numPeers) +{ + statusMessageLabel->setText(tr("%n user(s) in chat", "Number of users in chat", numPeers)); +} + +void GroupWidget::setAsActiveChatroom() +{ + setActive(true); + avatar->setPixmap(Style::scaleSvgImage(":img/group_dark.svg", avatar->width(), avatar->height())); +} + +void GroupWidget::setAsInactiveChatroom() +{ + setActive(false); + avatar->setPixmap(Style::scaleSvgImage(":img/group.svg", avatar->width(), avatar->height())); +} + +/* + * @brief GroupWidget::startCall light up the on call indicator. + */ +void GroupWidget::startCall() +{ + updateStatusLight(); +} + +/* + * @brief GroupWidget::stopCall shut down the on call indicator. + */ +void GroupWidget::stopCall() +{ + updateStatusLight(); +} + +void GroupWidget::updateStatusLight() +{ + Group* g = chatroom->getGroup(); + + const bool event = g->getEventFlag(); + statusPic.setPixmap(QPixmap(Status::getIconPath(Status::Status::Online, event))); + statusPic.setMargin(event ? 1 : 3); +} + +QString GroupWidget::getStatusString() const +{ + if (chatroom->hasNewMessage()) { + return tr("New message"); + } + return tr("Online"); +} + +void GroupWidget::editName() +{ + nameLabel->editBegin(); +} + +bool GroupWidget::isFriend() const +{ + return false; +} + +bool GroupWidget::isConference() const +{ + return false; +} + +QString GroupWidget::getNameItem() const +{ + return nameLabel->fullText(); +} + +bool GroupWidget::isOnline() const +{ + return true; +} + +bool GroupWidget::widgetIsVisible() const +{ + return isVisible(); +} + +QDateTime GroupWidget::getLastActivity() const +{ + return QDateTime::currentDateTime(); +} + +QWidget* GroupWidget::getWidget() +{ + return this; +} + +void GroupWidget::setWidgetVisible(bool visible) +{ + setVisible(visible); +} + +Group* GroupWidget::getGroup() const +{ + return chatroom->getGroup(); +} + +const Chat* GroupWidget::getChat() const +{ + return getGroup(); +} + +void GroupWidget::resetEventFlags() +{ + chatroom->resetEventFlags(); +} + +void GroupWidget::dragEnterEvent(QDragEnterEvent* ev) +{ + if (!ev->mimeData()->hasFormat("toxPk")) { + return; + } + const ToxPk pk{ev->mimeData()->data("toxPk")}; + if (chatroom->friendExists(pk)) { + ev->acceptProposedAction(); + } + + if (!active) { + setBackgroundRole(QPalette::Highlight); + } +} + +void GroupWidget::dragLeaveEvent(QDragLeaveEvent* event) +{ + std::ignore = event; + if (!active) { + setBackgroundRole(QPalette::Window); + } +} + +void GroupWidget::dropEvent(QDropEvent* ev) +{ + if (!ev->mimeData()->hasFormat("toxPk")) { + return; + } + const ToxPk pk{ev->mimeData()->data("toxPk")}; + if (!chatroom->friendExists(pk)) { + return; + } + + chatroom->inviteFriend(pk); + + if (!active) { + setBackgroundRole(QPalette::Window); + } +} + +void GroupWidget::setName(const QString& name) +{ + nameLabel->setText(name); +} + +void GroupWidget::retranslateUi() +{ + const Group* group = chatroom->getGroup(); + updateUserCount(group->getPeersCount()); +} diff --git a/src/widget/groupwidget.h b/src/widget/groupwidget.h new file mode 100644 index 0000000000..95d141fc49 --- /dev/null +++ b/src/widget/groupwidget.h @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "genericchatroomwidget.h" + +#include "src/core/groupid.h" +#include "src/model/chatroom/grouproom.h" +#include "src/model/friendlist/ifriendlistitem.h" + +#include + +class Settings; +class Style; + +class GroupWidget final : public GenericChatroomWidget, public IFriendListItem +{ + Q_OBJECT +public: + GroupWidget(std::shared_ptr chatroom_, bool compact, Settings& settings, Style& style, + QWidget* parent); + ~GroupWidget() override; + void setAsInactiveChatroom() final; + void setAsActiveChatroom() final; + void updateStatusLight() final; + void resetEventFlags() final; + QString getStatusString() const final; + Group* getGroup() const final; + const Chat* getChat() const final; + void setName(const QString& name); + void editName(); + + bool isFriend() const final; + bool isConference() const final; + QString getNameItem() const final; + bool isOnline() const final; + void startCall() final; + void stopCall() final; + bool widgetIsVisible() const final; + QDateTime getLastActivity() const final; + QWidget* getWidget() final; + void setWidgetVisible(bool visible) final; + +signals: + void groupWidgetClicked(GroupWidget* widget); + void removeGroup(const GroupId& groupId); + +protected: + void contextMenuEvent(QContextMenuEvent* event) final; + void mousePressEvent(QMouseEvent* event) final; + void mouseMoveEvent(QMouseEvent* event) final; + void dragEnterEvent(QDragEnterEvent* ev) override; + void dragLeaveEvent(QDragLeaveEvent* ev) override; + void dropEvent(QDropEvent* ev) override; + +private slots: + void retranslateUi(); + void updateTitle(const QString& author, const QString& newName); + void updateUserCount(int numPeers); + +private: + std::shared_ptr chatroom; + +public: + GroupId groupId; +}; diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 2b693e7122..d754781b7a 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -30,11 +30,13 @@ #include "contentlayout.h" #include "friendlistwidget.h" #include "friendwidget.h" +#include "groupwidget.h" #include "maskablepixmapwidget.h" #include "splitterrestorer.h" #include "audio/audio.h" #include "form/conferenceform.h" +#include "form/groupform.h" #include "src/chatlog/content/filetransferwidget.h" #include "src/chatlog/documentcache.h" #include "src/conferencelist.h" @@ -42,14 +44,19 @@ #include "src/core/coreav.h" #include "src/core/corefile.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/ipc.h" #include "src/model/chathistory.h" #include "src/model/chatmanager.h" #include "src/model/chatroom/conferenceroom.h" #include "src/model/chatroom/friendchatroom.h" +#include "src/model/chatroom/grouproom.h" #include "src/model/conference.h" #include "src/model/conferenceinvite.h" #include "src/model/friend.h" +#include "src/model/group.h" +#include "src/model/groupinvite.h" +#include "src/model/groupmessagedispatcher.h" #include "src/model/profile/profileinfo.h" #include "src/model/status.h" #include "src/net/updatecheck.h" @@ -64,6 +71,7 @@ #include "src/widget/form/chatform.h" #include "src/widget/form/conferenceinviteform.h" #include "src/widget/form/filesform.h" +#include "src/widget/form/groupinviteform.h" #include "src/widget/form/profileform.h" #include "src/widget/form/settingswidget.h" #include "src/widget/style.h" @@ -146,6 +154,7 @@ Widget::Widget(Profile& profile_, IAudioControl& audio_, CameraSource& cameraSou , messageBoxManager(new MessageBoxManager(this)) , friendList(new FriendList()) , conferenceList(new ConferenceList()) + , groupList(new GroupList()) , contentDialogManager(new ContentDialogManager(*friendList)) , ipc{ipc_} , toxSave(new ToxSave{settings, ipc, this}) @@ -260,14 +269,15 @@ void Widget::init() core = &profile.getCore(); chatManager = std::make_unique(profile, settings, *friendList, *conferenceList, - contentDialogManager.get(), this); + *groupList, contentDialogManager.get(), this); connect(chatManager.get(), &ChatManager::friendAdded, this, &Widget::onFriendModelAdded); connect(chatManager.get(), &ChatManager::conferenceAdded, this, &Widget::onConferenceModelAdded); connect(chatManager.get(), &ChatManager::conferenceNeedsName, this, &Widget::onConferenceNeedsName); + connect(chatManager.get(), &ChatManager::groupAdded, this, &Widget::onGroupModelAdded); chatListWidget = new FriendListWidget(*core, this, settings, style, *messageBoxManager, *friendList, - *conferenceList, profile, settings.getConferencePosition()); + *conferenceList, *groupList, profile, settings.getConferencePosition()); connect(chatListWidget, &FriendListWidget::searchCircle, this, &Widget::searchCircle); connect(chatListWidget, &FriendListWidget::connectCircleWidget, this, &Widget::connectCircleWidget); ui->friendList->setWidget(chatListWidget); @@ -298,6 +308,7 @@ void Widget::init() filesForm = new FilesForm(*coreFile, settings, style, *messageBoxManager, *friendList); addFriendForm = new AddFriendForm(core->getSelfId(), settings, style, *messageBoxManager, *core); conferenceInviteForm = new ConferenceInviteForm(settings, *core); + groupInviteForm = new GroupInviteForm(settings, *core); updateCheck = std::make_unique(settings); connect(updateCheck.get(), &UpdateCheck::updateAvailable, this, &Widget::onUpdateAvailable); @@ -320,6 +331,7 @@ void Widget::init() connect(coreFile, &CoreFile::fileReceiveRequested, this, &Widget::onFileReceiveRequested); connect(ui->addButton, &QPushButton::clicked, this, &Widget::onAddClicked); connect(ui->conferenceButton, &QPushButton::clicked, this, &Widget::onConferenceClicked); + connect(ui->groupButton, &QPushButton::clicked, this, &Widget::onGroupClicked); connect(ui->transferButton, &QPushButton::clicked, this, &Widget::onTransferClicked); connect(ui->settingsButton, &QPushButton::clicked, this, &Widget::onShowSettings); connect(ui->debugButton, &QPushButton::clicked, this, &Widget::onShowDebug); @@ -330,6 +342,7 @@ void Widget::init() connect(addFriendForm, &AddFriendForm::friendRequested, this, &Widget::friendRequested); connect(conferenceInviteForm, &ConferenceInviteForm::conferenceCreate, core, &Core::createConference); + connect(groupInviteForm, &GroupInviteForm::groupCreate, core, &Core::createGroup); connect(timer, &QTimer::timeout, this, &Widget::onUserAwayCheck); connect(timer, &QTimer::timeout, this, &Widget::onEventIconTick); connect(timer, &QTimer::timeout, this, &Widget::onTryCreateTrayIcon); @@ -488,7 +501,9 @@ void Widget::init() friendRequestsButton = nullptr; conferenceInvitesButton = nullptr; + groupInvitesButton = nullptr; unreadConferenceInvites = 0; + unreadGroupInvites = 0; connect(addFriendForm, &AddFriendForm::friendRequested, this, &Widget::friendRequestsUpdate); connect(addFriendForm, &AddFriendForm::friendRequestsSeen, this, &Widget::friendRequestsUpdate); @@ -497,6 +512,9 @@ void Widget::init() &Widget::conferenceInvitesClear); connect(conferenceInviteForm, &ConferenceInviteForm::conferenceInviteAccepted, this, &Widget::onConferenceInviteAccepted); + connect(groupInviteForm, &GroupInviteForm::groupInvitesSeen, this, &Widget::groupInvitesClear); + connect(groupInviteForm, &GroupInviteForm::groupInviteAccepted, this, + &Widget::onGroupInviteAccepted); // settings connect(&settings, &Settings::enableDebugChanged, this, &Widget::onEnableDebugChanged); @@ -737,6 +755,7 @@ void Widget::onCoreChanged(Core& core_) connect(core, &Core::conferencePeerAudioPlaying, this, &Widget::onConferencePeerAudioPlaying); connect(core, &Core::friendTypingChanged, this, &Widget::onFriendTypingChanged); connect(core, &Core::conferenceSentFailed, this, &Widget::onConferenceSendFailed); + connect(core, &Core::groupInviteReceived, this, &Widget::onGroupInviteReceived); connect(core, &Core::usernameSet, this, &Widget::refreshPeerListsLocal); connect(this, &Widget::statusSet, core, &Core::setStatus); @@ -906,6 +925,22 @@ void Widget::onConferenceClicked() } } +void Widget::onGroupClicked() +{ + if (settings.getSeparateWindow()) { + if (!groupInviteForm->isShown()) { + groupInviteForm->show(createContentDialog(DialogType::GroupDialog)); + } + + setActiveToolMenuButton(ActiveToolMenuButton::None); + } else { + hideMainForms(nullptr); + groupInviteForm->show(contentLayout); + setWindowTitle(fromDialogType(DialogType::GroupDialog)); + setActiveToolMenuButton(ActiveToolMenuButton::GroupButton); + } +} + void Widget::onTransferClicked() { if (settings.getSeparateWindow()) { @@ -1235,7 +1270,7 @@ void Widget::onFriendModelAdded(Friend* newFriend, std::shared_ptrgetFriend(); const Conference* conference = widget->getConference(); + const Group* group = widget->getGroup(); bool chatFormIsSet; if (frnd != nullptr) { form = chatForms[frnd->getPublicKey()]; contentDialogManager->focusChat(frnd->getPersistentId()); chatFormIsSet = contentDialogManager->chatWidgetExists(frnd->getPersistentId()); - } else { + } else if (conference != nullptr) { form = conferenceForms[conference->getPersistentId()].data(); contentDialogManager->focusChat(conference->getPersistentId()); chatFormIsSet = contentDialogManager->chatWidgetExists(conference->getPersistentId()); + } else { + form = groupForms[group->getPersistentId()].data(); + contentDialogManager->focusChat(group->getPersistentId()); + chatFormIsSet = contentDialogManager->chatWidgetExists(group->getPersistentId()); } if ((chatFormIsSet || form->isVisible()) && !newWindow) { @@ -1396,8 +1436,10 @@ void Widget::openDialog(GenericChatroomWidget* widget, bool newWindow) if (frnd != nullptr) { addFriendDialog(frnd, dialog); - } else { + } else if (conference != nullptr) { addConferenceDialog(conference, dialog); + } else { + addGroupDialog(group, dialog); } dialog->raise(); @@ -1406,8 +1448,10 @@ void Widget::openDialog(GenericChatroomWidget* widget, bool newWindow) hideMainForms(widget); if (frnd != nullptr) { chatForms[frnd->getPublicKey()]->show(contentLayout); - } else { + } else if (conference != nullptr) { conferenceForms[conference->getPersistentId()]->show(contentLayout); + } else { + groupForms[group->getPersistentId()]->show(contentLayout); } widget->setAsActiveChatroom(); setWindowTitle(widget->getTitle()); @@ -1511,6 +1555,49 @@ void Widget::addConferenceDialog(const Conference* conference, ContentDialog* di emit widget->chatroomWidgetClicked(widget); } +void Widget::addGroupDialog(const Group* group, ContentDialog* dialog) +{ + const GroupId& groupId = group->getPersistentId(); + ContentDialog* groupDialog = contentDialogManager->getGroupDialog(groupId); + const bool separated = settings.getSeparateWindow(); + Q_ASSERT(groupWidgets.contains(groupId)); + GroupWidget* widget = groupWidgets[groupId]; + const bool isCurrentWindow = activeChatroomWidget == widget; + if ((groupDialog == nullptr) && !separated && isCurrentWindow) { + onAddClicked(); + } + + auto* chatForm = groupForms[groupId].data(); + auto chatroom = chatManager->getGroupRoom(groupId); + auto* groupWidget = contentDialogManager->addGroupToDialog(dialog, chatroom, chatForm); + + auto removeGroup = qOverload(&Widget::removeGroup); + connect(groupWidget, &GroupWidget::removeGroup, this, removeGroup); + connect(groupWidget, &GroupWidget::chatroomWidgetClicked, chatForm, + &GenericChatForm::focusInput); + connect(groupWidget, &GroupWidget::middleMouseClicked, dialog, + [=]() { dialog->removeGroup(groupId); }); + connect(groupWidget, &GroupWidget::newWindowOpened, this, &Widget::openNewDialog); + + // Signal transmission from the created `groupWidget` (which shown in + // ContentDialog) to the `widget` (which shown in main widget) + // FIXME: emit should be removed + connect(groupWidget, &GroupWidget::chatroomWidgetClicked, widget, + [widget](GenericChatroomWidget* w) { + std::ignore = w; + emit widget->chatroomWidgetClicked(widget); + }); + + connect(groupWidget, &GroupWidget::newWindowOpened, widget, + [widget](GenericChatroomWidget* w) { + std::ignore = w; + emit widget->newWindowOpened(widget); + }); + + // FIXME: emit should be removed + emit widget->chatroomWidgetClicked(widget); +} + bool Widget::newFriendMessageAlert(const ToxPk& friendId, const QString& text, bool sound, QString filename, size_t filesize) { @@ -1617,6 +1704,46 @@ bool Widget::newConferenceMessageAlert(const ConferenceId& conferenceId, const T return true; } +bool Widget::newGroupMessageAlert(const GroupId& groupId, const ToxPk& authorPk, + const QString& message, bool notify) +{ + bool hasActive; + QWidget* currentWindow; + ContentDialog* contentDialog = contentDialogManager->getGroupDialog(groupId); + Group* g = groupList->findGroup(groupId); + GroupWidget* widget = groupWidgets[groupId]; + + if (contentDialog != nullptr) { + currentWindow = contentDialog->window(); + hasActive = contentDialogManager->isChatActive(groupId); + } else { + currentWindow = window(); + hasActive = widget == activeChatroomWidget; + } + + if (!newMessageAlert(currentWindow, hasActive, true, notify)) { + return false; + } + + g->setEventFlag(true); + widget->updateStatusLight(); + if (notifier != nullptr) { + auto notificationData = + notificationGenerator->groupMessageNotification(g, authorPk, message); + notifier->notifyMessage(notificationData); + } + + if (contentDialog == nullptr) { + if (hasActive) { + setWindowTitle(widget->getTitle()); + } + } else { + contentDialogManager->updateGroupStatus(groupId); + } + + return true; +} + QString Widget::fromDialogType(DialogType type) { switch (type) { @@ -1624,6 +1751,8 @@ QString Widget::fromDialogType(DialogType type) return tr("Add friend", "title of the window"); case DialogType::ConferenceDialog: return tr("Conference invites", "title of the window"); + case DialogType::GroupDialog: + return tr("Group invites", "title of the window"); case DialogType::TransferDialog: return tr("File transfers", "title of the window"); case DialogType::SettingDialog: @@ -1777,6 +1906,12 @@ void Widget::onConferenceDialogShown(Conference* c) onDialogShown(conferenceWidgets[conferenceId]); } +void Widget::onGroupDialogShown(Group* g) +{ + const GroupId& groupId = g->getPersistentId(); + onDialogShown(groupWidgets[groupId]); +} + void Widget::toggleFullScreen() { if (windowState().testFlag(Qt::WindowFullScreen)) { @@ -1796,7 +1931,7 @@ void Widget::onUpdateAvailable() ContentDialog* Widget::createContentDialog() const { auto* contentDialog = new ContentDialog(*core, settings, style, *messageBoxManager, *friendList, - *conferenceList, profile); + *conferenceList, *groupList, profile); registerContentDialog(*contentDialog); return contentDialog; } @@ -1807,11 +1942,13 @@ void Widget::registerContentDialog(ContentDialog& contentDialog) const connect(&contentDialog, &ContentDialog::friendDialogShown, this, &Widget::onFriendDialogShown); connect(&contentDialog, &ContentDialog::conferenceDialogShown, this, &Widget::onConferenceDialogShown); + connect(&contentDialog, &ContentDialog::groupDialogShown, this, &Widget::onGroupDialogShown); connect(core, &Core::usernameSet, &contentDialog, &ContentDialog::setUsername); connect(&settings, &Settings::conferencePositionChanged, &contentDialog, &ContentDialog::reorderLayouts); connect(&contentDialog, &ContentDialog::addFriendDialog, this, &Widget::addFriendDialog); connect(&contentDialog, &ContentDialog::addConferenceDialog, this, &Widget::addConferenceDialog); + connect(&contentDialog, &ContentDialog::addGroupDialog, this, &Widget::addGroupDialog); connect(&contentDialog, &ContentDialog::connectFriendWidget, this, &Widget::connectFriendWidget); #ifdef Q_OS_MAC @@ -1955,6 +2092,41 @@ void Widget::onConferenceInviteAccepted(const ConferenceInvite& inviteInfo) } } +void Widget::onGroupInviteReceived(const GroupInvite& inviteInfo) +{ + const uint32_t friendId = inviteInfo.getFriendId(); + const ToxPk& friendPk = friendList->id2Key(friendId); + const Friend* f = friendList->findFriend(friendPk); + if (f != nullptr) { + updateFriendActivity(*f); + } + + if (settings.getAutoGroupInvite(f->getPublicKey())) { + onGroupInviteAccepted(inviteInfo); + } else { + if (!groupInviteForm->addGroupInvite(inviteInfo)) { + return; + } + + ++unreadGroupInvites; + groupInvitesUpdate(); + newMessageAlert(window(), isActiveWindow(), true, true); + if (notifier != nullptr) { + auto notificationData = notificationGenerator->groupInvitationNotification(f); + notifier->notifyMessage(notificationData); + } + } +} + +void Widget::onGroupInviteAccepted(const GroupInvite& inviteInfo) +{ + const uint32_t groupNumber = core->joinGroup(inviteInfo); + if (groupNumber == std::numeric_limits::max()) { + qWarning() << "onGroupInviteAccepted: Unable to accept group invite"; + return; + } +} + void Widget::titleChangedByUser(const QString& title) { const auto* conference = qobject_cast(sender()); @@ -2040,6 +2212,126 @@ void Widget::removeConference(const ConferenceId& conferenceId) removeConference(conferenceList->findConference(conferenceId)); } +void Widget::removeGroup(Group* g, bool fake) +{ + assert(g); + if (!fake) { + RemoveChatDialog ask(this, *g); + ask.exec(); + + if (!ask.accepted()) { + return; + } + + if (ask.removeHistory()) { + profile.getHistory()->removeChatHistory(g->getPersistentId()); + } + } + + const auto& groupId = g->getPersistentId(); + const auto groupNumber = g->getId(); + auto groupWidgetIt = groupWidgets.find(groupId); + if (groupWidgetIt == groupWidgets.end()) { + qWarning() << "Tried to remove group" << groupNumber + << "but GroupWidget doesn't exist"; + return; + } + auto* widget = groupWidgetIt.value(); + widget->setAsInactiveChatroom(); + if (static_cast(widget) == activeChatroomWidget) { + activeChatroomWidget = nullptr; + onAddClicked(); + } + + ContentDialog* contentDialog = contentDialogManager->getGroupDialog(groupId); + if (contentDialog != nullptr) { + contentDialog->removeGroup(groupId); + } + + chatListWidget->removeGroupWidget(widget); // deletes widget + + groupWidgets.remove(groupId); + groupAlertConnections.remove(groupId); + + // Destroy GroupForm before ChatManager removes the model, because + // ~GroupForm() calls addSystemInfoMessage() which accesses chatLog. + auto groupFormIt = groupForms.find(groupId); + if (groupFormIt == groupForms.end()) { + qWarning() << "Tried to remove group" << groupNumber + << "but GroupForm doesn't exist"; + return; + } + groupForms.erase(groupFormIt); + + if (!fake) { + chatManager->removeGroup(groupId); + } else { + chatManager->removeGroupModel(groupId); + } + groupList->removeGroup(groupId, fake); + + delete g; + if ((contentLayout != nullptr) && contentLayout->mainHead->layout()->isEmpty()) { + onAddClicked(); + } +} + +void Widget::removeGroup(const GroupId& groupId) +{ + removeGroup(groupList->findGroup(groupId)); +} + +void Widget::onGroupModelAdded(Group* newGroup, std::shared_ptr chatroom, + std::shared_ptr messageDispatcher, + std::shared_ptr chatHistory) +{ + const GroupId& groupId = newGroup->getPersistentId(); + + const auto compact = settings.getCompactLayout(); + auto* widget = new GroupWidget(chatroom, compact, settings, style, this); + + auto notifyReceivedConnection = + connect(messageDispatcher.get(), &IMessageDispatcher::messageReceived, this, + [this, groupId](const ToxPk& author, const Message& message) { + auto isTargeted = + std::any_of(message.metadata.begin(), message.metadata.end(), + [](MessageMetadata metadata) { + return metadata.type == MessageMetadataType::selfMention; + }); + newGroupMessageAlert(groupId, author, message.content, + isTargeted || settings.getConferenceAlwaysNotify()); + }); + groupAlertConnections.insert(groupId, notifyReceivedConnection); + + auto* form = new GroupForm(*core, newGroup, *chatHistory, *messageDispatcher, settings, + *documentCache, *smileyPack, style, *messageBoxManager, *friendList, + *conferenceList, *groupList); + connect(&settings, &Settings::nameColorsChanged, form, &GenericChatForm::setColorizedNames); + form->setColorizedNames(settings.getEnableConferencesColor()); + groupWidgets[groupId] = widget; + groupForms[groupId] = QSharedPointer(form); + + chatListWidget->addGroupWidget(widget); + widget->updateStatusLight(); + chatListWidget->activateWindow(); + + connect(widget, &GroupWidget::chatroomWidgetClicked, this, &Widget::onChatroomWidgetClicked); + connect(widget, &GroupWidget::newWindowOpened, this, &Widget::openNewDialog); + auto widgetRemoveGroup = QOverload::of(&Widget::removeGroup); + connect(widget, &GroupWidget::removeGroup, this, widgetRemoveGroup); + connect(widget, &GroupWidget::middleMouseClicked, this, + [this, groupId]() { removeGroup(groupId); }); + connect(widget, &GroupWidget::chatroomWidgetClicked, form, &GenericChatForm::focusInput); + connect(newGroup, &Group::titleChanged, this, + [this, groupId](const QString& /* author */, const QString& title) { + GroupWidget* w = groupWidgets[groupId]; + if (w->isActive()) { + formatWindowTitle(title); + } + chatListWidget->itemsChanged(); + }); +} + void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptr chatroom, std::shared_ptr messageDispatcher, std::shared_ptr chatHistory) @@ -2064,7 +2356,7 @@ void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptrsetColorizedNames(settings.getEnableConferencesColor()); conferenceWidgets[conferenceId] = widget; @@ -2542,12 +2834,38 @@ void Widget::conferenceInvitesClear() conferenceInvitesUpdate(); } +void Widget::groupInvitesUpdate() +{ + if (unreadGroupInvites == 0) { + delete groupInvitesButton; + groupInvitesButton = nullptr; + } else if (groupInvitesButton == nullptr) { + groupInvitesButton = new QPushButton(this); + groupInvitesButton->setObjectName("green"); + ui->statusLayout->insertWidget(3, groupInvitesButton); + + connect(groupInvitesButton, &QPushButton::released, this, &Widget::onGroupClicked); + } + + if (groupInvitesButton != nullptr) { + groupInvitesButton->setText(tr("%n new group invite(s)", "", unreadGroupInvites)); + } +} + +void Widget::groupInvitesClear() +{ + unreadGroupInvites = 0; + groupInvitesUpdate(); +} + void Widget::setActiveToolMenuButton(ActiveToolMenuButton newActiveButton) { ui->addButton->setChecked(newActiveButton == ActiveToolMenuButton::AddButton); ui->addButton->setDisabled(newActiveButton == ActiveToolMenuButton::AddButton); ui->conferenceButton->setChecked(newActiveButton == ActiveToolMenuButton::ConferenceButton); ui->conferenceButton->setDisabled(newActiveButton == ActiveToolMenuButton::ConferenceButton); + ui->groupButton->setChecked(newActiveButton == ActiveToolMenuButton::GroupButton); + ui->groupButton->setDisabled(newActiveButton == ActiveToolMenuButton::GroupButton); ui->transferButton->setChecked(newActiveButton == ActiveToolMenuButton::TransferButton); ui->transferButton->setDisabled(newActiveButton == ActiveToolMenuButton::TransferButton); ui->settingsButton->setChecked(newActiveButton == ActiveToolMenuButton::SettingButton); @@ -2585,6 +2903,7 @@ void Widget::retranslateUi() friendRequestsUpdate(); conferenceInvitesUpdate(); + groupInvitesUpdate(); #ifdef Q_OS_MAC diff --git a/src/widget/widget.h b/src/widget/widget.h index 1bc497de42..2287af1c97 100644 --- a/src/widget/widget.h +++ b/src/widget/widget.h @@ -11,6 +11,7 @@ #include "audio/iaudiocontrol.h" #include "audio/iaudiosink.h" #include "src/core/conferenceid.h" +#include "src/core/groupid.h" #include "src/core/toxfile.h" #include "src/core/toxid.h" #include "src/core/toxpk.h" @@ -54,6 +55,13 @@ class ConferenceWidget; class ConferenceMessageDispatcher; class DocumentCache; class FriendMessageDispatcher; +class Group; +class GroupForm; +class GroupRoom; +class GroupInvite; +class GroupInviteForm; +class GroupWidget; +class GroupMessageDispatcher; class MaskablePixmapWidget; class ProfileForm; class ProfileInfo; @@ -76,6 +84,7 @@ class IMessageBoxManager; class ContentDialogManager; class FriendList; class ConferenceList; +class GroupList; class IPC; class ToxSave; class Nexus; @@ -89,6 +98,7 @@ class Widget final : public QMainWindow { AddButton, ConferenceButton, + GroupButton, TransferButton, SettingButton, DebugButton, @@ -102,6 +112,7 @@ class Widget final : public QMainWindow SettingDialog, ProfileDialog, ConferenceDialog, + GroupDialog, DebugDialog, }; @@ -126,10 +137,13 @@ class Widget final : public QMainWindow void showUpdateDownloadProgress(); void addFriendDialog(const Friend* frnd, ContentDialog* dialog); void addConferenceDialog(const Conference* conference, ContentDialog* dialog); + void addGroupDialog(const Group* group, ContentDialog* dialog); bool newFriendMessageAlert(const ToxPk& friendId, const QString& text, bool sound = true, QString filename = QString(), size_t filesize = 0); bool newConferenceMessageAlert(const ConferenceId& conferenceId, const ToxPk& authorPk, const QString& message, bool notify); + bool newGroupMessageAlert(const GroupId& groupId, const ToxPk& authorPk, const QString& message, + bool notify); bool getIsWindowMinimized(); void updateIcons(); @@ -173,6 +187,8 @@ public slots: void onFileReceiveRequested(const ToxFile& file); void onConferenceInviteReceived(const ConferenceInvite& inviteInfo); void onConferenceInviteAccepted(const ConferenceInvite& inviteInfo); + void onGroupInviteReceived(const GroupInvite& inviteInfo); + void onGroupInviteAccepted(const GroupInvite& inviteInfo); void titleChangedByUser(const QString& title); void onConferencePeerAudioPlaying(uint32_t conferencenumber, ToxPk peerPk); void onConferenceSendFailed(uint32_t conferencenumber); @@ -181,6 +197,7 @@ public slots: void previousChat(); void onFriendDialogShown(const Friend* f); void onConferenceDialogShown(Conference* c); + void onGroupDialogShown(Group* g); void toggleFullScreen(); void refreshPeerListsLocal(const QString& username); void onUpdateAvailable(); @@ -200,6 +217,7 @@ public slots: private slots: void onAddClicked(); void onConferenceClicked(); + void onGroupClicked(); void onTransferClicked(); void showProfile(); void openNewDialog(GenericChatroomWidget* widget); @@ -208,6 +226,7 @@ private slots: void removeFriend(const ToxPk& friendId); void copyFriendIdToClipboard(const ToxPk& friendId); void removeConference(const ConferenceId& conferenceId); + void removeGroup(const GroupId& groupId); void setStatusOnline(); void setStatusAway(); void setStatusBusy(); @@ -222,6 +241,8 @@ private slots: void friendRequestsUpdate(); void conferenceInvitesUpdate(); void conferenceInvitesClear(); + void groupInvitesUpdate(); + void groupInvitesClear(); void onStartConferenceCall(uint32_t conferenceId); void onEndConferenceCall(uint32_t conferenceId); void onDialogShown(GenericChatroomWidget* widget); @@ -246,6 +267,9 @@ private slots: void onConferenceModelAdded(Conference* newConference, std::shared_ptr chatroom, std::shared_ptr dispatcher, std::shared_ptr chatHistory); + void onGroupModelAdded(Group* newGroup, std::shared_ptr chatroom, + std::shared_ptr dispatcher, + std::shared_ptr chatHistory); void onConferenceNeedsName(const ConferenceId& conferenceId); private: @@ -262,6 +286,7 @@ private slots: void hideMainForms(GenericChatroomWidget* chatroomWidget); void removeFriend(Friend* f, bool fake = false); void removeConference(Conference* c, bool fake = false); + void removeGroup(Group* g, bool fake = false); void saveWindowGeometry(); void saveSplitterGeometry(); void cycleChats(bool forward); @@ -310,6 +335,7 @@ private slots: ContentLayout* contentLayout; AddFriendForm* addFriendForm; ConferenceInviteForm* conferenceInviteForm; + GroupInviteForm* groupInviteForm; ProfileInfo* profileInfo; ProfileForm* profileForm; @@ -330,13 +356,18 @@ private slots: bool wasMaximized = false; QPushButton* friendRequestsButton; QPushButton* conferenceInvitesButton; + QPushButton* groupInvitesButton; unsigned int unreadConferenceInvites; + unsigned int unreadGroupInvites; int icon_size; IAudioControl& audio; std::unique_ptr audioNotification; Settings& settings; + std::unique_ptr smileyPack; + std::unique_ptr documentCache; + QMap friendWidgets; // Stop gap method of linking our friend messages back to a conference id. // Eventual goal is to have a notification manager that works on @@ -352,6 +383,14 @@ private slots: // yet QMap conferenceAlertConnections; QMap> conferenceForms; + + QMap groupWidgets; + // Stop gap method of linking our group messages back to a group id. + // Eventual goal is to have a notification manager that works on + // Messages hooked up to message dispatchers but we aren't there + // yet + QMap groupAlertConnections; + QMap> groupForms; Core* core = nullptr; std::unique_ptr chatManager; @@ -369,13 +408,12 @@ private slots: QAction* nextConversationAction; QAction* previousConversationAction; #endif - std::unique_ptr smileyPack; - std::unique_ptr documentCache; CameraSource& cameraSource; Style& style; IMessageBoxManager* messageBoxManager = nullptr; // freed by Qt on destruction std::unique_ptr friendList; std::unique_ptr conferenceList; + std::unique_ptr groupList; std::unique_ptr contentDialogManager; IPC& ipc; std::unique_ptr toxSave; diff --git a/test/core/chatid_test.cpp b/test/core/chatid_test.cpp index 99b59ff94a..72c7d23ca1 100644 --- a/test/core/chatid_test.cpp +++ b/test/core/chatid_test.cpp @@ -40,7 +40,7 @@ private slots: void TestChatId::toStringTest() { - QCOMPARE(testPk.size(), ToxPk::size); + QCOMPARE(testPk.size(), TOX_PUBLIC_KEY_SIZE); const ToxPk pk(testPk); QVERIFY(testStr == pk.toString()); } @@ -83,8 +83,8 @@ void TestChatId::sizeTest() { const ToxPk pk; const ConferenceId id; - QVERIFY(pk.getSize() == ToxPk::size); - QVERIFY(id.getSize() == ConferenceId::size); + QVERIFY(pk.getSize() == TOX_PUBLIC_KEY_SIZE); + QVERIFY(id.getSize() == TOX_CONFERENCE_ID_SIZE); } void TestChatId::hashableTest() diff --git a/test/mock/include/mock/mockconferencequery.h b/test/mock/include/mock/mockconferencequery.h index 56e27e74d8..b959d2db94 100644 --- a/test/mock/include/mock/mockconferencequery.h +++ b/test/mock/include/mock/mockconferencequery.h @@ -45,7 +45,7 @@ class MockConferenceQuery : public ICoreConferenceQuery ToxPk getConferencePeerPk(int conferenceId, int peerId) const override { std::ignore = conferenceId; - uint8_t id[ToxPk::size] = {static_cast(peerId)}; + uint8_t id[TOX_PUBLIC_KEY_SIZE] = {static_cast(peerId)}; return ToxPk(id); } diff --git a/test/mock/include/mock/mockcoreidhandler.h b/test/mock/include/mock/mockcoreidhandler.h index f22fdb1869..79026c3fb1 100644 --- a/test/mock/include/mock/mockcoreidhandler.h +++ b/test/mock/include/mock/mockcoreidhandler.h @@ -25,7 +25,7 @@ class MockCoreIdHandler : public ICoreIdHandler ToxPk getSelfPublicKey() const override { - static uint8_t id[ToxPk::size] = {0}; + static uint8_t id[TOX_PUBLIC_KEY_SIZE] = {0}; return ToxPk(id); } diff --git a/test/mock/include/mock/mockcoresettings.h b/test/mock/include/mock/mockcoresettings.h index c6ac5ea70d..7f49e73487 100644 --- a/test/mock/include/mock/mockcoresettings.h +++ b/test/mock/include/mock/mockcoresettings.h @@ -9,6 +9,7 @@ #include #include +#include class MockSettings : public QObject, public ICoreSettings { @@ -80,6 +81,11 @@ class MockSettings : public QObject, public ICoreSettings return {QNetworkProxy::ProxyType::NoProxy}; } + QStringList getSavedGroups() const override + { + return savedGroups; + } + SIGNAL_IMPL(MockSettings, enableIPv6Changed, bool enabled) SIGNAL_IMPL(MockSettings, forceTCPChanged, bool enabled) SIGNAL_IMPL(MockSettings, enableLanDiscoveryChanged, bool enabled) @@ -91,4 +97,5 @@ class MockSettings : public QObject, public ICoreSettings QString addr; ProxyType type; quint16 port; + QStringList savedGroups; }; diff --git a/test/model/chathistory_test.cpp b/test/model/chathistory_test.cpp index 5e100f1c78..28b33e8d7f 100644 --- a/test/model/chathistory_test.cpp +++ b/test/model/chathistory_test.cpp @@ -7,6 +7,7 @@ #include "src/conferencelist.h" #include "src/core/icoreidhandler.h" #include "src/friendlist.h" +#include "src/grouplist.h" #include "src/model/friend.h" #include "src/persistence/db/rawdatabase.h" #include "src/persistence/db/upgrades/dbupgrader.h" @@ -121,6 +122,7 @@ private slots: std::unique_ptr messageDispatcher; std::unique_ptr friendList; std::unique_ptr conferenceList; + std::unique_ptr groupList; std::unique_ptr f; }; @@ -140,6 +142,7 @@ void TestChatHistory::init() messageDispatcher = std::make_unique(); friendList = std::make_unique(); conferenceList = std::make_unique(); + groupList = std::make_unique(); f = std::make_unique( 0, ToxPk(QString("FE34BC6D87B66E958C57BBF205F9B79B62BE0AB8A4EFC1F1BB9EC4D0D8FB0663"))); } @@ -147,6 +150,7 @@ void TestChatHistory::init() void TestChatHistory::cleanup() { f.reset(); + groupList.reset(); conferenceList.reset(); friendList.reset(); messageDispatcher.reset(); @@ -171,7 +175,7 @@ void TestChatHistory::testHistoryLoading() db->sync(); const ChatHistory chatHistory(*f, history.get(), *idHandler, *settings, *messageDispatcher, - *friendList, *conferenceList); + *friendList, *conferenceList, *groupList); QCOMPARE(chatHistory.getNextIdx(), ChatLogIdx(2)); QCOMPARE(chatHistory.at(ChatLogIdx(0)).getContentAsMessage().message.content, QString("msg1")); @@ -189,7 +193,7 @@ void TestChatHistory::testHistorySearch() db->sync(); const ChatHistory chatHistory(*f, history.get(), *idHandler, *settings, *messageDispatcher, - *friendList, *conferenceList); + *friendList, *conferenceList, *groupList); const SearchPos startPos{chatHistory.getNextIdx(), 0}; const SearchResult result = chatHistory.searchBackward(startPos, "needle", ParameterSearch()); diff --git a/test/model/conferencemessagedispatcher_test.cpp b/test/model/conferencemessagedispatcher_test.cpp index 38cf776648..63c365fd10 100644 --- a/test/model/conferencemessagedispatcher_test.cpp +++ b/test/model/conferencemessagedispatcher_test.cpp @@ -223,11 +223,11 @@ void TestConferenceMessageDispatcher::testEmptyConference() */ void TestConferenceMessageDispatcher::testSelfReceive() { - uint8_t selfId[ToxPk::size] = {0}; + uint8_t selfId[TOX_PUBLIC_KEY_SIZE] = {0}; conferenceMessageDispatcher->onMessageReceived(ToxPk(selfId), false, "Test"); QVERIFY(receivedMessages.empty()); - uint8_t id[ToxPk::size] = {1}; + uint8_t id[TOX_PUBLIC_KEY_SIZE] = {1}; conferenceMessageDispatcher->onMessageReceived(ToxPk(id), false, "Test"); QVERIFY(receivedMessages.size() == 1); } @@ -237,7 +237,7 @@ void TestConferenceMessageDispatcher::testSelfReceive() */ void TestConferenceMessageDispatcher::testBlockList() { - uint8_t id[ToxPk::size] = {1}; + uint8_t id[TOX_PUBLIC_KEY_SIZE] = {1}; auto otherPk = ToxPk(id); conferenceMessageDispatcher->onMessageReceived(otherPk, false, "Test"); QVERIFY(receivedMessages.size() == 1); diff --git a/test/model/sessionchatlog_test.cpp b/test/model/sessionchatlog_test.cpp index e04486df81..7c51c4dbd8 100644 --- a/test/model/sessionchatlog_test.cpp +++ b/test/model/sessionchatlog_test.cpp @@ -9,6 +9,8 @@ #include "src/model/ichatlog.h" #include "src/model/imessagedispatcher.h" +#include "src/grouplist.h" + #include #include @@ -35,7 +37,7 @@ class MockCoreIdHandler : public ICoreIdHandler ToxPk getSelfPublicKey() const override { - static uint8_t id[ToxPk::size] = {5}; + static uint8_t id[TOX_PUBLIC_KEY_SIZE] = {5}; return ToxPk(id); } @@ -65,6 +67,7 @@ private slots: std::unique_ptr chatLog; std::unique_ptr friendList; std::unique_ptr conferenceList; + std::unique_ptr groupList; }; /** @@ -74,7 +77,8 @@ void TestSessionChatLog::init() { friendList = std::make_unique(); conferenceList = std::make_unique(); - chatLog = std::make_unique(idHandler, *friendList, *conferenceList); + groupList = std::make_unique(); + chatLog = std::make_unique(idHandler, *friendList, *conferenceList, *groupList); } /** diff --git a/themes/dark/chatArea/chatHead.qss b/themes/dark/chatArea/chatHead.qss index 53073ebb3e..1046edd63a 100644 --- a/themes/dark/chatArea/chatHead.qss +++ b/themes/dark/chatArea/chatHead.qss @@ -26,6 +26,16 @@ QLineEdit font-size:12px; } +#topicLabel +{ + color: @mainText; +} + +#topicLabel[empty="true"] +{ + color: @statusActive; +} + QLabel[peerType="our"] { color: green; diff --git a/themes/default/chatArea/chatHead.qss b/themes/default/chatArea/chatHead.qss index 15d3a89fb0..64e96b11af 100644 --- a/themes/default/chatArea/chatHead.qss +++ b/themes/default/chatArea/chatHead.qss @@ -26,6 +26,16 @@ QLineEdit font-size:12px; } +#topicLabel +{ + color: @mainText; +} + +#topicLabel[empty="true"] +{ + color: @statusActive; +} + QLabel[peerType="our"] { color: green; diff --git a/translations/ru.ts b/translations/ru.ts index f862e8bbee..cc7686648f 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -759,6 +759,10 @@ so you can save the file on Windows. Conference #%1 Конференция #%1 + + Group %1 + Группа %1 + ChatTextEdit @@ -870,6 +874,93 @@ so you can save the file on Windows. Отказаться + + GroupInviteForm + + Create group + Создать группу + + + Create new group + Создать новую группу + + + Enter a name for the group + Введите имя группы + + + Enter the group Chat ID (64 hex characters): + Введите Chat ID группы (64 шестнадцатеричных символа): + + + Group invites + Приглашения в группы + + + Groups + Группы + + + Invalid group ID. Expected 64 hex characters. + Недопустимый идентификатор группы. Ожидается 64 шестнадцатеричных символа. + + + Join group by ID + Присоединиться к группе по ID + + + + GroupWidget + + Open chat in new window + Перенести разговор в новое окно + + + Remove chat from this window + Исключить разговор из этого окна + + + Set title... + Установить заголовок... + + + Quit group + Menu to quit a group + Покинуть группу + + + %n user(s) in chat + Number of users in chat + + %n пользователь в чате + %n пользователя в чате + %n пользователей в чате + + + + New message + Новое сообщение + + + Online + В сети + + + + GroupInviteWidget + + Invited by %1 to %2 on %3 at %4. + Приглашён %1 в %2 от %3 в %4. + + + Join + Присоединиться + + + Decline + Отказаться + + ConferenceWidget @@ -1351,6 +1442,114 @@ instead of closing entirely. Вы уверены, что вы хотите удалить все отображаемые сообщения? + + GroupForm + + promote to moderator + сделать модератором + + + demote to user + снять модератора + + + kick from group + исключить из группы + + + Copy group ID + Скопировать идентификатор группы + + + Copy topic + Скопировать тему + + + Everyone + Все + + + Group visibility + Видимость группы + + + Lock topic + Заблокировать тему + + + Moderators and Founder + Модераторы и основатель + + + No topic + Тема отсутствует + + + Only Founder + Только основатель + + + Password: + Пароль: + + + Peer limit: + Лимит участников: + + + Private (invite only) + Приватная (только по приглашению) + + + Public (join by link) + Публичная (вступление по ссылке) + + + Remove group password + Убрать пароль группы + + + Set group password + Установить пароль группы + + + Set group password... + Установить пароль группы... + + + Set group topic + Установить тему группы + + + Set peer limit + Установить лимит участников + + + Set peer limit... + Установить лимит участников... + + + Set topic... + Установить тему... + + + Topic: + Тема: + + + Who can speak + Кто может говорить + + + %n user(s) in chat + Number of users in chat + + %n пользователь в чате + %n пользователя в чате + %n пользователей в чате + + + IdentitySettings @@ -3153,6 +3352,19 @@ number here may cause the scroll bar to disappear. %n новых приглашений в конференции + + Group invites + title of the window + Приглашения в группы + + + %n new group invite(s) + + %n новое приглашение в группу + %n новых приглашения в группы + %n новых приглашений в группы + + Exit Tray action menu to exit Tox diff --git a/util/include/util/toxcoreerrorparser.h b/util/include/util/toxcoreerrorparser.h index d769ae4a6b..2135cbfae9 100644 --- a/util/include/util/toxcoreerrorparser.h +++ b/util/include/util/toxcoreerrorparser.h @@ -46,4 +46,15 @@ bool parseErr(Toxav_Err_Bit_Rate_Set error, const char* file, int line, const ch bool parseErr(Toxav_Err_Call_Control error, const char* file, int line, const char* func); bool parseErr(Toxav_Err_Call error, const char* file, int line, const char* func); bool parseErr(Tox_Err_Options_New error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_New error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Join error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Leave error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Peer_Query error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Self_Query error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_State_Query error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Topic_Set error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Send_Message error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Send_Private_Message error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Invite_Friend error, const char* file, int line, const char* func); +bool parseErr(Tox_Err_Group_Invite_Accept error, const char* file, int line, const char* func); } // namespace ToxcoreErrorParser diff --git a/util/src/toxcoreerrorparser.cpp b/util/src/toxcoreerrorparser.cpp index 43879b5618..3b1a5a140b 100644 --- a/util/src/toxcoreerrorparser.cpp +++ b/util/src/toxcoreerrorparser.cpp @@ -685,3 +685,329 @@ bool ToxcoreErrorParser::parseErr(Tox_Err_Options_New error, const char* file, i qCriticalFrom(file, line, func) << "Unknown Tox_Err_Options_New error code:" << error; return false; } + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_New error, const char* file, int line, const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_NEW_OK: + return true; + + case TOX_ERR_GROUP_NEW_TOO_LONG: + qCriticalFrom(file, line, func) << "Group name exceeds maximum length"; + return false; + + case TOX_ERR_GROUP_NEW_EMPTY: + qCriticalFrom(file, line, func) << "Group name is empty"; + return false; + + case TOX_ERR_GROUP_NEW_INIT: + qCriticalFrom(file, line, func) << "Failed to initialize group"; + return false; + + case TOX_ERR_GROUP_NEW_STATE: + qCriticalFrom(file, line, func) << "Group state invalid"; + return false; + + case TOX_ERR_GROUP_NEW_ANNOUNCE: + qCriticalFrom(file, line, func) << "Failed to announce new group"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_New error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Join error, const char* file, int line, const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_JOIN_OK: + return true; + + case TOX_ERR_GROUP_JOIN_INIT: + qCriticalFrom(file, line, func) << "Failed to initialize group join"; + return false; + + case TOX_ERR_GROUP_JOIN_BAD_CHAT_ID: + qCriticalFrom(file, line, func) << "Invalid group chat ID"; + return false; + + case TOX_ERR_GROUP_JOIN_EMPTY: + qCriticalFrom(file, line, func) << "Group chat ID is empty"; + return false; + + case TOX_ERR_GROUP_JOIN_TOO_LONG: + qCriticalFrom(file, line, func) << "Group chat ID is too long"; + return false; + + case TOX_ERR_GROUP_JOIN_PASSWORD: + qCriticalFrom(file, line, func) << "Group requires a password"; + return false; + + case TOX_ERR_GROUP_JOIN_CORE: + qCriticalFrom(file, line, func) << "Failed to initialize group from core"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Join error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Leave error, const char* file, int line, const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_LEAVE_OK: + return true; + + case TOX_ERR_GROUP_LEAVE_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_LEAVE_TOO_LONG: + qCriticalFrom(file, line, func) << "Group name exceeds maximum length"; + return false; + + case TOX_ERR_GROUP_LEAVE_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send leave packet"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Leave error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Peer_Query error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_PEER_QUERY_OK: + return true; + + case TOX_ERR_GROUP_PEER_QUERY_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_PEER_QUERY_PEER_NOT_FOUND: + qCriticalFrom(file, line, func) << "Peer not found"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Peer_Query error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Self_Query error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_SELF_QUERY_OK: + return true; + + case TOX_ERR_GROUP_SELF_QUERY_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Self_Query error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_State_Query error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_STATE_QUERY_OK: + return true; + + case TOX_ERR_GROUP_STATE_QUERY_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_State_Query error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Topic_Set error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_TOPIC_SET_OK: + return true; + + case TOX_ERR_GROUP_TOPIC_SET_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_TOPIC_SET_TOO_LONG: + qCriticalFrom(file, line, func) << "Topic exceeds maximum length"; + return false; + + case TOX_ERR_GROUP_TOPIC_SET_PERMISSIONS: + qCriticalFrom(file, line, func) << "Not enough permissions to set topic"; + return false; + + case TOX_ERR_GROUP_TOPIC_SET_FAIL_CREATE: + qCriticalFrom(file, line, func) << "Failed to create topic packet"; + return false; + + case TOX_ERR_GROUP_TOPIC_SET_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send topic packet"; + return false; + + case TOX_ERR_GROUP_TOPIC_SET_DISCONNECTED: + qCriticalFrom(file, line, func) << "Group is disconnected"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Topic_Set error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Send_Message error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_SEND_MESSAGE_OK: + return true; + + case TOX_ERR_GROUP_SEND_MESSAGE_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_TOO_LONG: + qCriticalFrom(file, line, func) << "Message is too long"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_EMPTY: + qCriticalFrom(file, line, func) << "Message is empty"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_BAD_TYPE: + qCriticalFrom(file, line, func) << "Invalid message type"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_PERMISSIONS: + qCriticalFrom(file, line, func) << "Not enough permissions to send message"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send message"; + return false; + + case TOX_ERR_GROUP_SEND_MESSAGE_DISCONNECTED: + qCriticalFrom(file, line, func) << "Group is disconnected"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Send_Message error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Send_Private_Message error, const char* file, + int line, const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_OK: + return true; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_PEER_NOT_FOUND: + qCriticalFrom(file, line, func) << "Peer not found"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_TOO_LONG: + qCriticalFrom(file, line, func) << "Message is too long"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_EMPTY: + qCriticalFrom(file, line, func) << "Message is empty"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_BAD_TYPE: + qCriticalFrom(file, line, func) << "Invalid message type"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_PERMISSIONS: + qCriticalFrom(file, line, func) << "Not enough permissions to send message"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send message"; + return false; + + case TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_DISCONNECTED: + qCriticalFrom(file, line, func) << "Group is disconnected"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Send_Private_Message error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Invite_Friend error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_INVITE_FRIEND_OK: + return true; + + case TOX_ERR_GROUP_INVITE_FRIEND_GROUP_NOT_FOUND: + qCriticalFrom(file, line, func) << "Group not found"; + return false; + + case TOX_ERR_GROUP_INVITE_FRIEND_FRIEND_NOT_FOUND: + qCriticalFrom(file, line, func) << "Friend not found"; + return false; + + case TOX_ERR_GROUP_INVITE_FRIEND_INVITE_FAIL: + qCriticalFrom(file, line, func) << "Failed to create invite"; + return false; + + case TOX_ERR_GROUP_INVITE_FRIEND_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send invite"; + return false; + + case TOX_ERR_GROUP_INVITE_FRIEND_DISCONNECTED: + qCriticalFrom(file, line, func) << "Friend is disconnected"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Invite_Friend error code:" << error; + return false; +} + +bool ToxcoreErrorParser::parseErr(Tox_Err_Group_Invite_Accept error, const char* file, int line, + const char* func) +{ + switch (error) { + case TOX_ERR_GROUP_INVITE_ACCEPT_OK: + return true; + + case TOX_ERR_GROUP_INVITE_ACCEPT_BAD_INVITE: + qCriticalFrom(file, line, func) << "Invalid invite data"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_INIT_FAILED: + qCriticalFrom(file, line, func) << "Failed to initialize group join"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_TOO_LONG: + qCriticalFrom(file, line, func) << "Invite data is too long"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_EMPTY: + qCriticalFrom(file, line, func) << "Invite data is empty"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_PASSWORD: + qCriticalFrom(file, line, func) << "Group requires a password"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_FRIEND_NOT_FOUND: + qCriticalFrom(file, line, func) << "Friend not found"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_FAIL_SEND: + qCriticalFrom(file, line, func) << "Failed to send join packet"; + return false; + + case TOX_ERR_GROUP_INVITE_ACCEPT_NULL: + qCriticalFrom(file, line, func) << "A required argument was NULL"; + return false; + } + qCriticalFrom(file, line, func) << "Unknown Tox_Err_Group_Invite_Accept error code:" << error; + return false; +} From c656f08176c9ae3b9349dfc5f285d9611bbe822b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 17:03:26 +0200 Subject: [PATCH 02/73] feat(friends): invite friend to group --- src/model/chatmanager.cpp | 3 ++- src/model/chatroom/friendchatroom.cpp | 31 ++++++++++++++++++++++++++- src/model/chatroom/friendchatroom.h | 17 +++++++++++++-- src/widget/friendwidget.cpp | 14 ++++++++++++ translations/ru.ts | 13 +++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index fa5279d4b7..e957c6b995 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -240,7 +240,8 @@ void ChatManager::onFriendAdded(uint32_t friendId, const ToxPk& friendPk) Friend* newFriend = friendList.addFriend(friendId, friendPk, settings); auto chatroom = - std::make_shared(newFriend, dialogsManager, *core, settings, conferenceList); + std::make_shared(newFriend, dialogsManager, *core, settings, conferenceList, + groupList); auto friendMessageDispatcher = std::make_shared(*newFriend, MessageProcessor(*sharedMessageProcessorParams), diff --git a/src/model/chatroom/friendchatroom.cpp b/src/model/chatroom/friendchatroom.cpp index 6784f5ffc2..466fa94628 100644 --- a/src/model/chatroom/friendchatroom.cpp +++ b/src/model/chatroom/friendchatroom.cpp @@ -7,9 +7,11 @@ #include "src/conferencelist.h" #include "src/core/core.h" +#include "src/grouplist.h" #include "src/model/conference.h" #include "src/model/dialogs/idialogsmanager.h" #include "src/model/friend.h" +#include "src/model/group.h" #include "src/model/status.h" #include "src/persistence/settings.h" #include "src/widget/contentdialog.h" @@ -31,12 +33,14 @@ QString getShortName(const QString& name) } // namespace FriendChatroom::FriendChatroom(Friend* frnd_, IDialogsManager* dialogsManager_, Core& core_, - Settings& settings_, ConferenceList& conferenceList_) + Settings& settings_, ConferenceList& conferenceList_, + GroupList& groupList_) : frnd{frnd_} , dialogsManager{dialogsManager_} , core{core_} , settings{settings_} , conferenceList{conferenceList_} + , groupList{groupList_} { } @@ -110,6 +114,19 @@ void FriendChatroom::inviteFriend(const Conference* conference) core.conferenceInviteFriend(friendId, conferenceId); } +void FriendChatroom::inviteToNewGroup() +{ + const auto friendId = frnd->getId(); + const auto groupId = core.createGroup(tr("Group %1").arg(groupList.getAllGroups().size() + 1)); + core.groupInviteFriend(friendId, groupId); +} + +void FriendChatroom::inviteFriend(const Group* group) +{ + const auto friendId = frnd->getId(); + core.groupInviteFriend(friendId, group->getId()); +} + QVector FriendChatroom::getConferences() const { QVector conferences; @@ -122,6 +139,18 @@ QVector FriendChatroom::getConferences() const return conferences; } +QVector FriendChatroom::getGroups() const +{ + QVector groups; + for (auto* const group : groupList.getAllGroups()) { + const auto name = getShortName(group->getDisplayedName()); + const GroupToDisplay groupToDisplay = {name, group}; + groups.push_back(groupToDisplay); + } + + return groups; +} + /** * @brief Return sorted list of circles exclude current circle. */ diff --git a/src/model/chatroom/friendchatroom.h b/src/model/chatroom/friendchatroom.h index 3e6894c911..f054cbe07e 100644 --- a/src/model/chatroom/friendchatroom.h +++ b/src/model/chatroom/friendchatroom.h @@ -11,8 +11,10 @@ class Chat; class Core; -class IDialogsManager; class Friend; +class Group; +class GroupList; +class IDialogsManager; class Conference; class Settings; class ConferenceList; @@ -23,6 +25,12 @@ struct ConferenceToDisplay Conference* conference; }; +struct GroupToDisplay +{ + QString name; + Group* group; +}; + struct CircleToDisplay { QString name; @@ -34,7 +42,7 @@ class FriendChatroom final : public QObject Q_OBJECT public: FriendChatroom(Friend* frnd_, IDialogsManager* dialogsManager_, Core& core_, - Settings& settings_, ConferenceList& conferenceList); + Settings& settings_, ConferenceList& conferenceList, GroupList& groupList); Chat* getChat(); @@ -52,12 +60,16 @@ public slots: void inviteToNewConference(); void inviteFriend(const Conference* conference); + void inviteToNewGroup(); + void inviteFriend(const Group* group); + bool autoAcceptEnabled() const; QString getAutoAcceptDir() const; void disableAutoAccept(); void setAutoAcceptDir(const QString& dir); QVector getConferences() const; + QVector getGroups() const; QVector getOtherCircles() const; void resetEventFlags(); @@ -77,4 +89,5 @@ public slots: Core& core; Settings& settings; ConferenceList& conferenceList; + GroupList& groupList; }; diff --git a/src/widget/friendwidget.cpp b/src/widget/friendwidget.cpp index 4d2e33f1b4..9e5bed04b9 100644 --- a/src/widget/friendwidget.cpp +++ b/src/widget/friendwidget.cpp @@ -122,6 +122,20 @@ void FriendWidget::onContextMenuCalled(QContextMenuEvent* event) [this, conference] { chatroom->inviteFriend(conference.conference); }); } + QMenu* groupMenu = + menu.addMenu(tr("Invite to group", "Menu to invite a friend to a group")); + groupMenu->setEnabled(chatroom->canBeInvited()); + auto* const newGroupAction = groupMenu->addAction(tr("To new group")); + connect(newGroupAction, &QAction::triggered, chatroom.get(), + &FriendChatroom::inviteToNewGroup); + groupMenu->addSeparator(); + + for (const auto& group : chatroom->getGroups()) { + auto* const groupAction = groupMenu->addAction(tr("Invite to group '%1'").arg(group.name)); + connect(groupAction, &QAction::triggered, this, + [this, group] { chatroom->inviteFriend(group.group); }); + } + const auto circleId = chatroom->getCircleId(); auto* circleMenu = menu.addMenu(tr("Move to circle...", "Menu to move a friend into a different circle")); diff --git a/translations/ru.ts b/translations/ru.ts index cc7686648f..5f848027af 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1179,6 +1179,19 @@ so you can save the file on Windows. Invite to conference '%1' Пригласить в конференцию '%1' + + Invite to group + Menu to invite a friend to a group + Пригласить в группу + + + Invite to group '%1' + Пригласить в группу '%1' + + + To new group + В новую группу + Move to circle... Menu to move a friend into a different circle From e339a51121df3dc558baa73a44ff90a3572dfec1 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 17:21:22 +0200 Subject: [PATCH 03/73] refactor(model): use toxcore group size macros --- src/model/group.cpp | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/model/group.cpp b/src/model/group.cpp index 5e5d410b28..783a15e72a 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -14,11 +14,6 @@ #include #include -namespace { -const int MAX_GROUP_NAME_LENGTH = 48; -const int MAX_GROUP_TOPIC_LENGTH = 512; -} // namespace - Group::Group(int groupId_, const GroupId persistentGroupId, QString name, QString selfName_, ICoreGroupQuery& groupQuery_, ICoreIdHandler& idHandler_, FriendList& friendList_) : groupQuery(groupQuery_) @@ -35,7 +30,7 @@ Group::Group(int groupId_, const GroupId persistentGroupId, QString name, QStrin void Group::setName(const QString& newTitle) { - const QString shortTitle = newTitle.left(MAX_GROUP_NAME_LENGTH); + const QString shortTitle = newTitle.left(TOX_GROUP_MAX_GROUP_NAME_LENGTH); if (!shortTitle.isEmpty() && groupName != shortTitle) { groupName = shortTitle; emit displayedNameChanged(groupName); @@ -45,7 +40,7 @@ void Group::setName(const QString& newTitle) void Group::updateName(const QString& newTitle) { - const QString shortTitle = newTitle.left(MAX_GROUP_NAME_LENGTH); + const QString shortTitle = newTitle.left(TOX_GROUP_MAX_GROUP_NAME_LENGTH); if (!shortTitle.isEmpty() && groupName != shortTitle) { groupName = shortTitle; emit displayedNameChanged(groupName); @@ -140,7 +135,7 @@ QString Group::getSelfName() const void Group::setTopic(const QString& author, const QString& newTopic) { - const QString shortTopic = newTopic.left(MAX_GROUP_TOPIC_LENGTH); + const QString shortTopic = newTopic.left(TOX_GROUP_MAX_TOPIC_LENGTH); if (topic != shortTopic) { topic = shortTopic; emit topicChanged(author, topic); From 48dbce46f80b6e8c12e557c34589922bd3576bc6 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 17:27:40 +0200 Subject: [PATCH 04/73] fix(groups): respect topic lock for setting topic --- src/widget/form/groupform.cpp | 19 ++++++++++++++++--- src/widget/form/groupform.h | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index e90da56e7f..081486db71 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -433,7 +433,7 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) auto* copyIdAction = contextMenu->addAction(tr("Copy group ID")); const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); QAction* setTopicAction = nullptr; - if (selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator) { + if (canSetTopic()) { setTopicAction = contextMenu->addAction(tr("Set topic...")); } @@ -547,8 +547,7 @@ void GroupForm::setPeerLimit() void GroupForm::editTopic() { - const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); - if (selfRole != GroupRole::Founder && selfRole != GroupRole::Moderator) { + if (!canSetTopic()) { return; } @@ -559,3 +558,17 @@ void GroupForm::editTopic() core.changeGroupTopic(group->getId(), topic); } } + +bool GroupForm::canSetTopic() const +{ + const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); + if (selfRole == GroupRole::Observer) { + return false; + } + + if (group->getTopicLock() == GroupTopicLock::Disabled) { + return true; + } + + return selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator; +} diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h index 2a8a3d8940..d9c82a9ecf 100644 --- a/src/widget/form/groupform.h +++ b/src/widget/form/groupform.h @@ -70,6 +70,7 @@ private slots: void updateUserNames(); void updateTopicLabel(); static QString roleIcon(GroupRole role); + bool canSetTopic() const; private: Core& core; From 5dbacf3902d48465d4e05eab44105286e030cd3c Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 17:48:41 +0200 Subject: [PATCH 05/73] fix(groups): query initial founder control states --- src/core/core.cpp | 65 ++++++++++++++++++++++++++++++++++++++ src/core/core.h | 5 +++ src/core/icoregroupquery.h | 5 +++ src/model/chatmanager.cpp | 6 ++++ 4 files changed, 81 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index 07f63e33d9..9f6aba17eb 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1620,6 +1620,71 @@ bool Core::setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) return true; } +bool Core::getGroupHasPassword(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const size_t passwordSize = tox_group_get_password_size(tox.get(), groupNumber, &error); + if (PARSE_ERR(error)) { + return passwordSize != 0; + } + + return false; +} + +uint16_t Core::getGroupPeerLimit(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const uint16_t peerLimit = tox_group_get_peer_limit(tox.get(), groupNumber, &error); + if (PARSE_ERR(error)) { + return peerLimit; + } + + return 0; +} + +GroupTopicLock Core::getGroupTopicLock(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const auto topicLock = tox_group_get_topic_lock(tox.get(), groupNumber, &error); + if (PARSE_ERR(error)) { + return static_cast(topicLock); + } + + return GroupTopicLock::Unknown; +} + +GroupVoiceState Core::getGroupVoiceState(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const auto voiceState = tox_group_get_voice_state(tox.get(), groupNumber, &error); + if (PARSE_ERR(error)) { + return static_cast(voiceState); + } + + return GroupVoiceState::Unknown; +} + +GroupPrivacyState Core::getGroupPrivacyState(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const auto privacyState = tox_group_get_privacy_state(tox.get(), groupNumber, &error); + if (PARSE_ERR(error)) { + return static_cast(privacyState); + } + + return GroupPrivacyState::Unknown; +} + /** * @brief Get the name of a group */ diff --git a/src/core/core.h b/src/core/core.h index 2b9f5d6cc5..8f6336ce0c 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -134,6 +134,11 @@ public slots: bool setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) override; bool setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) override; bool setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) override; + bool getGroupHasPassword(int groupNumber) const override; + uint16_t getGroupPeerLimit(int groupNumber) const override; + GroupTopicLock getGroupTopicLock(int groupNumber) const override; + GroupVoiceState getGroupVoiceState(int groupNumber) const override; + GroupPrivacyState getGroupPrivacyState(int groupNumber) const override; void removeFriend(uint32_t friendId); void removeConference(int conferenceId); diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h index b46060cc25..b0ba15f4a5 100644 --- a/src/core/icoregroupquery.h +++ b/src/core/icoregroupquery.h @@ -63,4 +63,9 @@ class ICoreGroupQuery virtual bool setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) = 0; virtual bool setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) = 0; virtual bool setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) = 0; + virtual bool getGroupHasPassword(int groupNumber) const = 0; + virtual uint16_t getGroupPeerLimit(int groupNumber) const = 0; + virtual GroupTopicLock getGroupTopicLock(int groupNumber) const = 0; + virtual GroupVoiceState getGroupVoiceState(int groupNumber) const = 0; + virtual GroupPrivacyState getGroupPrivacyState(int groupNumber) const = 0; }; diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index e957c6b995..d4551d646f 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -633,6 +633,12 @@ Group* ChatManager::createGroup(uint32_t groupNumber, const GroupId& groupId, co core->getUsername(), friendList); assert(newGroup); + newGroup->setPasswordSet(core->getGroupHasPassword(groupNumber)); + newGroup->setPeerLimit(core->getGroupPeerLimit(groupNumber)); + newGroup->setTopicLock(core->getGroupTopicLock(groupNumber)); + newGroup->setVoiceState(core->getGroupVoiceState(groupNumber)); + newGroup->setPrivacyState(core->getGroupPrivacyState(groupNumber)); + auto chatroom = std::make_shared(newGroup, dialogsManager, *core, friendList); auto messageDispatcher = std::make_shared(*newGroup, From daacc7cbd470b7992b54692343aeab4b0aaafd1e Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 18:07:13 +0200 Subject: [PATCH 06/73] fix(groups): inviting friend to group --- src/core/core.cpp | 24 ++++++++++++++++++++++-- src/grouplist.cpp | 8 ++++++++ src/grouplist.h | 1 + src/model/chatmanager.cpp | 11 +++++++++++ src/model/chatmanager.h | 1 + src/model/group.cpp | 5 +++++ src/model/group.h | 1 + 7 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index 9f6aba17eb..f2ee870303 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -1220,6 +1221,25 @@ void Core::loadGroups() { const QMutexLocker ml{&coreLoopLock}; + const uint32_t groupCount = tox_group_get_group_list_size(tox.get()); + QVector numbers(groupCount); + tox_group_get_group_list(tox.get(), numbers.data()); + + QSet alreadyLoaded; + for (const uint32_t groupNumber : numbers) { + const GroupId groupId = getGroupPersistentId(groupNumber); + if (groupId.isEmpty()) { + continue; + } + alreadyLoaded.insert(groupId); + if (groupIdToNumber.contains(groupId)) { + continue; + } + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + emit groupJoined(groupNumber, groupId); + } + const QStringList saved = settings.getSavedGroups(); for (const QString& groupIdHex : saved) { if (groupIdHex.isEmpty()) { @@ -1231,7 +1251,7 @@ void Core::loadGroups() continue; } const GroupId groupId(rawId); - if (groupIdToNumber.contains(groupId)) { + if (groupIdToNumber.contains(groupId) || alreadyLoaded.contains(groupId)) { continue; } @@ -1794,7 +1814,7 @@ void Core::groupInviteFriend(uint32_t friendId, int groupNumber) const QMutexLocker ml{&coreLoopLock}; Tox_Err_Group_Invite_Friend error; - tox_group_invite_friend(tox.get(), friendId, groupNumber, &error); + tox_group_invite_friend(tox.get(), groupNumber, friendId, &error); if (!PARSE_ERR(error)) { qWarning() << "Failed to invite friend" << friendId << "to group" << groupNumber; } diff --git a/src/grouplist.cpp b/src/grouplist.cpp index cb32c557cb..6ce5b39c18 100644 --- a/src/grouplist.cpp +++ b/src/grouplist.cpp @@ -39,6 +39,14 @@ const GroupId& GroupList::id2Key(uint32_t groupNum) return id2key[groupNum]; } +void GroupList::setToxGroupNum(uint32_t oldGroupNum, uint32_t newGroupNum, const GroupId& groupId) +{ + if (oldGroupNum != newGroupNum) { + id2key.remove(oldGroupNum); + id2key[newGroupNum] = groupId; + } +} + void GroupList::removeGroup(const GroupId& groupId, bool /*fake*/) { auto g_it = groupList.find(groupId); diff --git a/src/grouplist.h b/src/grouplist.h index 22bc72924b..a0cf21f195 100644 --- a/src/grouplist.h +++ b/src/grouplist.h @@ -22,6 +22,7 @@ class GroupList const QString& groupName, const QString& selfName, FriendList& friendList); Group* findGroup(const GroupId& groupId); const GroupId& id2Key(uint32_t groupNum); + void setToxGroupNum(uint32_t oldGroupNum, uint32_t newGroupNum, const GroupId& groupId); void removeGroup(const GroupId& groupId, bool fake = false); QList getAllGroups(); void clear(); diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index d4551d646f..add6289d32 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -414,6 +414,8 @@ void ChatManager::onGroupJoined(uint32_t groupNumber, const GroupId& groupId) groupName = settings.getGroupName(groupId.toString()); } g = createGroup(groupNumber, groupId, groupName); + } else { + updateGroupNumber(g, groupNumber); } if (g != nullptr) { addSelfToGroup(g); @@ -477,6 +479,7 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) } } if (g != nullptr) { + updateGroupNumber(g, groupNumber); addSelfToGroup(g); const QString groupName = core->getGroupTitle(groupNumber); if (!groupName.isEmpty()) { @@ -496,6 +499,14 @@ void ChatManager::addSelfToGroup(Group* g) g->onPeerJoin(selfPeerId); } +void ChatManager::updateGroupNumber(Group* g, uint32_t groupNumber) +{ + if (g->getId() != groupNumber) { + groupList.setToxGroupNum(g->getId(), groupNumber, g->getPersistentId()); + g->setToxGroupNumber(groupNumber); + } +} + void ChatManager::onGroupSelfDisconnected(uint32_t groupNumber) { const GroupId& groupId = groupList.id2Key(groupNumber); diff --git a/src/model/chatmanager.h b/src/model/chatmanager.h index 037e291175..6f3e52c2da 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -121,6 +121,7 @@ private slots: Conference* createConference(uint32_t conferenceNum, const ConferenceId& conferenceId); Group* createGroup(uint32_t groupNumber, const GroupId& groupId, const QString& groupName); void addSelfToGroup(Group* g); + void updateGroupNumber(Group* g, uint32_t groupNumber); Profile& profile; Core* core = nullptr; diff --git a/src/model/group.cpp b/src/model/group.cpp index 783a15e72a..fc9edb2c99 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -68,6 +68,11 @@ uint32_t Group::getId() const return toxGroupNum; } +void Group::setToxGroupNumber(uint32_t groupNumber) +{ + toxGroupNum = groupNumber; +} + const GroupId& Group::getPersistentId() const { return groupId; diff --git a/src/model/group.h b/src/model/group.h index 21fbf45097..3df6d2c766 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -28,6 +28,7 @@ class Group : public Chat ICoreGroupQuery& groupQuery_, ICoreIdHandler& idHandler_, FriendList& friendList); uint32_t getId() const override; const GroupId& getPersistentId() const override; + void setToxGroupNumber(uint32_t groupNumber); int getPeersCount() const; const QMap& getPeerList() const; bool peerHasNickname(ToxPk pk); From 65d481c5cbb4eb194c477497b13b270f69c313de Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 18:18:10 +0200 Subject: [PATCH 07/73] fix(groups): remove kicked peer locally --- src/model/group.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/model/group.cpp b/src/model/group.cpp index fc9edb2c99..f14bb1c54d 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -347,7 +347,11 @@ bool Group::kickPeer(const ToxPk& pk) { for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { if (it.value() == pk) { - return groupQuery.kickGroupPeer(toxGroupNum, it.key()); + if (groupQuery.kickGroupPeer(toxGroupNum, it.key())) { + onPeerExit(it.key()); + return true; + } + return false; } } qWarning() << "kickPeer: unknown peer" << pk.toString(); From 9fa065121c20d07cac8be7368b18accd2fe1d41d Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 18:40:07 +0200 Subject: [PATCH 08/73] fix(groups): update local state after founder set --- src/core/core.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index f2ee870303..5b9ea11ca0 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1574,6 +1574,7 @@ bool Core::setGroupPassword(int groupNumber, const QByteArray& password) return false; } + emit groupPasswordChanged(groupNumber, !password.isEmpty()); return true; } @@ -1589,6 +1590,7 @@ bool Core::setGroupPeerLimit(int groupNumber, uint16_t peerLimit) return false; } + emit groupPeerLimitChanged(groupNumber, peerLimit); return true; } @@ -1605,6 +1607,7 @@ bool Core::setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) return false; } + emit groupTopicLockChanged(groupNumber, topicLock); return true; } @@ -1621,6 +1624,7 @@ bool Core::setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) return false; } + emit groupVoiceStateChanged(groupNumber, voiceState); return true; } @@ -1637,6 +1641,7 @@ bool Core::setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) return false; } + emit groupPrivacyStateChanged(groupNumber, privacyState); return true; } From 9dec394f8d80df63968dd804bbf49df787c0cb7a Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 18:55:52 +0200 Subject: [PATCH 09/73] fix(groups): refresh peer roles on self join and reconnect --- src/model/chatmanager.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index add6289d32..ef93059e28 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -481,6 +481,7 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) if (g != nullptr) { updateGroupNumber(g, groupNumber); addSelfToGroup(g); + g->updatePeerRoles(); const QString groupName = core->getGroupTitle(groupNumber); if (!groupName.isEmpty()) { g->updateName(groupName); From 28f93b62dbda234db9d0ac03ed538cb4f5efa6de Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 21:10:50 +0200 Subject: [PATCH 10/73] fix(groups): added workaround for connecting to NGC --- src/core/core.cpp | 85 +++++++++++++++++++++++++++++++++++++++++++++++ src/core/core.h | 9 +++++ 2 files changed, 94 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index 5b9ea11ca0..a85b24b86d 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -36,6 +36,10 @@ const QString Core::TOX_EXT = ".tox"; +// how often to force a group reconnect (workaround for toxcore not restoring +// group connections/roles after a restart) +static constexpr int GROUP_RECONNECT_INTERVAL_MS = 10000; + #define ASSERT_CORE_THREAD assert(QThread::currentThread() == coreThread.get()) namespace { @@ -580,6 +584,8 @@ void Core::onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void std::ignore = tox; auto* const core = static_cast(vCore); qWarning("Group %u peer %u joined", groupNumber, peerId); + ++core->groupPeerCounts[groupNumber]; + core->stopGroupReconnectTimer(groupNumber); emit core->groupPeerJoined(groupNumber, peerId); } @@ -594,6 +600,15 @@ void Core::onGroupPeerExit(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_ std::ignore = partMessageLength; auto* const core = static_cast(vCore); qWarning("Group %u peer %u left, exit type %d", groupNumber, peerId, static_cast(exitType)); + auto it = core->groupPeerCounts.find(groupNumber); + if (it != core->groupPeerCounts.end() && it.value() > 0) { + if (it.value() == 1) { + core->startGroupReconnectTimer(groupNumber); + core->groupPeerCounts.erase(it); + } else { + --it.value(); + } + } emit core->groupPeerExited(groupNumber, peerId); } @@ -616,6 +631,7 @@ void Core::onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore) if (!groupId.isEmpty()) { core->numberToGroupId[groupNumber] = groupId; core->groupIdToNumber[groupId] = groupNumber; + core->startGroupReconnectTimer(groupNumber); } emit core->groupSelfJoined(groupNumber); } @@ -638,6 +654,8 @@ void Core::onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail f std::ignore = failType; auto* const core = static_cast(vCore); qWarning() << "Group join failed for group" << groupNumber; + core->stopGroupReconnectTimer(groupNumber); + core->groupPeerCounts.remove(groupNumber); emit core->groupJoinFailed(groupNumber); } @@ -1237,6 +1255,7 @@ void Core::loadGroups() } numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(groupNumber); emit groupJoined(groupNumber, groupId); } @@ -1267,6 +1286,7 @@ void Core::loadGroups() numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(groupNumber); emit groupJoined(groupNumber, groupId); } } @@ -1843,6 +1863,7 @@ int Core::createGroup(const QString& groupName) const GroupId groupId = getGroupPersistentId(groupNumber); numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(groupNumber); emit saveRequest(); emit emptyGroupCreated(groupNumber, groupId, groupName); @@ -1878,6 +1899,7 @@ uint32_t Core::joinGroup(const GroupInvite& inviteInfo) const GroupId groupId = getGroupPersistentId(groupNumber); numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(groupNumber); emit saveRequest(); emit groupJoined(groupNumber, groupId); @@ -1914,6 +1936,7 @@ int Core::joinGroup(const GroupId& groupId) numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(groupNumber); emit saveRequest(); emit groupJoined(groupNumber, groupId); @@ -1933,6 +1956,8 @@ void Core::quitGroup(int groupNumber) numberToGroupId.erase(groupIdIt); groupIdToNumber.remove(groupId); } + stopGroupReconnectTimer(groupNumber); + groupPeerCounts.remove(groupNumber); emit saveRequest(); emit groupSelfDisconnected(groupNumber); } @@ -1942,6 +1967,12 @@ void Core::leaveAllGroups() { const QMutexLocker ml{&coreLoopLock}; + for (auto it = groupReconnectTimers.cbegin(); it != groupReconnectTimers.cend(); ++it) { + it.value()->deleteLater(); + } + groupReconnectTimers.clear(); + groupPeerCounts.clear(); + if (numberToGroupId.isEmpty()) { return; } @@ -1962,6 +1993,60 @@ void Core::leaveAllGroups() } } +bool Core::reconnectGroup(uint32_t groupNumber) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Reconnect error; + const bool success = tox_group_reconnect(tox.get(), groupNumber, &error); + if (!success) { + qWarning() << "Failed to reconnect group" << groupNumber << ":" + << tox_err_group_reconnect_to_string(error); + } + return success; +} + +void Core::retryGroupReconnect(uint32_t groupNumber) +{ + const QMutexLocker ml{&coreLoopLock}; + + if (!numberToGroupId.contains(groupNumber)) { + stopGroupReconnectTimer(groupNumber); + return; + } + + // group has other members, toxcore keeps it connected on its own + if (groupPeerCounts.value(groupNumber, 0) > 0) { + stopGroupReconnectTimer(groupNumber); + return; + } + + reconnectGroup(groupNumber); +} + +void Core::startGroupReconnectTimer(uint32_t groupNumber) +{ + if (groupReconnectTimers.contains(groupNumber)) { + return; + } + + auto* timer = new QTimer(this); + timer->setInterval(GROUP_RECONNECT_INTERVAL_MS); + connect(timer, &QTimer::timeout, this, + [this, groupNumber] { retryGroupReconnect(groupNumber); }); + groupReconnectTimers[groupNumber] = timer; + QMetaObject::invokeMethod(this, [timer] { timer->start(); }, Qt::QueuedConnection); +} + +void Core::stopGroupReconnectTimer(uint32_t groupNumber) +{ + auto it = groupReconnectTimers.find(groupNumber); + if (it != groupReconnectTimers.end()) { + it.value()->deleteLater(); + groupReconnectTimers.erase(it); + } +} + /** * @brief Checks if a friend is online. Unknown friends are considered offline. */ diff --git a/src/core/core.h b/src/core/core.h index 8f6336ce0c..1fa3374add 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -21,6 +21,7 @@ #include "src/model/status.h" +#include #include #include #include @@ -96,6 +97,7 @@ class Core : public QObject, bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override; bool kickGroupPeer(int groupNumber, int peerId) override; GroupId getGroupPersistentId(uint32_t groupNumber) const; + bool reconnectGroup(uint32_t groupNumber); bool isFriendOnline(uint32_t friendId) const; bool hasFriendWithPublicKey(const ToxPk& publicKey) const; @@ -315,6 +317,10 @@ public slots: void checkLastOnline(uint32_t friendId); + void startGroupReconnectTimer(uint32_t groupNumber); + void stopGroupReconnectTimer(uint32_t groupNumber); + void retryGroupReconnect(uint32_t groupNumber); + QString getFriendRequestErrorMessage(const ToxId& friendId, const QString& message) const; static void registerCallbacks(Tox* tox); @@ -356,4 +362,7 @@ private slots: QHash numberToGroupId; QHash groupIdToNumber; + QHash groupReconnectTimers; + // number of group members other than ourselves + QHash groupPeerCounts; }; From 79f55f9077871ff413ef9f6e0ea79b5e17459051 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Thu, 6 Aug 2026 21:37:25 +0200 Subject: [PATCH 11/73] fix(groups): strip HTML markup from context menu title --- src/widget/form/groupform.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 081486db71..1ade8a67cc 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -27,6 +27,7 @@ #include #include #include +#include namespace { const auto LABEL_PEER_TYPE_OUR = QVariant(QStringLiteral("our")); @@ -366,6 +367,12 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) if (menuTitle.endsWith(QLatin1String(", "))) { menuTitle.chop(2); } + + // remove HTML tags from the title, if any, so that it's displayed correctly in the menu + QTextDocument doc; + doc.setHtml(menuTitle); + menuTitle = doc.toPlainText(); + QAction* menuTitleAction = contextMenu->addAction(menuTitle); menuTitleAction->setEnabled(false); // make sure the title is not clickable contextMenu->addSeparator(); From 11a53bd124f2639a0ad2236e1468a5b2ddc961d8 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 02:24:53 +0200 Subject: [PATCH 12/73] feat(groups): fetch and cache group topic --- src/core/core.cpp | 22 ++++++++++++++++++++++ src/core/core.h | 1 + src/core/icoregroupquery.h | 1 + src/model/chatmanager.cpp | 11 +++++++++++ src/persistence/settings.cpp | 29 +++++++++++++++++++++++++++++ src/persistence/settings.h | 3 +++ 6 files changed, 67 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index a85b24b86d..656446199b 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1752,6 +1752,28 @@ QString Core::getGroupTitle(int groupNumber) const return ToxString(nameBuf.data(), length).getQString(); } +/** + * @brief Get the topic of a group + */ +QString Core::getGroupTopic(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_State_Query error; + const size_t length = tox_group_get_topic_size(tox.get(), groupNumber, &error); + if (!PARSE_ERR(error) || (length == 0u)) { + return QString{}; + } + + std::vector topicBuf(length); + tox_group_get_topic(tox.get(), groupNumber, topicBuf.data(), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(topicBuf.data(), length).getQString(); +} + /** * @brief Accept a conference invite. * @param inviteInfo Object which contains info about conference invitation diff --git a/src/core/core.h b/src/core/core.h index 1fa3374add..9da339b950 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -93,6 +93,7 @@ class Core : public QObject, QString getGroupPeerName(int groupNumber, int peerId) const override; ToxPk getGroupPeerPk(int groupNumber, int peerId) const override; QString getGroupTitle(int groupNumber) const override; + QString getGroupTopic(int groupNumber) const override; GroupRole getGroupPeerRole(int groupNumber, int peerId) const override; bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override; bool kickGroupPeer(int groupNumber, int peerId) override; diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h index b0ba15f4a5..6cec13a03f 100644 --- a/src/core/icoregroupquery.h +++ b/src/core/icoregroupquery.h @@ -55,6 +55,7 @@ class ICoreGroupQuery virtual QString getGroupPeerName(int groupNumber, int peerId) const = 0; virtual ToxPk getGroupPeerPk(int groupNumber, int peerId) const = 0; virtual QString getGroupTitle(int groupNumber) const = 0; + virtual QString getGroupTopic(int groupNumber) const = 0; virtual GroupRole getGroupPeerRole(int groupNumber, int peerId) const = 0; virtual bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) = 0; virtual bool kickGroupPeer(int groupNumber, int peerId) = 0; diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index ef93059e28..b69026e549 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -459,6 +459,7 @@ void ChatManager::onGroupTopicChanged(uint32_t groupNumber, const QString& topic assert(g); g->setTopic(QString(), topic); + settings.setGroupTopic(groupId.toString(), topic); } void ChatManager::onGroupSelfJoined(uint32_t groupNumber) @@ -487,6 +488,11 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) g->updateName(groupName); settings.setGroupName(groupId.toString(), groupName); } + const QString groupTopic = core->getGroupTopic(groupNumber); + if (!groupTopic.isEmpty()) { + g->setTopic(QString(), groupTopic); + settings.setGroupTopic(groupId.toString(), groupTopic); + } } } @@ -647,6 +653,11 @@ Group* ChatManager::createGroup(uint32_t groupNumber, const GroupId& groupId, co newGroup->setPasswordSet(core->getGroupHasPassword(groupNumber)); newGroup->setPeerLimit(core->getGroupPeerLimit(groupNumber)); + QString topic = core->getGroupTopic(groupNumber); + if (topic.isEmpty()) { + topic = settings.getGroupTopic(groupId.toString()); + } + newGroup->setTopic(QString(), topic); newGroup->setTopicLock(core->getGroupTopicLock(groupNumber)); newGroup->setVoiceState(core->getGroupVoiceState(groupNumber)); newGroup->setPrivacyState(core->getGroupPrivacyState(groupNumber)); diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index b3492a1cf4..f213d4758b 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -654,6 +654,12 @@ void Settings::loadPersonal(const Profile& profile, bool newProfile) for (auto it = names.constBegin(); it != names.constEnd(); ++it) { groupNames.insert(it.key(), it.value().toString()); } + const QJsonObject topics = + QJsonDocument::fromJson(ps.value("groupTopics").toString().toUtf8()).object(); + groupTopics.clear(); + for (auto it = topics.constBegin(); it != topics.constEnd(); ++it) { + groupTopics.insert(it.key(), it.value().toString()); + } }); inGroup(ps, "Friends", [this, &ps] { @@ -931,6 +937,12 @@ void Settings::savePersonal(QString profileName, const ToxEncrypt* passkey) } ps.setValue("groupNames", QString::fromUtf8(QJsonDocument(names).toJson(QJsonDocument::Compact))); + QJsonObject topics; + for (auto it = groupTopics.cbegin(); it != groupTopics.cend(); ++it) { + topics.insert(it.key(), it.value()); + } + ps.setValue("groupTopics", + QString::fromUtf8(QJsonDocument(topics).toJson(QJsonDocument::Compact))); }); inGroup(ps, "Version", [this, &ps] { // @@ -1924,6 +1936,7 @@ void Settings::removeSavedGroup(const QString& groupIdHex) const QMutexLocker locker{&bigLock}; savedGroups.removeAll(groupIdHex); groupNames.remove(groupIdHex); + groupTopics.remove(groupIdHex); } QString Settings::getGroupName(const QString& groupIdHex) const @@ -1942,6 +1955,22 @@ void Settings::setGroupName(const QString& groupIdHex, const QString& name) } } +QString Settings::getGroupTopic(const QString& groupIdHex) const +{ + const QMutexLocker locker{&bigLock}; + return groupTopics.value(groupIdHex); +} + +void Settings::setGroupTopic(const QString& groupIdHex, const QString& topic) +{ + const QMutexLocker locker{&bigLock}; + if (topic.isEmpty()) { + groupTopics.remove(groupIdHex); + } else { + groupTopics.insert(groupIdHex, topic); + } +} + QString Settings::getInDev() const { const QMutexLocker locker{&bigLock}; diff --git a/src/persistence/settings.h b/src/persistence/settings.h index eb981192e4..83a1705bce 100644 --- a/src/persistence/settings.h +++ b/src/persistence/settings.h @@ -490,6 +490,8 @@ public slots: void removeSavedGroup(const QString& groupIdHex); QString getGroupName(const QString& groupIdHex) const; void setGroupName(const QString& groupIdHex, const QString& name); + QString getGroupTopic(const QString& groupIdHex) const; + void setGroupTopic(const QString& groupIdHex, const QString& topic); // State QByteArray getWindowGeometry() const; @@ -706,6 +708,7 @@ private slots: // Groups QStringList savedGroups; QHash groupNames; + QHash groupTopics; // Audio QString inDev; From 1cc96745f70563c34c96e5e917116b96ccb60997 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 02:28:32 +0200 Subject: [PATCH 13/73] fix(groups): clean up group forms before ChatManager destruction --- src/widget/widget.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index d754781b7a..dd608762d6 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -651,6 +651,10 @@ Widget::~Widget() removeConference(c, true); } + for (Group* g : groupList->getAllGroups()) { + removeGroup(g, true); + } + for (Friend* f : friendList->getAllFriends()) { removeFriend(f, true); } From adae75862ee117d129d610931ccc7e2e356aafcf Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 03:21:14 +0200 Subject: [PATCH 14/73] fix(groups): persist groups in tox save --- src/core/core.cpp | 32 -------------------------------- src/core/core.h | 1 - src/core/toxoptions.cpp | 4 ++++ 3 files changed, 4 insertions(+), 33 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index 656446199b..721fea73aa 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -70,8 +70,6 @@ Core::Core(QThread* coreThread_, IBootstrapListGenerator& bootstrapListGenerator toxTimer->setSingleShot(true); connect(toxTimer, &QTimer::timeout, this, &Core::process); connect(coreThread_, &QThread::finished, toxTimer, &QTimer::stop); - connect(qApp, &QCoreApplication::aboutToQuit, this, &Core::leaveAllGroups, - Qt::DirectConnection); } Core::~Core() @@ -1985,36 +1983,6 @@ void Core::quitGroup(int groupNumber) } } -void Core::leaveAllGroups() -{ - const QMutexLocker ml{&coreLoopLock}; - - for (auto it = groupReconnectTimers.cbegin(); it != groupReconnectTimers.cend(); ++it) { - it.value()->deleteLater(); - } - groupReconnectTimers.clear(); - groupPeerCounts.clear(); - - if (numberToGroupId.isEmpty()) { - return; - } - - for (auto it = numberToGroupId.cbegin(); it != numberToGroupId.cend(); ++it) { - Tox_Err_Group_Leave error; - tox_group_leave(tox.get(), it.key(), nullptr, 0, &error); - if (!PARSE_ERR(error)) { - qWarning() << "Failed to leave group" << it.value().toString(); - } - } - numberToGroupId.clear(); - groupIdToNumber.clear(); - - // Let toxcore send out the leave packets before the Tox instance is torn down. - for (int i = 0; i < 10; ++i) { - tox_iterate(tox.get(), this); - } -} - bool Core::reconnectGroup(uint32_t groupNumber) { const QMutexLocker ml{&coreLoopLock}; diff --git a/src/core/core.h b/src/core/core.h index 9da339b950..4823da38cd 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -130,7 +130,6 @@ public slots: void groupInviteFriend(uint32_t friendId, int groupNumber); int createGroup(const QString& groupName); void quitGroup(int groupNumber); - void leaveAllGroups(); void changeGroupTopic(uint32_t groupNumber, const QString& topic); bool setGroupPassword(int groupNumber, const QByteArray& password) override; bool setGroupPeerLimit(int groupNumber, uint16_t peerLimit) override; diff --git a/src/core/toxoptions.cpp b/src/core/toxoptions.cpp index 4640f3eb94..432b505ad6 100644 --- a/src/core/toxoptions.cpp +++ b/src/core/toxoptions.cpp @@ -99,6 +99,10 @@ std::unique_ptr ToxOptions::makeToxOptions(const QByteArray& savedat tox_options_set_savedata_data(toxOptions->get(), reinterpret_cast(savedata.data()), savedata.size()); + // Groups must be persisted in the tox save, otherwise they are rejoined on + // every start and the founder role is lost. + tox_options_set_experimental_groups_persistence(toxOptions->get(), true); + // IPv6 needed for LAN discovery, but can crash some weird routers. On by default, can be // disabled in options. const bool enableIPv6 = s.getEnableIPv6(); From 5953af99a942ea6f4d7f8373d97dadf35418d683 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 03:34:25 +0200 Subject: [PATCH 15/73] feat(groups): highlight group button and dim icon when selected --- src/widget/widget.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index dd608762d6..43db73f0de 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -485,6 +485,11 @@ void Widget::init() ui->addButton->setCheckable(true); ui->conferenceButton->setCheckable(true); + ui->groupButton->setCheckable(true); + QIcon groupButtonIcon; + groupButtonIcon.addPixmap(QPixmap(":/img/group.svg"), QIcon::Normal); + groupButtonIcon.addPixmap(QPixmap(":/img/group_dark.svg"), QIcon::Disabled); + ui->groupButton->setIcon(groupButtonIcon); ui->transferButton->setCheckable(true); ui->settingsButton->setCheckable(true); ui->debugButton->setCheckable(true); From 3b37a2d1ab4ce131f1d60d7c01b2adb4186f35dc Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 03:34:25 +0200 Subject: [PATCH 16/73] feat(groups): translate group button labels --- translations/ru.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/translations/ru.ts b/translations/ru.ts index 5f848027af..bdc24fdc74 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1962,6 +1962,18 @@ Press Shift+F1 for more information. Open conference management page Открыть страницу управления конференцией + + Create a group + Создать группу + + + Group + Группа + + + Open group management page + Открыть страницу управления группой + File transfers history История передачи файлов From 92b8f0da28cbf2e9072e02e97bae602e4a1d162e Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 03:37:18 +0200 Subject: [PATCH 17/73] feat(groups): translate group peer context menu --- translations/ru.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/translations/ru.ts b/translations/ru.ts index bdc24fdc74..c403b48483 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1562,6 +1562,18 @@ instead of closing entirely. %n пользователей в чате + + mute + выключить звук + + + unmute + включить звук + + + copy peer ID + скопировать идентификатор узла + IdentitySettings From a69aa9b88671fc5cd92ef3fe468497464663ea9c Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 03:42:43 +0200 Subject: [PATCH 18/73] fix(groups): mark getSavedGroups as override --- src/persistence/settings.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/persistence/settings.h b/src/persistence/settings.h index 83a1705bce..07fad5b8f4 100644 --- a/src/persistence/settings.h +++ b/src/persistence/settings.h @@ -484,7 +484,7 @@ public slots: SIGNAL_IMPL(Settings, showConferenceJoinLeaveMessagesChanged, bool show) // Groups - QStringList getSavedGroups() const; + QStringList getSavedGroups() const override; void setSavedGroups(const QStringList& glist); void addSavedGroup(const QString& groupIdHex); void removeSavedGroup(const QString& groupIdHex); From 8900b544546be4bd527d2c907dd0a3d6cfa2c926 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 04:07:02 +0200 Subject: [PATCH 19/73] fix(groups): silence unused parameter warnings --- src/core/core.cpp | 1 + src/widget/form/groupform.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/core/core.cpp b/src/core/core.cpp index 721fea73aa..6bdaa67f3f 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -673,6 +673,7 @@ void Core::onGroupPassword(Tox* tox, uint32_t groupNumber, const uint8_t* passwo void* vCore) { std::ignore = tox; + std::ignore = password; auto* const core = static_cast(vCore); qDebug() << "Group" << groupNumber << "password changed, has password:" << (length != 0); emit core->groupPasswordChanged(groupNumber, length != 0); diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 1ade8a67cc..e9be3f2721 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -315,12 +315,14 @@ void GroupForm::dropEvent(QDropEvent* ev) void GroupForm::keyPressEvent(QKeyEvent* ev) { + std::ignore = ev; if (msgEdit->hasFocus()) return; } void GroupForm::keyReleaseEvent(QKeyEvent* ev) { + std::ignore = ev; if (msgEdit->hasFocus()) return; } From be1872baf1f7a0539ca5e84790d4f486ef8556bc Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 04:59:42 +0200 Subject: [PATCH 20/73] fix(groups): guard against double accept of the same group invite --- src/core/core.cpp | 18 +++++++++++++++++- src/model/chatmanager.cpp | 6 +++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index 6bdaa67f3f..ae5a806b95 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -654,6 +654,11 @@ void Core::onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail f qWarning() << "Group join failed for group" << groupNumber; core->stopGroupReconnectTimer(groupNumber); core->groupPeerCounts.remove(groupNumber); + const auto groupIdIt = core->numberToGroupId.find(groupNumber); + if (groupIdIt != core->numberToGroupId.end()) { + core->groupIdToNumber.remove(*groupIdIt); + core->numberToGroupId.erase(groupIdIt); + } emit core->groupJoinFailed(groupNumber); } @@ -1905,6 +1910,18 @@ uint32_t Core::joinGroup(const GroupInvite& inviteInfo) const QByteArray invite = inviteInfo.getInviteData(); const auto* const inviteData = reinterpret_cast(invite.constData()); const size_t inviteLength = invite.size(); + + if (inviteLength < TOX_GROUP_CHAT_ID_SIZE) { + qWarning() << "joinGroup: invite data too short"; + return std::numeric_limits::max(); + } + + const GroupId groupId(inviteData); + if (groupIdToNumber.contains(groupId)) { + qDebug() << "joinGroup: already in group" << groupId.toString(); + return groupIdToNumber[groupId]; + } + const ToxString cSelfName(getUsername()); qDebug() << "Trying to accept invite for group sent by friend" << friendId; @@ -1917,7 +1934,6 @@ uint32_t Core::joinGroup(const GroupInvite& inviteInfo) return std::numeric_limits::max(); } - const GroupId groupId = getGroupPersistentId(groupNumber); numberToGroupId[groupNumber] = groupId; groupIdToNumber[groupId] = groupNumber; startGroupReconnectTimer(groupNumber); diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index b69026e549..b7806ad2b2 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -525,9 +525,9 @@ void ChatManager::onGroupJoinFailed(uint32_t groupNumber) { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); - - removeGroup(groupId); + if (g != nullptr) { + removeGroup(groupId); + } } void ChatManager::onGroupPeerRolesChanged(uint32_t groupNumber) From e196573bc092c029e0721f2b9a58d4b5e8957017 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 05:16:19 +0200 Subject: [PATCH 21/73] fix(groups): create reconnect timers on the core thread --- src/core/core.cpp | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index ae5a806b95..b41ca78078 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -2033,25 +2033,29 @@ void Core::retryGroupReconnect(uint32_t groupNumber) void Core::startGroupReconnectTimer(uint32_t groupNumber) { - if (groupReconnectTimers.contains(groupNumber)) { - return; - } + QMetaObject::invokeMethod(this, [this, groupNumber] { + if (groupReconnectTimers.contains(groupNumber)) { + return; + } - auto* timer = new QTimer(this); - timer->setInterval(GROUP_RECONNECT_INTERVAL_MS); - connect(timer, &QTimer::timeout, this, - [this, groupNumber] { retryGroupReconnect(groupNumber); }); - groupReconnectTimers[groupNumber] = timer; - QMetaObject::invokeMethod(this, [timer] { timer->start(); }, Qt::QueuedConnection); + auto* timer = new QTimer(this); + timer->setInterval(GROUP_RECONNECT_INTERVAL_MS); + connect(timer, &QTimer::timeout, this, + [this, groupNumber] { retryGroupReconnect(groupNumber); }); + groupReconnectTimers[groupNumber] = timer; + timer->start(); + }); } void Core::stopGroupReconnectTimer(uint32_t groupNumber) { - auto it = groupReconnectTimers.find(groupNumber); - if (it != groupReconnectTimers.end()) { - it.value()->deleteLater(); - groupReconnectTimers.erase(it); - } + QMetaObject::invokeMethod(this, [this, groupNumber] { + auto it = groupReconnectTimers.find(groupNumber); + if (it != groupReconnectTimers.end()) { + it.value()->deleteLater(); + groupReconnectTimers.erase(it); + } + }); } /** From 1f77b9b0e52e091888b6e74d3266a152c7d8c322 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 05:22:37 +0200 Subject: [PATCH 22/73] fix(groups): remove group UI before model on join failure --- src/model/chatmanager.cpp | 4 +++- src/widget/widget.cpp | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index b7806ad2b2..d00b418851 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -526,7 +526,9 @@ void ChatManager::onGroupJoinFailed(uint32_t groupNumber) const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); if (g != nullptr) { - removeGroup(groupId); + // The UI must be torn down before the model, otherwise the GroupForm + // keeps a dangling reference to the chat log. + emit groupRemoved(groupId); } } diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 43db73f0de..5c670df3da 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -274,6 +274,8 @@ void Widget::init() connect(chatManager.get(), &ChatManager::conferenceAdded, this, &Widget::onConferenceModelAdded); connect(chatManager.get(), &ChatManager::conferenceNeedsName, this, &Widget::onConferenceNeedsName); connect(chatManager.get(), &ChatManager::groupAdded, this, &Widget::onGroupModelAdded); + connect(chatManager.get(), &ChatManager::groupRemoved, this, + [this](const GroupId& groupId) { removeGroup(groupList->findGroup(groupId), true); }); chatListWidget = new FriendListWidget(*core, this, settings, style, *messageBoxManager, *friendList, From 63f290df7475ead97190183a0e58de8bd67e1f7a Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 12:58:46 +0200 Subject: [PATCH 23/73] fix(groups): remove group from groupIdToNumber before erasing entry --- src/core/core.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index b41ca78078..e7ca4c67e7 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1989,9 +1989,8 @@ void Core::quitGroup(int groupNumber) if (PARSE_ERR(error)) { const auto groupIdIt = numberToGroupId.find(groupNumber); if (groupIdIt != numberToGroupId.end()) { - const GroupId& groupId = *groupIdIt; + groupIdToNumber.remove(*groupIdIt); numberToGroupId.erase(groupIdIt); - groupIdToNumber.remove(groupId); } stopGroupReconnectTimer(groupNumber); groupPeerCounts.remove(groupNumber); From 27021e431741e96f9a43b7d7c5c544f3c01f8021 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:00:45 +0200 Subject: [PATCH 24/73] fix(widget): free groupInviteForm when main window is destroyed --- src/widget/widget.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 5c670df3da..d62cfff73d 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -674,6 +674,7 @@ Widget::~Widget() delete profileInfo; delete addFriendForm; delete conferenceInviteForm; + delete groupInviteForm; delete filesForm; delete timer; delete contentLayout; From f6db7530d4b4f4659bbd6a308e91e0289ac6a4c5 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:01:57 +0200 Subject: [PATCH 25/73] fix(groupform): free topic context menu after it hides --- src/widget/form/groupform.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index e9be3f2721..b316e2e905 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -438,6 +438,8 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) auto* const contextMenu = new QMenu(this); contextMenu->setStyleSheet(style.getStylesheet("chatArea/chatHead.qss", settings)); + connect(contextMenu, &QMenu::aboutToHide, contextMenu, &QObject::deleteLater); + auto* copyTopicAction = contextMenu->addAction(tr("Copy topic")); auto* copyIdAction = contextMenu->addAction(tr("Copy group ID")); const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); From 317d3ca886b11eaa79bfd796c35f5f9a8884837c Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:02:41 +0200 Subject: [PATCH 26/73] style(groupform): wrap single-line if bodies in braces --- src/widget/form/groupform.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index b316e2e905..9abb9e1a37 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -292,8 +292,9 @@ void GroupForm::dragEnterEvent(QDragEnterEvent* ev) } const ToxPk toxPk{ev->mimeData()->data("toxPk")}; Friend* frnd = friendList.findFriend(toxPk); - if (frnd != nullptr) + if (frnd != nullptr) { ev->acceptProposedAction(); + } } void GroupForm::dropEvent(QDropEvent* ev) @@ -303,8 +304,9 @@ void GroupForm::dropEvent(QDropEvent* ev) } const ToxPk toxPk{ev->mimeData()->data("toxPk")}; Friend* frnd = friendList.findFriend(toxPk); - if (frnd == nullptr) + if (frnd == nullptr) { return; + } const uint32_t friendId = frnd->getId(); const uint32_t groupNumber = group->getId(); @@ -316,15 +318,17 @@ void GroupForm::dropEvent(QDropEvent* ev) void GroupForm::keyPressEvent(QKeyEvent* ev) { std::ignore = ev; - if (msgEdit->hasFocus()) + if (msgEdit->hasFocus()) { return; + } } void GroupForm::keyReleaseEvent(QKeyEvent* ev) { std::ignore = ev; - if (msgEdit->hasFocus()) + if (msgEdit->hasFocus()) { return; + } } /** From b24c9039d4b5192fe21a5b99a4e4e3a014c905b3 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:04:36 +0200 Subject: [PATCH 27/73] fix(chatmanager): store group name/topic under persistent id --- src/model/chatmanager.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index d00b418851..d62cb5e6ad 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -486,12 +486,12 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) const QString groupName = core->getGroupTitle(groupNumber); if (!groupName.isEmpty()) { g->updateName(groupName); - settings.setGroupName(groupId.toString(), groupName); + settings.setGroupName(g->getPersistentId().toString(), groupName); } const QString groupTopic = core->getGroupTopic(groupNumber); if (!groupTopic.isEmpty()) { g->setTopic(QString(), groupTopic); - settings.setGroupTopic(groupId.toString(), groupTopic); + settings.setGroupTopic(g->getPersistentId().toString(), groupTopic); } } } From 373d579a14699df813e1903a57f5919e27f076f4 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:05:22 +0200 Subject: [PATCH 28/73] fix(widget): guard group invite handling against unknown sender --- src/widget/widget.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index d62cfff73d..fb090036ea 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2111,21 +2111,21 @@ void Widget::onGroupInviteReceived(const GroupInvite& inviteInfo) const Friend* f = friendList->findFriend(friendPk); if (f != nullptr) { updateFriendActivity(*f); - } - if (settings.getAutoGroupInvite(f->getPublicKey())) { - onGroupInviteAccepted(inviteInfo); - } else { - if (!groupInviteForm->addGroupInvite(inviteInfo)) { - return; - } + if (settings.getAutoGroupInvite(f->getPublicKey())) { + onGroupInviteAccepted(inviteInfo); + } else { + if (!groupInviteForm->addGroupInvite(inviteInfo)) { + return; + } - ++unreadGroupInvites; - groupInvitesUpdate(); - newMessageAlert(window(), isActiveWindow(), true, true); - if (notifier != nullptr) { - auto notificationData = notificationGenerator->groupInvitationNotification(f); - notifier->notifyMessage(notificationData); + ++unreadGroupInvites; + groupInvitesUpdate(); + newMessageAlert(window(), isActiveWindow(), true, true); + if (notifier != nullptr) { + auto notificationData = notificationGenerator->groupInvitationNotification(f); + notifier->notifyMessage(notificationData); + } } } } From d4a578816a540628e336318ed3e7ae2ad936242f Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:08:52 +0200 Subject: [PATCH 29/73] refactor(core): log group join/leave events at debug level --- src/core/core.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index e7ca4c67e7..8b4f479320 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -581,7 +581,7 @@ void Core::onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void { std::ignore = tox; auto* const core = static_cast(vCore); - qWarning("Group %u peer %u joined", groupNumber, peerId); + qDebug("Group %u peer %u joined", groupNumber, peerId); ++core->groupPeerCounts[groupNumber]; core->stopGroupReconnectTimer(groupNumber); emit core->groupPeerJoined(groupNumber, peerId); @@ -597,7 +597,7 @@ void Core::onGroupPeerExit(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_ std::ignore = partMessage; std::ignore = partMessageLength; auto* const core = static_cast(vCore); - qWarning("Group %u peer %u left, exit type %d", groupNumber, peerId, static_cast(exitType)); + qDebug("Group %u peer %u left, exit type %d", groupNumber, peerId, static_cast(exitType)); auto it = core->groupPeerCounts.find(groupNumber); if (it != core->groupPeerCounts.end() && it.value() > 0) { if (it.value() == 1) { @@ -624,7 +624,7 @@ void Core::onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore) { std::ignore = tox; auto* const core = static_cast(vCore); - qWarning("Joined group %u", groupNumber); + qDebug("Joined group %u", groupNumber); const GroupId groupId = core->getGroupPersistentId(groupNumber); if (!groupId.isEmpty()) { core->numberToGroupId[groupNumber] = groupId; @@ -670,7 +670,7 @@ void Core::onGroupModeration(Tox* tox, uint32_t groupNumber, uint32_t sourcePeer std::ignore = targetPeerId; std::ignore = modType; auto* const core = static_cast(vCore); - qWarning() << "Group" << groupNumber << "moderation event, refreshing peer roles"; + qDebug() << "Group" << groupNumber << "moderation event, refreshing peer roles"; emit core->groupPeerRolesChanged(groupNumber); } From 79a1e54d61745aa7d8390d351464af7b38994ea9 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:10:42 +0200 Subject: [PATCH 30/73] fix(widget): defer group removal on middle click --- src/widget/widget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index fb090036ea..f3183d78a5 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2332,7 +2332,7 @@ void Widget::onGroupModelAdded(Group* newGroup, std::shared_ptr chatr auto widgetRemoveGroup = QOverload::of(&Widget::removeGroup); connect(widget, &GroupWidget::removeGroup, this, widgetRemoveGroup); connect(widget, &GroupWidget::middleMouseClicked, this, - [this, groupId]() { removeGroup(groupId); }); + [this, groupId]() { removeGroup(groupId); }, Qt::QueuedConnection); connect(widget, &GroupWidget::chatroomWidgetClicked, form, &GenericChatForm::focusInput); connect(newGroup, &Group::titleChanged, this, [this, groupId](const QString& /* author */, const QString& title) { From e72639a992caf8dc0c8697196c4121443a9d4eff Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:29:24 +0200 Subject: [PATCH 31/73] fix(groupinvite): suppress duplicate invites by friend and data --- src/widget/form/groupinviteform.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/widget/form/groupinviteform.cpp b/src/widget/form/groupinviteform.cpp index 8df6701f4e..ab99b080dd 100644 --- a/src/widget/form/groupinviteform.cpp +++ b/src/widget/form/groupinviteform.cpp @@ -131,7 +131,9 @@ bool GroupInviteForm::addGroupInvite(const GroupInvite& inviteInfo) { // supress duplicate invite messages for (GroupInviteWidget* existing : invites) { - if (existing->getInviteInfo() == inviteInfo) { + const GroupInvite& existingInvite = existing->getInviteInfo(); + if (existingInvite.getFriendId() == inviteInfo.getFriendId() + && existingInvite.getInviteData() == inviteInfo.getInviteData()) { return false; } } From 19f765f552a91e68608f3f4bf6bd44a8ae5282a8 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:30:39 +0200 Subject: [PATCH 32/73] fix(widget): show failed group message sends --- src/widget/widget.cpp | 14 ++++++++++++++ src/widget/widget.h | 1 + 2 files changed, 15 insertions(+) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index f3183d78a5..ca966be2c7 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -768,6 +768,7 @@ void Widget::onCoreChanged(Core& core_) connect(core, &Core::friendTypingChanged, this, &Widget::onFriendTypingChanged); connect(core, &Core::conferenceSentFailed, this, &Widget::onConferenceSendFailed); connect(core, &Core::groupInviteReceived, this, &Widget::onGroupInviteReceived); + connect(core, &Core::groupSentFailed, this, &Widget::onGroupSendFailed); connect(core, &Core::usernameSet, this, &Widget::refreshPeerListsLocal); connect(this, &Widget::statusSet, core, &Core::setStatus); @@ -2568,6 +2569,19 @@ void Widget::onConferenceSendFailed(uint32_t conferencenumber) form->addSystemInfoMessage(curTime, SystemMessageType::messageSendFailed, {}); } +void Widget::onGroupSendFailed(uint32_t groupNumber) +{ + const GroupId& groupId = groupList->id2Key(groupNumber); + auto groupFormIt = groupForms.find(groupId); + if (groupFormIt == groupForms.end()) { + return; + } + + const auto curTime = QDateTime::currentDateTime(); + auto* form = groupFormIt.value().data(); + form->addSystemInfoMessage(curTime, SystemMessageType::messageSendFailed, {}); +} + void Widget::onFriendTypingChanged(uint32_t friendNumber, bool isTyping) { const auto& friendId = friendList->id2Key(friendNumber); diff --git a/src/widget/widget.h b/src/widget/widget.h index 2287af1c97..8371c5cf42 100644 --- a/src/widget/widget.h +++ b/src/widget/widget.h @@ -192,6 +192,7 @@ public slots: void titleChangedByUser(const QString& title); void onConferencePeerAudioPlaying(uint32_t conferencenumber, ToxPk peerPk); void onConferenceSendFailed(uint32_t conferencenumber); + void onGroupSendFailed(uint32_t groupNumber); void onFriendTypingChanged(uint32_t friendNumber, bool isTyping); void nextChat(); void previousChat(); From c09772e57d188e32b64d9d4a8d9e7689d2991d24 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:31:56 +0200 Subject: [PATCH 33/73] fix(settings): save group data on mutation --- src/persistence/settings.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index f213d4758b..3d4330c853 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -1921,6 +1921,7 @@ void Settings::setSavedGroups(const QStringList& glist) { const QMutexLocker locker{&bigLock}; savedGroups = glist; + requestSave(); } void Settings::addSavedGroup(const QString& groupIdHex) @@ -1928,6 +1929,7 @@ void Settings::addSavedGroup(const QString& groupIdHex) const QMutexLocker locker{&bigLock}; if (!savedGroups.contains(groupIdHex)) { savedGroups.append(groupIdHex); + requestSave(); } } @@ -1937,6 +1939,7 @@ void Settings::removeSavedGroup(const QString& groupIdHex) savedGroups.removeAll(groupIdHex); groupNames.remove(groupIdHex); groupTopics.remove(groupIdHex); + requestSave(); } QString Settings::getGroupName(const QString& groupIdHex) const @@ -1953,6 +1956,7 @@ void Settings::setGroupName(const QString& groupIdHex, const QString& name) } else { groupNames.insert(groupIdHex, name); } + requestSave(); } QString Settings::getGroupTopic(const QString& groupIdHex) const @@ -1969,6 +1973,7 @@ void Settings::setGroupTopic(const QString& groupIdHex, const QString& topic) } else { groupTopics.insert(groupIdHex, topic); } + requestSave(); } QString Settings::getInDev() const From 92db34773ebd98ff17b08157feb5e77b50edaf54 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:39:15 +0200 Subject: [PATCH 34/73] fix(friendlist): filter out group entries --- src/model/friendlist/friendlistmanager.cpp | 2 +- src/model/friendlist/ifriendlistitem.h | 1 + src/widget/conferencewidget.cpp | 5 +++++ src/widget/conferencewidget.h | 1 + src/widget/friendwidget.cpp | 5 +++++ src/widget/friendwidget.h | 1 + src/widget/groupwidget.cpp | 5 +++++ src/widget/groupwidget.h | 1 + test/model/friendlistmanager_test.cpp | 8 ++++++++ 9 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/model/friendlist/friendlistmanager.cpp b/src/model/friendlist/friendlistmanager.cpp index 6db31c3f0f..cce32c4886 100644 --- a/src/model/friendlist/friendlistmanager.cpp +++ b/src/model/friendlist/friendlistmanager.cpp @@ -112,7 +112,7 @@ void FriendListManager::applyFilter() itemTmp->setWidgetVisible(false); } - if (filterParams.hideConferences && itemTmp->isConference()) { + if (filterParams.hideConferences && (itemTmp->isConference() || itemTmp->isGroup())) { itemTmp->setWidgetVisible(false); } } diff --git a/src/model/friendlist/ifriendlistitem.h b/src/model/friendlist/ifriendlistitem.h index d06da15a81..c364356410 100644 --- a/src/model/friendlist/ifriendlistitem.h +++ b/src/model/friendlist/ifriendlistitem.h @@ -21,6 +21,7 @@ class IFriendListItem virtual bool isFriend() const = 0; virtual bool isConference() const = 0; + virtual bool isGroup() const = 0; virtual bool isOnline() const = 0; virtual void startCall() = 0; virtual void stopCall() = 0; diff --git a/src/widget/conferencewidget.cpp b/src/widget/conferencewidget.cpp index b66be9ebe3..672a67bd92 100644 --- a/src/widget/conferencewidget.cpp +++ b/src/widget/conferencewidget.cpp @@ -210,6 +210,11 @@ bool ConferenceWidget::isConference() const return true; } +bool ConferenceWidget::isGroup() const +{ + return false; +} + QString ConferenceWidget::getNameItem() const { return nameLabel->fullText(); diff --git a/src/widget/conferencewidget.h b/src/widget/conferencewidget.h index a5cacfb87f..0e9ddd3073 100644 --- a/src/widget/conferencewidget.h +++ b/src/widget/conferencewidget.h @@ -35,6 +35,7 @@ class ConferenceWidget final : public GenericChatroomWidget, public IFriendListI bool isFriend() const final; bool isConference() const final; + bool isGroup() const final; QString getNameItem() const final; bool isOnline() const final; void startCall() final; diff --git a/src/widget/friendwidget.cpp b/src/widget/friendwidget.cpp index 9e5bed04b9..2520e71c9e 100644 --- a/src/widget/friendwidget.cpp +++ b/src/widget/friendwidget.cpp @@ -392,6 +392,11 @@ bool FriendWidget::isConference() const return false; } +bool FriendWidget::isGroup() const +{ + return false; +} + bool FriendWidget::isOnline() const { const auto* const frnd = getFriend(); diff --git a/src/widget/friendwidget.h b/src/widget/friendwidget.h index 65b7bfffd6..35099659da 100644 --- a/src/widget/friendwidget.h +++ b/src/widget/friendwidget.h @@ -40,6 +40,7 @@ class FriendWidget : public GenericChatroomWidget, public IFriendListItem bool isFriend() const final; bool isConference() const final; + bool isGroup() const final; bool isOnline() const final; void startCall() final; void stopCall() final; diff --git a/src/widget/groupwidget.cpp b/src/widget/groupwidget.cpp index de67fa738d..8542d2919c 100644 --- a/src/widget/groupwidget.cpp +++ b/src/widget/groupwidget.cpp @@ -198,6 +198,11 @@ bool GroupWidget::isConference() const return false; } +bool GroupWidget::isGroup() const +{ + return true; +} + QString GroupWidget::getNameItem() const { return nameLabel->fullText(); diff --git a/src/widget/groupwidget.h b/src/widget/groupwidget.h index 95d141fc49..c0095df417 100644 --- a/src/widget/groupwidget.h +++ b/src/widget/groupwidget.h @@ -34,6 +34,7 @@ class GroupWidget final : public GenericChatroomWidget, public IFriendListItem bool isFriend() const final; bool isConference() const final; + bool isGroup() const final; QString getNameItem() const final; bool isOnline() const final; void startCall() final; diff --git a/test/model/friendlistmanager_test.cpp b/test/model/friendlistmanager_test.cpp index d5b22c09cd..52a36a78de 100644 --- a/test/model/friendlistmanager_test.cpp +++ b/test/model/friendlistmanager_test.cpp @@ -38,6 +38,10 @@ class MockFriend : public IFriendListItem { return false; } + bool isGroup() const override + { + return false; + } bool isOnline() const override { return online; @@ -99,6 +103,10 @@ class MockConference : public IFriendListItem { return true; } + bool isGroup() const override + { + return false; + } bool isOnline() const override { return true; From ea743b3e2c71c83318d84c723b55dab646bb28fb Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:56:03 +0200 Subject: [PATCH 35/73] feat(widget): add group filter --- src/model/friendlist/friendlistmanager.cpp | 12 ++- src/model/friendlist/friendlistmanager.h | 3 +- src/widget/friendlistwidget.cpp | 4 +- src/widget/friendlistwidget.h | 3 +- src/widget/widget.cpp | 30 ++++++- src/widget/widget.h | 5 +- test/model/friendlistmanager_test.cpp | 96 +++++++++++++++++++--- translations/ru.ts | 4 + 8 files changed, 135 insertions(+), 22 deletions(-) diff --git a/src/model/friendlist/friendlistmanager.cpp b/src/model/friendlist/friendlistmanager.cpp index cce32c4886..da17ab7212 100644 --- a/src/model/friendlist/friendlistmanager.cpp +++ b/src/model/friendlist/friendlistmanager.cpp @@ -76,16 +76,18 @@ void FriendListManager::resetParents() } void FriendListManager::setFilter(const QString& searchString, bool hideOnline, bool hideOffline, - bool hideConferences) + bool hideConferences, bool hideGroups) { if (filterParams.searchString == searchString && filterParams.hideOnline == hideOnline - && filterParams.hideOffline == hideOffline && filterParams.hideConferences == hideConferences) { + && filterParams.hideOffline == hideOffline && filterParams.hideConferences == hideConferences + && filterParams.hideGroups == hideGroups) { return; } filterParams.searchString = searchString; filterParams.hideOnline = hideOnline; filterParams.hideOffline = hideOffline; filterParams.hideConferences = hideConferences; + filterParams.hideGroups = hideGroups; setSortRequired(); } @@ -112,7 +114,11 @@ void FriendListManager::applyFilter() itemTmp->setWidgetVisible(false); } - if (filterParams.hideConferences && (itemTmp->isConference() || itemTmp->isGroup())) { + if (filterParams.hideConferences && itemTmp->isConference()) { + itemTmp->setWidgetVisible(false); + } + + if (filterParams.hideGroups && itemTmp->isGroup()) { itemTmp->setWidgetVisible(false); } } diff --git a/src/model/friendlist/friendlistmanager.h b/src/model/friendlist/friendlistmanager.h index 00a8134e00..2ecf7d9ff2 100644 --- a/src/model/friendlist/friendlistmanager.h +++ b/src/model/friendlist/friendlistmanager.h @@ -32,7 +32,7 @@ class FriendListManager : public QObject void sortByActivity(); void resetParents(); void setFilter(const QString& searchString, bool hideOnline, bool hideOffline, - bool hideConferences); + bool hideConferences, bool hideGroups); void applyFilter(); void updatePositions(); void setSortRequired(); @@ -49,6 +49,7 @@ class FriendListManager : public QObject bool hideOnline = false; bool hideOffline = false; bool hideConferences = false; + bool hideGroups = false; } filterParams; void removeAll(IFriendListItem* item); diff --git a/src/widget/friendlistwidget.cpp b/src/widget/friendlistwidget.cpp index 8c673d2859..70e5e4e320 100644 --- a/src/widget/friendlistwidget.cpp +++ b/src/widget/friendlistwidget.cpp @@ -428,9 +428,9 @@ void FriendListWidget::removeCircleWidget(CircleWidget* widget) } void FriendListWidget::searchChatRooms(const QString& searchString, bool hideOnline, - bool hideOffline, bool hideConferences) + bool hideOffline, bool hideConferences, bool hideGroups) { - manager->setFilter(searchString, hideOnline, hideOffline, hideConferences); + manager->setFilter(searchString, hideOnline, hideOffline, hideConferences, hideGroups); } void FriendListWidget::renameConferenceWidget(ConferenceWidget* conferenceWidget, const QString& newName) diff --git a/src/widget/friendlistwidget.h b/src/widget/friendlistwidget.h index 5ce7d1ab33..d1c5ea10fe 100644 --- a/src/widget/friendlistwidget.h +++ b/src/widget/friendlistwidget.h @@ -57,7 +57,8 @@ class FriendListWidget : public QWidget void addCircleWidget(FriendWidget* widget = nullptr); static void removeCircleWidget(CircleWidget* widget); void searchChatRooms(const QString& searchString, bool hideOnline = false, - bool hideOffline = false, bool hideConferences = false); + bool hideOffline = false, bool hideConferences = false, + bool hideGroups = false); void cycleChats(GenericChatroomWidget* activeChatroomWidget, bool forward); diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index ca966be2c7..91d1b606d0 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -259,6 +259,10 @@ void Widget::init() filterFriendsAction->setCheckable(true); filterGroup->addAction(filterFriendsAction); filterMenu->addAction(filterFriendsAction); + filterConferencesAction = new QAction(this); + filterConferencesAction->setCheckable(true); + filterGroup->addAction(filterConferencesAction); + filterMenu->addAction(filterConferencesAction); filterGroupsAction = new QAction(this); filterGroupsAction->setCheckable(true); filterGroup->addAction(filterGroupsAction); @@ -2630,11 +2634,24 @@ void Widget::cycleChats(bool forward) chatListWidget->cycleChats(activeChatroomWidget, forward); } +bool Widget::filterConferences(FilterCriteria index) +{ + switch (index) { + case FilterCriteria::Offline: + case FilterCriteria::Friends: + case FilterCriteria::Conferences: + return true; + default: + return false; + } +} + bool Widget::filterGroups(FilterCriteria index) { switch (index) { case FilterCriteria::Offline: case FilterCriteria::Friends: + case FilterCriteria::Groups: return true; default: return false; @@ -2646,6 +2663,7 @@ bool Widget::filterOffline(FilterCriteria index) switch (index) { case FilterCriteria::Online: case FilterCriteria::Conferences: + case FilterCriteria::Groups: return true; default: return false; @@ -2657,6 +2675,7 @@ bool Widget::filterOnline(FilterCriteria index) switch (index) { case FilterCriteria::Offline: case FilterCriteria::Conferences: + case FilterCriteria::Groups: return true; default: return false; @@ -2739,7 +2758,7 @@ void Widget::searchChats() const FilterCriteria filter = getFilterCriteria(); chatListWidget->searchChatRooms(searchString, filterOnline(filter), filterOffline(filter), - filterGroups(filter)); + filterConferences(filter), filterGroups(filter)); updateFilterText(); } @@ -2778,8 +2797,10 @@ Widget::FilterCriteria Widget::getFilterCriteria() const return FilterCriteria::Offline; if (checked == filterFriendsAction) return FilterCriteria::Friends; - if (checked == filterGroupsAction) + if (checked == filterConferencesAction) return FilterCriteria::Conferences; + if (checked == filterGroupsAction) + return FilterCriteria::Groups; return FilterCriteria::All; } @@ -2796,7 +2817,7 @@ void Widget::searchCircle(CircleWidget& circleWidget) bool Widget::conferencesVisible() const { const FilterCriteria filter = getFilterCriteria(); - return !filterGroups(filter); + return !filterConferences(filter); } void Widget::friendListContextMenu(const QPoint& pos) @@ -2912,7 +2933,8 @@ void Widget::retranslateUi() filterOnlineAction->setText(tr("Online")); filterOfflineAction->setText(tr("Offline")); filterFriendsAction->setText(tr("Friends")); - filterGroupsAction->setText(tr("Conferences")); + filterConferencesAction->setText(tr("Conferences")); + filterGroupsAction->setText(tr("Groups")); ui->searchContactText->setPlaceholderText(tr("Search Contacts")); updateFilterText(); diff --git a/src/widget/widget.h b/src/widget/widget.h index 8371c5cf42..405db10d12 100644 --- a/src/widget/widget.h +++ b/src/widget/widget.h @@ -122,7 +122,8 @@ class Widget final : public QMainWindow Online, Offline, Friends, - Conferences + Conferences, + Groups }; public: @@ -295,6 +296,7 @@ private slots: void changeDisplayMode(); void updateFilterText(); FilterCriteria getFilterCriteria() const; + static bool filterConferences(FilterCriteria index); static bool filterGroups(FilterCriteria index); static bool filterOnline(FilterCriteria index); static bool filterOffline(FilterCriteria index); @@ -324,6 +326,7 @@ private slots: QAction* filterOnlineAction; QAction* filterOfflineAction; QAction* filterFriendsAction; + QAction* filterConferencesAction; QAction* filterGroupsAction; QActionGroup* filterDisplayGroup; diff --git a/test/model/friendlistmanager_test.cpp b/test/model/friendlistmanager_test.cpp index 52a36a78de..e3bde8de7a 100644 --- a/test/model/friendlistmanager_test.cpp +++ b/test/model/friendlistmanager_test.cpp @@ -143,6 +143,64 @@ class MockConference : public IFriendListItem MockConference::~MockConference() = default; +class MockGroup : public IFriendListItem +{ +public: + explicit MockGroup(QString nameStr) + : name(std::move(nameStr)) + { + } + + ~MockGroup() override; + + bool isFriend() const override + { + return false; + } + bool isConference() const override + { + return false; + } + bool isGroup() const override + { + return true; + } + bool isOnline() const override + { + return true; + } + void startCall() override {} + void stopCall() override {} + bool widgetIsVisible() const override + { + return visible; + } + + QString getNameItem() const override + { + return name; + } + QDateTime getLastActivity() const override + { + return QDateTime::currentDateTime(); + } + QWidget* getWidget() override + { + return nullptr; + } + + void setWidgetVisible(bool v) override + { + visible = v; + } + +private: + QString name; + bool visible = true; +}; + +MockGroup::~MockGroup() = default; + class FriendItemsBuilder { public: @@ -459,12 +517,12 @@ void TestFriendListManager::testSetFilter() listBuilder.addOfflineFriends()->addOnlineFriends()->addConferences()->buildUnsorted()); const QSignalSpy spy(manager.get(), &FriendListManager::itemsChanged); - manager->setFilter("", false, false, false); + manager->setFilter("", false, false, false, false); QCOMPARE(spy.count(), 0); - manager->setFilter("Test", true, false, false); - manager->setFilter("Test", true, false, false); + manager->setFilter("Test", true, false, false, false); + manager->setFilter("Test", true, false, false, false); QCOMPARE(spy.count(), 1); } @@ -478,7 +536,7 @@ void TestFriendListManager::testApplyFilterSearchString() const QString testNameA = "NO_ITEMS_WITH_THIS_NAME"; const QString testNameB = "Test Name B"; manager->sortByName(); - manager->setFilter(testNameA, false, false, false); + manager->setFilter(testNameA, false, false, false, false); manager->applyFilter(); resultVec = manager->getItems(); @@ -507,7 +565,7 @@ void TestFriendListManager::testApplyFilterSearchString() } } - manager->setFilter("", false, false, false); + manager->setFilter("", false, false, false, false); manager->applyFilter(); resultVec = manager->getItems(); @@ -525,8 +583,10 @@ void TestFriendListManager::testApplyFilterByStatus() auto offlineItems = listBuilder.addOfflineFriends()->buildSortedByName(); auto conferenceItems = listBuilder.addConferences()->buildSortedByName(); manager->sortByName(); + manager->addFriendListItem(new MockGroup("test group")); - manager->setFilter("", true /*hideOnline*/, false /*hideOffline*/, false /*hideConferences*/); + manager->setFilter("", true /*hideOnline*/, false /*hideOffline*/, false /*hideConferences*/, + false /*hideGroups*/); manager->applyFilter(); for (auto item : manager->getItems()) { @@ -537,7 +597,8 @@ void TestFriendListManager::testApplyFilterByStatus() } } - manager->setFilter("", false /*hideOnline*/, true /*hideOffline*/, false /*hideConferences*/); + manager->setFilter("", false /*hideOnline*/, true /*hideOffline*/, false /*hideConferences*/, + false /*hideGroups*/); manager->applyFilter(); for (auto item : manager->getItems()) { @@ -548,7 +609,8 @@ void TestFriendListManager::testApplyFilterByStatus() } } - manager->setFilter("", false /*hideOnline*/, false /*hideOffline*/, true /*hideConferences*/); + manager->setFilter("", false /*hideOnline*/, false /*hideOffline*/, true /*hideConferences*/, + false /*hideGroups*/); manager->applyFilter(); for (auto item : manager->getItems()) { @@ -559,14 +621,28 @@ void TestFriendListManager::testApplyFilterByStatus() } } - manager->setFilter("", true /*hideOnline*/, true /*hideOffline*/, true /*hideConferences*/); + manager->setFilter("", false /*hideOnline*/, false /*hideOffline*/, false /*hideConferences*/, + true /*hideGroups*/); + manager->applyFilter(); + + for (auto item : manager->getItems()) { + if (item->isGroup()) { + QCOMPARE(item->widgetIsVisible(), false); + } else { + QCOMPARE(item->widgetIsVisible(), true); + } + } + + manager->setFilter("", true /*hideOnline*/, true /*hideOffline*/, true /*hideConferences*/, + true /*hideGroups*/); manager->applyFilter(); for (auto item : manager->getItems()) { QCOMPARE(item->widgetIsVisible(), false); } - manager->setFilter("", false /*hideOnline*/, false /*hideOffline*/, false /*hideConferences*/); + manager->setFilter("", false /*hideOnline*/, false /*hideOffline*/, false /*hideConferences*/, + false /*hideGroups*/); manager->applyFilter(); for (auto item : manager->getItems()) { diff --git a/translations/ru.ts b/translations/ru.ts index c403b48483..5101e8be77 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -3301,6 +3301,10 @@ number here may cause the scroll bar to disappear. Conferences Конференции + + Groups + Группы + Search Contacts Поиск контактов From 6fffb358ea790b3a23b07931b1d8e895135a0e28 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 13:58:14 +0200 Subject: [PATCH 36/73] fix(widget): swap conference and group filter logic --- src/widget/widget.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 91d1b606d0..acf122ce64 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2639,7 +2639,7 @@ bool Widget::filterConferences(FilterCriteria index) switch (index) { case FilterCriteria::Offline: case FilterCriteria::Friends: - case FilterCriteria::Conferences: + case FilterCriteria::Groups: return true; default: return false; @@ -2651,7 +2651,7 @@ bool Widget::filterGroups(FilterCriteria index) switch (index) { case FilterCriteria::Offline: case FilterCriteria::Friends: - case FilterCriteria::Groups: + case FilterCriteria::Conferences: return true; default: return false; From 87d12c9e19992242105cbecd9f8c331cc13dfbc7 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 14:04:05 +0200 Subject: [PATCH 37/73] fix(groups): keep user-set group alias across reconnects --- src/model/chatmanager.cpp | 12 ++++++++---- src/model/group.cpp | 1 + src/model/group.h | 1 + src/widget/widget.cpp | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index d62cb5e6ad..bf5a3fa583 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -483,10 +483,14 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) updateGroupNumber(g, groupNumber); addSelfToGroup(g); g->updatePeerRoles(); - const QString groupName = core->getGroupTitle(groupNumber); - if (!groupName.isEmpty()) { - g->updateName(groupName); - settings.setGroupName(g->getPersistentId().toString(), groupName); + const QString alias = settings.getGroupName(g->getPersistentId().toString()); + if (alias.isEmpty()) { + const QString groupName = core->getGroupTitle(groupNumber); + if (!groupName.isEmpty()) { + g->updateName(groupName); + } + } else if (alias != g->getName()) { + g->updateName(alias); } const QString groupTopic = core->getGroupTopic(groupNumber); if (!groupTopic.isEmpty()) { diff --git a/src/model/group.cpp b/src/model/group.cpp index f14bb1c54d..249258f982 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -34,6 +34,7 @@ void Group::setName(const QString& newTitle) if (!shortTitle.isEmpty() && groupName != shortTitle) { groupName = shortTitle; emit displayedNameChanged(groupName); + emit titleChangedByUser(groupName); emit titleChanged(selfName, groupName); } } diff --git a/src/model/group.h b/src/model/group.h index 3df6d2c766..ea13a91a9a 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -79,6 +79,7 @@ class Group : public Chat signals: void titleChanged(const QString& author, const QString& title); + void titleChangedByUser(const QString& title); void topicChanged(const QString& author, const QString& topic); void userJoined(const ToxPk& user, const QString& name); void userLeft(const ToxPk& user, const QString& name); diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index acf122ce64..7754f274a5 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2347,6 +2347,8 @@ void Widget::onGroupModelAdded(Group* newGroup, std::shared_ptr chatr } chatListWidget->itemsChanged(); }); + connect(newGroup, &Group::titleChangedByUser, this, + [this, groupId](const QString& title) { settings.setGroupName(groupId.toString(), title); }); } void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptr chatroom, From 18c8d0230a75dce798fd62c5d760a37c6721e1fe Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 14:16:10 +0200 Subject: [PATCH 38/73] fix(groupform): don't HTML-escape peer names shown as plain text --- src/widget/form/groupform.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 9abb9e1a37..4668046669 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -213,13 +213,15 @@ void GroupForm::updateUserNames() const QString peerName = peers.value(peerPk); const QString editedName = editName(peerName); const QString icon = roleIcon(group->getPeerRole(peerPk)); - auto* const label = new QLabel(icon + editedName.toHtmlEscaped() + QLatin1String(", ")); - label->setProperty("peerSortName", editedName.toLower()); + QLabel* label; if (icon.isEmpty()) { + label = new QLabel(editedName + QLatin1String(", ")); label->setTextFormat(Qt::PlainText); } else { + label = new QLabel(icon + editedName.toHtmlEscaped() + QLatin1String(", ")); label->setTextFormat(Qt::RichText); } + label->setProperty("peerSortName", editedName.toLower()); if (editedName != peerName) { label->setToolTip(peerName + " (" + peerPk.toString() + ")"); } else if (peerName != peerPk.toString()) { @@ -586,4 +588,4 @@ bool GroupForm::canSetTopic() const } return selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator; -} +} \ No newline at end of file From eedf57b72fb3f6ae4e97530e76935a5a71bb90ea Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 19:55:48 +0200 Subject: [PATCH 39/73] fix(groups): fully cleanup groups that fail to join --- src/model/chatmanager.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index bf5a3fa583..6ea8872c84 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -530,6 +530,8 @@ void ChatManager::onGroupJoinFailed(uint32_t groupNumber) const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); if (g != nullptr) { + core->quitGroup(g->getId()); + settings.removeSavedGroup(groupId.toString()); // The UI must be torn down before the model, otherwise the GroupForm // keeps a dangling reference to the chat log. emit groupRemoved(groupId); From 22a4972333b5c0ee8e7424fe87c2f65e8bf30e2b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 20:15:31 +0200 Subject: [PATCH 40/73] feat(groups): use toxcore name when alias is empty --- src/model/chatmanager.cpp | 29 ++++++++++------------------- src/model/group.cpp | 24 +++++++++++++++--------- src/model/group.h | 1 + src/model/notificationgenerator.cpp | 2 +- src/persistence/settings.cpp | 7 +++++++ src/persistence/settings.h | 1 + src/widget/form/groupform.cpp | 2 +- src/widget/groupwidget.cpp | 4 ++-- src/widget/widget.cpp | 8 +++++++- 9 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index 6ea8872c84..96bdc9d1d8 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -392,7 +392,7 @@ void ChatManager::onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, void ChatManager::onEmptyGroupCreated(uint32_t groupNumber, const GroupId& groupId, const QString& groupName) { - Group* group = createGroup(groupNumber, groupId, groupName); + Group* group = createGroup(groupNumber, groupId, QString()); if (group == nullptr) { return; } @@ -400,6 +400,7 @@ void ChatManager::onEmptyGroupCreated(uint32_t groupNumber, const GroupId& group settings.addSavedGroup(groupId.toString()); if (!groupName.isEmpty()) { settings.setGroupName(groupId.toString(), groupName); + group->setName(groupName); } } addSelfToGroup(group); @@ -409,10 +410,7 @@ void ChatManager::onGroupJoined(uint32_t groupNumber, const GroupId& groupId) { Group* g = groupList.findGroup(groupId); if (g == nullptr) { - QString groupName = core->getGroupTitle(groupNumber); - if (groupName.isEmpty()) { - groupName = settings.getGroupName(groupId.toString()); - } + const QString groupName = core->getGroupTitle(groupNumber); g = createGroup(groupNumber, groupId, groupName); } else { updateGroupNumber(g, groupNumber); @@ -472,10 +470,7 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) g = groupList.findGroup(persistentId); } if (g == nullptr) { - QString groupName = core->getGroupTitle(groupNumber); - if (groupName.isEmpty()) { - groupName = settings.getGroupName(persistentId.toString()); - } + const QString groupName = core->getGroupTitle(groupNumber); g = createGroup(groupNumber, persistentId, groupName); } } @@ -483,14 +478,13 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) updateGroupNumber(g, groupNumber); addSelfToGroup(g); g->updatePeerRoles(); + const QString groupName = core->getGroupTitle(groupNumber); + if (!groupName.isEmpty()) { + g->updateName(groupName); + } const QString alias = settings.getGroupName(g->getPersistentId().toString()); - if (alias.isEmpty()) { - const QString groupName = core->getGroupTitle(groupNumber); - if (!groupName.isEmpty()) { - g->updateName(groupName); - } - } else if (alias != g->getName()) { - g->updateName(alias); + if (!alias.isEmpty() && alias != g->getName()) { + g->setName(alias); } const QString groupTopic = core->getGroupTopic(groupNumber); if (!groupTopic.isEmpty()) { @@ -645,9 +639,6 @@ Group* ChatManager::createGroup(uint32_t groupNumber, const GroupId& groupId, co assert(core != nullptr); QString name = groupName; - if (name.isEmpty() && !groupId.isEmpty()) { - name = tr("Group %1").arg(groupId.toString().left(8)); - } Group* g = groupList.findGroup(groupId); if (g != nullptr) { diff --git a/src/model/group.cpp b/src/model/group.cpp index 249258f982..64e8325742 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -19,7 +19,7 @@ Group::Group(int groupId_, const GroupId persistentGroupId, QString name, QStrin : groupQuery(groupQuery_) , idHandler(idHandler_) , selfName{std::move(selfName_)} - , groupName{std::move(name)} + , toxcoreName{std::move(name)} , toxGroupNum(groupId_) , groupId{persistentGroupId} , friendList{friendList_} @@ -31,21 +31,21 @@ Group::Group(int groupId_, const GroupId persistentGroupId, QString name, QStrin void Group::setName(const QString& newTitle) { const QString shortTitle = newTitle.left(TOX_GROUP_MAX_GROUP_NAME_LENGTH); - if (!shortTitle.isEmpty() && groupName != shortTitle) { + if (groupName != shortTitle) { groupName = shortTitle; - emit displayedNameChanged(groupName); + emit displayedNameChanged(getDisplayedName()); emit titleChangedByUser(groupName); - emit titleChanged(selfName, groupName); + emit titleChanged(selfName, getDisplayedName()); } } void Group::updateName(const QString& newTitle) { const QString shortTitle = newTitle.left(TOX_GROUP_MAX_GROUP_NAME_LENGTH); - if (!shortTitle.isEmpty() && groupName != shortTitle) { - groupName = shortTitle; - emit displayedNameChanged(groupName); - emit titleChanged(selfName, groupName); + if (!shortTitle.isEmpty() && toxcoreName != shortTitle) { + toxcoreName = shortTitle; + emit displayedNameChanged(getDisplayedName()); + emit titleChanged(selfName, getDisplayedName()); } } @@ -56,7 +56,13 @@ QString Group::getName() const QString Group::getDisplayedName() const { - return getName(); + if (!groupName.isEmpty()) { + return groupName; + } + if (!toxcoreName.isEmpty()) { + return toxcoreName; + } + return tr("Group %1").arg(groupId.toString().left(8)); } QString Group::getDisplayedName(const ToxPk& contact) const diff --git a/src/model/group.h b/src/model/group.h index ea13a91a9a..f7e043f5cf 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -100,6 +100,7 @@ class Group : public Chat ICoreIdHandler& idHandler; QString selfName; QString groupName; + QString toxcoreName; QString topic; bool hasPassword = false; uint16_t peerLimit = 0; diff --git a/src/model/notificationgenerator.cpp b/src/model/notificationgenerator.cpp index 69e74dfcbb..8fc00e65b7 100644 --- a/src/model/notificationgenerator.cpp +++ b/src/model/notificationgenerator.cpp @@ -109,7 +109,7 @@ NotificationData NotificationGenerator::groupMessageNotification(const Group* g, return ret; } - ret.title = g->getName(); + ret.title = g->getDisplayedName(); ret.message = message; ret.pixmap = getSenderAvatar(profile, sender); diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index 3d4330c853..dd3683becb 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -1959,6 +1959,13 @@ void Settings::setGroupName(const QString& groupIdHex, const QString& name) requestSave(); } +void Settings::removeGroupAlias(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + groupNames.remove(groupIdHex); + requestSave(); +} + QString Settings::getGroupTopic(const QString& groupIdHex) const { const QMutexLocker locker{&bigLock}; diff --git a/src/persistence/settings.h b/src/persistence/settings.h index 07fad5b8f4..86b276ffaf 100644 --- a/src/persistence/settings.h +++ b/src/persistence/settings.h @@ -490,6 +490,7 @@ public slots: void removeSavedGroup(const QString& groupIdHex); QString getGroupName(const QString& groupIdHex) const; void setGroupName(const QString& groupIdHex, const QString& name); + void removeGroupAlias(const QString& groupIdHex); QString getGroupTopic(const QString& groupIdHex) const; void setGroupTopic(const QString& groupIdHex, const QString& topic); diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 4668046669..87687bdb25 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -75,7 +75,7 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, fileButton->setProperty("state", ""); headWidget->setMode(ChatFormHeader::Mode::None); headWidget->setNameEditable(false); - setName(group->getName()); + setName(group->getDisplayedName()); nusersLabel->setFont(Style::getFont(Style::Font::Medium)); nusersLabel->setObjectName("statusLabel"); diff --git a/src/widget/groupwidget.cpp b/src/widget/groupwidget.cpp index 8542d2919c..8d97c2bbac 100644 --- a/src/widget/groupwidget.cpp +++ b/src/widget/groupwidget.cpp @@ -33,7 +33,7 @@ GroupWidget::GroupWidget(std::shared_ptr chatroom_, bool compact_, Se statusPic.setMargin(3); Group* g = chatroom->getGroup(); - nameLabel->setText(g->getName()); + nameLabel->setText(g->getDisplayedName()); updateUserCount(g->getPeersCount()); setAcceptDrops(true); @@ -123,7 +123,7 @@ void GroupWidget::mouseMoveEvent(QMouseEvent* ev) if ((dragStartPos - ev->pos()).manhattanLength() > QApplication::startDragDistance()) { auto* mdata = new QMimeData; const Group* group = getGroup(); - mdata->setText(group->getName()); + mdata->setText(group->getDisplayedName()); mdata->setData("groupId", group->getPersistentId().getByteArray()); auto* drag = new QDrag(this); diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 7754f274a5..8ef15aed69 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2348,7 +2348,13 @@ void Widget::onGroupModelAdded(Group* newGroup, std::shared_ptr chatr chatListWidget->itemsChanged(); }); connect(newGroup, &Group::titleChangedByUser, this, - [this, groupId](const QString& title) { settings.setGroupName(groupId.toString(), title); }); + [this, groupId](const QString& title) { + if (title.isEmpty()) { + settings.removeGroupAlias(groupId.toString()); + } else { + settings.setGroupName(groupId.toString(), title); + } + }); } void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptr chatroom, From ad6e2536c9b7680e5ceb78a406859b63f3ac7303 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 20:29:18 +0200 Subject: [PATCH 41/73] feat(groups): add per-group nickname support --- src/core/core.cpp | 43 +++++++++++++++++++++++++++++++++++ src/core/core.h | 4 +++- src/core/icoregroupquery.h | 3 +++ src/model/chatmanager.cpp | 4 ++++ src/model/group.cpp | 29 +++++++++++++++++++---- src/model/group.h | 4 ++++ src/persistence/settings.cpp | 29 +++++++++++++++++++++++ src/persistence/settings.h | 3 +++ src/widget/form/groupform.cpp | 15 ++++++++++++ src/widget/form/groupform.h | 1 + src/widget/widget.cpp | 4 ++++ 11 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index 8b4f479320..b053086e2b 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1778,6 +1778,49 @@ QString Core::getGroupTopic(int groupNumber) const return ToxString(topicBuf.data(), length).getQString(); } +/** + * @brief Get the self name in a group + */ +QString Core::getGroupSelfName(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Self_Query error; + const size_t length = tox_group_self_get_name_size(tox.get(), groupNumber, &error); + if (!PARSE_ERR(error) || (length == 0u)) { + return QString{}; + } + + std::vector nameBuf(length); + tox_group_self_get_name(tox.get(), groupNumber, nameBuf.data(), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(nameBuf.data(), length).getQString(); +} + +/** + * @brief Set the self name in a group + */ +bool Core::setGroupSelfName(int groupNumber, const QString& name) +{ + const QMutexLocker ml{&coreLoopLock}; + + const ToxString toxName(name); + Tox_Err_Group_Self_Name_Set error; + const bool success = tox_group_self_set_name(tox.get(), groupNumber, + reinterpret_cast(toxName.data()), + toxName.size(), &error); + if (!success) { + qWarning() << "Failed to set group self name for group" << groupNumber << ":" + << static_cast(error); + return false; + } + + return true; +} + /** * @brief Accept a conference invite. * @param inviteInfo Object which contains info about conference invitation diff --git a/src/core/core.h b/src/core/core.h index 4823da38cd..d4a436cb81 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -89,11 +89,13 @@ class Core : public QObject, QString getFriendUsername(uint32_t friendNumber) const; uint32_t getGroupNumberPeers(int groupNumber) const; - uint32_t getGroupSelfPeerId(int groupNumber) const; + uint32_t getGroupSelfPeerId(int groupNumber) const override; QString getGroupPeerName(int groupNumber, int peerId) const override; ToxPk getGroupPeerPk(int groupNumber, int peerId) const override; QString getGroupTitle(int groupNumber) const override; QString getGroupTopic(int groupNumber) const override; + QString getGroupSelfName(int groupNumber) const override; + bool setGroupSelfName(int groupNumber, const QString& name) override; GroupRole getGroupPeerRole(int groupNumber, int peerId) const override; bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override; bool kickGroupPeer(int groupNumber, int peerId) override; diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h index 6cec13a03f..3a06401963 100644 --- a/src/core/icoregroupquery.h +++ b/src/core/icoregroupquery.h @@ -56,6 +56,9 @@ class ICoreGroupQuery virtual ToxPk getGroupPeerPk(int groupNumber, int peerId) const = 0; virtual QString getGroupTitle(int groupNumber) const = 0; virtual QString getGroupTopic(int groupNumber) const = 0; + virtual QString getGroupSelfName(int groupNumber) const = 0; + virtual bool setGroupSelfName(int groupNumber, const QString& name) = 0; + virtual uint32_t getGroupSelfPeerId(int groupNumber) const = 0; virtual GroupRole getGroupPeerRole(int groupNumber, int peerId) const = 0; virtual bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) = 0; virtual bool kickGroupPeer(int groupNumber, int peerId) = 0; diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index 96bdc9d1d8..927eceef6e 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -486,6 +486,10 @@ void ChatManager::onGroupSelfJoined(uint32_t groupNumber) if (!alias.isEmpty() && alias != g->getName()) { g->setName(alias); } + const QString nickname = settings.getGroupNickname(g->getPersistentId().toString()); + if (!nickname.isEmpty()) { + g->setGroupNickname(nickname); + } const QString groupTopic = core->getGroupTopic(groupNumber); if (!groupTopic.isEmpty()) { g->setTopic(QString(), groupTopic); diff --git a/src/model/group.cpp b/src/model/group.cpp index 64e8325742..af330e6ba2 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -249,6 +249,25 @@ bool Group::setGroupPrivacyState(GroupPrivacyState privacyState_) return groupQuery.setGroupPrivacyState(toxGroupNum, privacyState_); } +bool Group::setGroupNickname(const QString& nickname_) +{ + if (groupQuery.setGroupSelfName(toxGroupNum, nickname_)) { + nickname = nickname_; + emit nicknameChanged(nickname); + const uint32_t selfPeerId = groupQuery.getGroupSelfPeerId(toxGroupNum); + if (selfPeerId != std::numeric_limits::max()) { + onPeerNameChanged(selfPeerId, nickname_); + } + return true; + } + return false; +} + +QString Group::getGroupNickname() const +{ + return nickname; +} + QString Group::resolvePeerName(uint32_t peerId) const { const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); @@ -298,15 +317,14 @@ void Group::onPeerExit(uint32_t peerId) void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) { const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); - if (pk == idHandler.getSelfPublicKey()) { - return; - } - peerIdToPk[peerId] = pk; const QString displayName = friendList.decideNickname(pk, newName); if (!peerDisplayNames.contains(pk)) { peerDisplayNames[pk] = displayName; + if (pk == idHandler.getSelfPublicKey()) { + selfName = displayName; + } emit userJoined(pk, displayName); emit numPeersChanged(peerDisplayNames.size()); return; @@ -315,6 +333,9 @@ void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) if (peerDisplayNames[pk] != displayName) { const auto oldName = peerDisplayNames[pk]; peerDisplayNames[pk] = displayName; + if (pk == idHandler.getSelfPublicKey()) { + selfName = displayName; + } emit peerNameChanged(pk, oldName, displayName); } } diff --git a/src/model/group.h b/src/model/group.h index f7e043f5cf..b446d1b9b4 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -67,6 +67,8 @@ class Group : public Chat bool setGroupTopicLock(GroupTopicLock topicLock); bool setGroupVoiceState(GroupVoiceState voiceState); bool setGroupPrivacyState(GroupPrivacyState privacyState); + bool setGroupNickname(const QString& nickname); + QString getGroupNickname() const; void onPeerJoin(uint32_t peerId); void onPeerExit(uint32_t peerId); @@ -91,6 +93,7 @@ class Group : public Chat void topicLockChanged(GroupTopicLock topicLock); void voiceStateChanged(GroupVoiceState voiceState); void privacyStateChanged(GroupPrivacyState privacyState); + void nicknameChanged(const QString& nickname); private: QString resolvePeerName(uint32_t peerId) const; @@ -102,6 +105,7 @@ class Group : public Chat QString groupName; QString toxcoreName; QString topic; + QString nickname; bool hasPassword = false; uint16_t peerLimit = 0; GroupTopicLock topicLock = GroupTopicLock::Unknown; diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index dd3683becb..e2d16e28ab 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -660,6 +660,12 @@ void Settings::loadPersonal(const Profile& profile, bool newProfile) for (auto it = topics.constBegin(); it != topics.constEnd(); ++it) { groupTopics.insert(it.key(), it.value().toString()); } + const QJsonObject nicknames = + QJsonDocument::fromJson(ps.value("groupNicknames").toString().toUtf8()).object(); + groupNicknames.clear(); + for (auto it = nicknames.constBegin(); it != nicknames.constEnd(); ++it) { + groupNicknames.insert(it.key(), it.value().toString()); + } }); inGroup(ps, "Friends", [this, &ps] { @@ -943,6 +949,12 @@ void Settings::savePersonal(QString profileName, const ToxEncrypt* passkey) } ps.setValue("groupTopics", QString::fromUtf8(QJsonDocument(topics).toJson(QJsonDocument::Compact))); + QJsonObject nicknames; + for (auto it = groupNicknames.cbegin(); it != groupNicknames.cend(); ++it) { + nicknames.insert(it.key(), it.value()); + } + ps.setValue("groupNicknames", + QString::fromUtf8(QJsonDocument(nicknames).toJson(QJsonDocument::Compact))); }); inGroup(ps, "Version", [this, &ps] { // @@ -1966,6 +1978,23 @@ void Settings::removeGroupAlias(const QString& groupIdHex) requestSave(); } +QString Settings::getGroupNickname(const QString& groupIdHex) const +{ + const QMutexLocker locker{&bigLock}; + return groupNicknames.value(groupIdHex); +} + +void Settings::setGroupNickname(const QString& groupIdHex, const QString& nickname) +{ + const QMutexLocker locker{&bigLock}; + if (nickname.isEmpty()) { + groupNicknames.remove(groupIdHex); + } else { + groupNicknames.insert(groupIdHex, nickname); + } + requestSave(); +} + QString Settings::getGroupTopic(const QString& groupIdHex) const { const QMutexLocker locker{&bigLock}; diff --git a/src/persistence/settings.h b/src/persistence/settings.h index 86b276ffaf..1502d6232d 100644 --- a/src/persistence/settings.h +++ b/src/persistence/settings.h @@ -491,6 +491,8 @@ public slots: QString getGroupName(const QString& groupIdHex) const; void setGroupName(const QString& groupIdHex, const QString& name); void removeGroupAlias(const QString& groupIdHex); + QString getGroupNickname(const QString& groupIdHex) const; + void setGroupNickname(const QString& groupIdHex, const QString& nickname); QString getGroupTopic(const QString& groupIdHex) const; void setGroupTopic(const QString& groupIdHex, const QString& topic); @@ -709,6 +711,7 @@ private slots: // Groups QStringList savedGroups; QHash groupNames; + QHash groupNicknames; QHash groupTopics; // Audio diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 87687bdb25..a7c4a685dc 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -450,9 +450,11 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) auto* copyIdAction = contextMenu->addAction(tr("Copy group ID")); const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); QAction* setTopicAction = nullptr; + QAction* setNicknameAction = nullptr; if (canSetTopic()) { setTopicAction = contextMenu->addAction(tr("Set topic...")); } + setNicknameAction = contextMenu->addAction(tr("Set nickname...")); QAction* setPasswordAction = nullptr; QAction* clearPasswordAction = nullptr; @@ -514,6 +516,8 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) } } else if (selectedItem == setTopicAction) { editTopic(); + } else if (selectedItem == setNicknameAction) { + setNickname(); } else if (selectedItem == setPasswordAction) { setPassword(); } else if (selectedItem == clearPasswordAction) { @@ -547,6 +551,17 @@ void GroupForm::setPassword() } } +void GroupForm::setNickname() +{ + bool ok = false; + const QString nickname = QInputDialog::getText(this, tr("Set nickname"), + tr("Nickname:"), QLineEdit::Normal, + group->getGroupNickname(), &ok); + if (ok) { + group->setGroupNickname(nickname); + } +} + void GroupForm::clearPassword() { group->setGroupPassword({}); diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h index d9c82a9ecf..9820998ce4 100644 --- a/src/widget/form/groupform.h +++ b/src/widget/form/groupform.h @@ -54,6 +54,7 @@ private slots: void onTopicContextMenuRequested(const QPoint& localPos); void editTopic(); void setPassword(); + void setNickname(); void clearPassword(); void setPeerLimit(); diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 8ef15aed69..3fdd221736 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -2355,6 +2355,10 @@ void Widget::onGroupModelAdded(Group* newGroup, std::shared_ptr chatr settings.setGroupName(groupId.toString(), title); } }); + connect(newGroup, &Group::nicknameChanged, this, + [this, groupId](const QString& nickname) { + settings.setGroupNickname(groupId.toString(), nickname); + }); } void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptr chatroom, From e7bd8517e09f420d17971a823a674b129f6cd377 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 20:51:22 +0200 Subject: [PATCH 42/73] feat(groups): add per-group status support --- src/core/core.cpp | 62 +++++++++++++++++++++++++++++++++++ src/core/core.h | 6 ++++ src/core/icoregroupquery.h | 4 +++ src/model/chatmanager.cpp | 10 ++++++ src/model/chatmanager.h | 1 + src/model/group.cpp | 40 ++++++++++++++++++++++ src/model/group.h | 7 ++++ src/widget/form/groupform.cpp | 42 +++++++++++++++++++++--- src/widget/form/groupform.h | 1 + 9 files changed, 168 insertions(+), 5 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index b053086e2b..6a4f3b1e4d 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -109,6 +109,7 @@ void Core::registerCallbacks(Tox* tox) tox_callback_group_peer_join(tox, onGroupPeerJoin); tox_callback_group_peer_exit(tox, onGroupPeerExit); tox_callback_group_peer_name(tox, onGroupPeerNameChange); + tox_callback_group_peer_status(tox, onGroupPeerStatusChange); tox_callback_group_self_join(tox, onGroupSelfJoin); tox_callback_group_topic(tox, onGroupTopic); tox_callback_group_join_fail(tox, onGroupJoinFail); @@ -620,6 +621,16 @@ void Core::onGroupPeerNameChange(Tox* tox, uint32_t groupNumber, uint32_t peerId emit core->groupPeerNameChanged(groupNumber, peerId, newName); } +void Core::onGroupPeerStatusChange(Tox* tox, uint32_t groupNumber, uint32_t peerId, + Tox_User_Status status, void* vCore) +{ + std::ignore = tox; + qDebug().nospace() << "Group " << groupNumber << ", peer " << peerId + << ", status " << static_cast(status); + auto* core = static_cast(vCore); + emit core->groupPeerStatusChanged(groupNumber, peerId, static_cast(status)); +} + void Core::onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore) { std::ignore = tox; @@ -1821,6 +1832,57 @@ bool Core::setGroupSelfName(int groupNumber, const QString& name) return true; } +/** + * @brief Get the self status in a group + */ +Status::Status Core::getGroupSelfStatus(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Self_Query error; + const Tox_User_Status status = tox_group_self_get_status(tox.get(), groupNumber, &error); + if (!PARSE_ERR(error)) { + return Status::Status::Offline; + } + + return static_cast(status); +} + +/** + * @brief Set the self status in a group + */ +bool Core::setGroupSelfStatus(int groupNumber, Status::Status status) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Self_Status_Set error; + const bool success = tox_group_self_set_status(tox.get(), groupNumber, + static_cast(status), &error); + if (!success) { + qWarning() << "Failed to set group self status for group" << groupNumber << ":" + << static_cast(error); + return false; + } + + return true; +} + +/** + * @brief Get the status of a peer in a group + */ +Status::Status Core::getGroupPeerStatus(int groupNumber, int peerId) const +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Group_Peer_Query error; + const Tox_User_Status status = tox_group_peer_get_status(tox.get(), groupNumber, peerId, &error); + if (!PARSE_ERR(error)) { + return Status::Status::Offline; + } + + return static_cast(status); +} + /** * @brief Accept a conference invite. * @param inviteInfo Object which contains info about conference invitation diff --git a/src/core/core.h b/src/core/core.h index d4a436cb81..33aee550f0 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -96,6 +96,9 @@ class Core : public QObject, QString getGroupTopic(int groupNumber) const override; QString getGroupSelfName(int groupNumber) const override; bool setGroupSelfName(int groupNumber, const QString& name) override; + Status::Status getGroupSelfStatus(int groupNumber) const override; + bool setGroupSelfStatus(int groupNumber, Status::Status status) override; + Status::Status getGroupPeerStatus(int groupNumber, int peerId) const override; GroupRole getGroupPeerRole(int groupNumber, int peerId) const override; bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override; bool kickGroupPeer(int groupNumber, int peerId) override; @@ -228,6 +231,7 @@ public slots: void groupPeerJoined(uint32_t groupNumber, uint32_t peerId); void groupPeerExited(uint32_t groupNumber, uint32_t peerId); void groupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName); + void groupPeerStatusChanged(uint32_t groupNumber, uint32_t peerId, Status::Status status); void groupTitleChanged(uint32_t groupNumber, const QString& author, const QString& title); void groupTopicChanged(uint32_t groupNumber, const QString& topic); void groupSentFailed(uint32_t groupNumber); @@ -286,6 +290,8 @@ public slots: const uint8_t* partMessage, size_t partMessageLength, void* vCore); static void onGroupPeerNameChange(Tox* tox, uint32_t groupNumber, uint32_t peerId, const uint8_t* name, size_t length, void* vCore); + static void onGroupPeerStatusChange(Tox* tox, uint32_t groupNumber, uint32_t peerId, + Tox_User_Status status, void* vCore); static void onGroupSelfJoin(Tox* tox, uint32_t groupNumber, void* vCore); static void onGroupTopic(Tox* tox, uint32_t groupNumber, uint32_t peerId, const uint8_t* topic, size_t length, void* vCore); diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h index 3a06401963..a62bbb6cb9 100644 --- a/src/core/icoregroupquery.h +++ b/src/core/icoregroupquery.h @@ -4,6 +4,7 @@ #pragma once +#include "src/model/status.h" #include "toxpk.h" #include @@ -59,6 +60,9 @@ class ICoreGroupQuery virtual QString getGroupSelfName(int groupNumber) const = 0; virtual bool setGroupSelfName(int groupNumber, const QString& name) = 0; virtual uint32_t getGroupSelfPeerId(int groupNumber) const = 0; + virtual Status::Status getGroupSelfStatus(int groupNumber) const = 0; + virtual bool setGroupSelfStatus(int groupNumber, Status::Status status) = 0; + virtual Status::Status getGroupPeerStatus(int groupNumber, int peerId) const = 0; virtual GroupRole getGroupPeerRole(int groupNumber, int peerId) const = 0; virtual bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) = 0; virtual bool kickGroupPeer(int groupNumber, int peerId) = 0; diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index 927eceef6e..e219023d9c 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -64,6 +64,7 @@ void ChatManager::connectToCore(Core& core_) connect(core, &Core::groupPeerJoined, this, &ChatManager::onGroupPeerJoined); connect(core, &Core::groupPeerExited, this, &ChatManager::onGroupPeerExited); connect(core, &Core::groupPeerNameChanged, this, &ChatManager::onGroupPeerNameChanged); + connect(core, &Core::groupPeerStatusChanged, this, &ChatManager::onGroupPeerStatusChanged); connect(core, &Core::groupTopicChanged, this, &ChatManager::onGroupTopicChanged); connect(core, &Core::groupSelfJoined, this, &ChatManager::onGroupSelfJoined); connect(core, &Core::groupSelfDisconnected, this, &ChatManager::onGroupSelfDisconnected); @@ -450,6 +451,15 @@ void ChatManager::onGroupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, g->onPeerNameChanged(peerId, newName); } +void ChatManager::onGroupPeerStatusChanged(uint32_t groupNumber, uint32_t peerId, Status::Status status) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + assert(g); + + g->onPeerStatusChanged(peerId, status); +} + void ChatManager::onGroupTopicChanged(uint32_t groupNumber, const QString& topic) { const GroupId& groupId = groupList.id2Key(groupNumber); diff --git a/src/model/chatmanager.h b/src/model/chatmanager.h index 6f3e52c2da..4dafc9c7e6 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -106,6 +106,7 @@ private slots: void onGroupPeerJoined(uint32_t groupNumber, uint32_t peerId); void onGroupPeerExited(uint32_t groupNumber, uint32_t peerId); void onGroupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName); + void onGroupPeerStatusChanged(uint32_t groupNumber, uint32_t peerId, Status::Status status); void onGroupTopicChanged(uint32_t groupNumber, const QString& topic); void onGroupSelfJoined(uint32_t groupNumber); void onGroupSelfDisconnected(uint32_t groupNumber); diff --git a/src/model/group.cpp b/src/model/group.cpp index af330e6ba2..81533375f2 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -268,6 +268,24 @@ QString Group::getGroupNickname() const return nickname; } +bool Group::setGroupStatus(Status::Status status) +{ + if (groupQuery.setGroupSelfStatus(toxGroupNum, status)) { + selfStatus = status; + const uint32_t selfPeerId = groupQuery.getGroupSelfPeerId(toxGroupNum); + if (selfPeerId != std::numeric_limits::max()) { + onPeerStatusChanged(selfPeerId, status); + } + return true; + } + return false; +} + +Status::Status Group::getGroupStatus() const +{ + return selfStatus; +} + QString Group::resolvePeerName(uint32_t peerId) const { const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); @@ -284,6 +302,7 @@ void Group::onPeerJoin(uint32_t peerId) const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); peerIdToPk[peerId] = pk; peerRoles[pk] = groupQuery.getGroupPeerRole(toxGroupNum, peerId); + peerStatuses[pk] = groupQuery.getGroupPeerStatus(toxGroupNum, peerId); const QString name = resolvePeerName(peerId); if (peerDisplayNames.contains(pk)) { if (peerDisplayNames[pk] != name) { @@ -303,6 +322,7 @@ void Group::onPeerExit(uint32_t peerId) { const ToxPk pk = resolvePeerPk(peerId); peerIdToPk.remove(peerId); + peerStatuses.remove(pk); auto it = peerDisplayNames.find(pk); if (it == peerDisplayNames.end()) { return; @@ -340,6 +360,21 @@ void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) } } +void Group::onPeerStatusChanged(uint32_t peerId, Status::Status status) +{ + const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); + peerIdToPk[peerId] = pk; + + if (pk == idHandler.getSelfPublicKey()) { + selfStatus = status; + } + + if (peerStatuses.value(pk, Status::Status::Online) != status) { + peerStatuses[pk] = status; + emit peerStatusChanged(pk, status); + } +} + void Group::updatePeerRoles() { bool changed = false; @@ -360,6 +395,11 @@ GroupRole Group::getPeerRole(const ToxPk& pk) const return peerRoles.value(pk, GroupRole::User); } +Status::Status Group::getPeerStatus(const ToxPk& pk) const +{ + return peerStatuses.value(pk, Status::Status::Online); +} + bool Group::setPeerRole(const ToxPk& pk, GroupRole role) { for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { diff --git a/src/model/group.h b/src/model/group.h index b446d1b9b4..5b2d61cb7a 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -69,12 +69,16 @@ class Group : public Chat bool setGroupPrivacyState(GroupPrivacyState privacyState); bool setGroupNickname(const QString& nickname); QString getGroupNickname() const; + bool setGroupStatus(Status::Status status); + Status::Status getGroupStatus() const; void onPeerJoin(uint32_t peerId); void onPeerExit(uint32_t peerId); void onPeerNameChanged(uint32_t peerId, const QString& newName); + void onPeerStatusChanged(uint32_t peerId, Status::Status status); void updatePeerRoles(); GroupRole getPeerRole(const ToxPk& pk) const; + Status::Status getPeerStatus(const ToxPk& pk) const; bool setPeerRole(const ToxPk& pk, GroupRole role); bool kickPeer(const ToxPk& pk); ToxPk resolvePeerPk(uint32_t peerId) const; @@ -87,6 +91,7 @@ class Group : public Chat void userLeft(const ToxPk& user, const QString& name); void numPeersChanged(int numPeers); void peerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName); + void peerStatusChanged(const ToxPk& peer, Status::Status status); void peerRolesChanged(); void passwordSetChanged(bool hasPassword); void peerLimitChanged(uint16_t peerLimit); @@ -106,12 +111,14 @@ class Group : public Chat QString toxcoreName; QString topic; QString nickname; + Status::Status selfStatus = Status::Status::Online; bool hasPassword = false; uint16_t peerLimit = 0; GroupTopicLock topicLock = GroupTopicLock::Unknown; GroupVoiceState voiceState = GroupVoiceState::Unknown; GroupPrivacyState privacyState = GroupPrivacyState::Unknown; QMap peerDisplayNames; + QMap peerStatuses; QMap peerIdToPk; QMap peerRoles; bool hasNewMessages; diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index a7c4a685dc..c4d53fa7cc 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -105,6 +105,7 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, connect(group, &Group::userJoined, this, &GroupForm::onUserJoined); connect(group, &Group::userLeft, this, &GroupForm::onUserLeft); connect(group, &Group::peerNameChanged, this, &GroupForm::onPeerNameChanged); + connect(group, &Group::peerStatusChanged, this, &GroupForm::onPeerStatusChanged); connect(group, &Group::numPeersChanged, this, &GroupForm::updateUserCount); connect(group, &Group::peerRolesChanged, this, &GroupForm::updateUserNames); connect(topicLabel, &CroppingLabel::customContextMenuRequested, this, @@ -212,13 +213,15 @@ void GroupForm::updateUserNames() for (const auto& peerPk : peers.keys()) { const QString peerName = peers.value(peerPk); const QString editedName = editName(peerName); - const QString icon = roleIcon(group->getPeerRole(peerPk)); + const QString roleIconStr = roleIcon(group->getPeerRole(peerPk)); + const Status::Status status = group->getPeerStatus(peerPk); + const QString statusIcon = QString(" ").arg(Status::getIconPath(status)); QLabel* label; - if (icon.isEmpty()) { - label = new QLabel(editedName + QLatin1String(", ")); - label->setTextFormat(Qt::PlainText); + if (roleIconStr.isEmpty()) { + label = new QLabel(statusIcon + editedName.toHtmlEscaped() + QLatin1String(", ")); + label->setTextFormat(Qt::RichText); } else { - label = new QLabel(icon + editedName.toHtmlEscaped() + QLatin1String(", ")); + label = new QLabel(statusIcon + roleIconStr + editedName.toHtmlEscaped() + QLatin1String(", ")); label->setTextFormat(Qt::RichText); } label->setProperty("peerSortName", editedName.toLower()); @@ -287,6 +290,13 @@ void GroupForm::onPeerNameChanged(const ToxPk& peer, const QString& oldName, con updateUserNames(); } +void GroupForm::onPeerStatusChanged(const ToxPk& peer, Status::Status status) +{ + std::ignore = peer; + std::ignore = status; + updateUserNames(); +} + void GroupForm::dragEnterEvent(QDragEnterEvent* ev) { if (!ev->mimeData()->hasFormat("toxPk")) { @@ -451,10 +461,26 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); QAction* setTopicAction = nullptr; QAction* setNicknameAction = nullptr; + QMenu* statusMenu = nullptr; + QAction* statusOnlineAction = nullptr; + QAction* statusAwayAction = nullptr; + QAction* statusBusyAction = nullptr; if (canSetTopic()) { setTopicAction = contextMenu->addAction(tr("Set topic...")); } setNicknameAction = contextMenu->addAction(tr("Set nickname...")); + + statusMenu = contextMenu->addMenu(tr("My status")); + const Status::Status currentStatus = group->getGroupStatus(); + statusOnlineAction = statusMenu->addAction(tr("Online")); + statusOnlineAction->setCheckable(true); + statusOnlineAction->setChecked(currentStatus == Status::Status::Online); + statusAwayAction = statusMenu->addAction(tr("Away")); + statusAwayAction->setCheckable(true); + statusAwayAction->setChecked(currentStatus == Status::Status::Away); + statusBusyAction = statusMenu->addAction(tr("Busy")); + statusBusyAction->setCheckable(true); + statusBusyAction->setChecked(currentStatus == Status::Status::Busy); QAction* setPasswordAction = nullptr; QAction* clearPasswordAction = nullptr; @@ -518,6 +544,12 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) editTopic(); } else if (selectedItem == setNicknameAction) { setNickname(); + } else if (selectedItem == statusOnlineAction) { + group->setGroupStatus(Status::Status::Online); + } else if (selectedItem == statusAwayAction) { + group->setGroupStatus(Status::Status::Away); + } else if (selectedItem == statusBusyAction) { + group->setGroupStatus(Status::Status::Busy); } else if (selectedItem == setPasswordAction) { setPassword(); } else if (selectedItem == clearPasswordAction) { diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h index 9820998ce4..bb0dba0017 100644 --- a/src/widget/form/groupform.h +++ b/src/widget/form/groupform.h @@ -48,6 +48,7 @@ private slots: void onUserJoined(const ToxPk& user, const QString& name); void onUserLeft(const ToxPk& user, const QString& name); void onPeerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName); + void onPeerStatusChanged(const ToxPk& peer, Status::Status status); void onTitleChanged(const QString& author, const QString& title); void onTopicChanged(const QString& author, const QString& topic); void onLabelContextMenuRequested(const QPoint& localPos); From 4ab217629a7502ceb26611108ba18064cf8c0138 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 21:21:34 +0200 Subject: [PATCH 43/73] feat(groups): add status icons to group status menu --- src/widget/form/groupform.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index c4d53fa7cc..490a0cc596 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -472,13 +473,13 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) statusMenu = contextMenu->addMenu(tr("My status")); const Status::Status currentStatus = group->getGroupStatus(); - statusOnlineAction = statusMenu->addAction(tr("Online")); + statusOnlineAction = statusMenu->addAction(QIcon(Status::getIconPath(Status::Status::Online)), tr("Online")); statusOnlineAction->setCheckable(true); statusOnlineAction->setChecked(currentStatus == Status::Status::Online); - statusAwayAction = statusMenu->addAction(tr("Away")); + statusAwayAction = statusMenu->addAction(QIcon(Status::getIconPath(Status::Status::Away)), tr("Away")); statusAwayAction->setCheckable(true); statusAwayAction->setChecked(currentStatus == Status::Status::Away); - statusBusyAction = statusMenu->addAction(tr("Busy")); + statusBusyAction = statusMenu->addAction(QIcon(Status::getIconPath(Status::Status::Busy)), tr("Busy")); statusBusyAction->setCheckable(true); statusBusyAction->setChecked(currentStatus == Status::Status::Busy); From 7806441d7e834d3dac2dc58c35d7101640db38a6 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 21:27:42 +0200 Subject: [PATCH 44/73] feat(ru): add Russian translations for group features --- translations/ru.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/translations/ru.ts b/translations/ru.ts index 5101e8be77..34c268e1b1 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1455,6 +1455,13 @@ instead of closing entirely. Вы уверены, что вы хотите удалить все отображаемые сообщения? + + Group + + Group %1 + Группа %1 + + GroupForm @@ -1574,6 +1581,34 @@ instead of closing entirely. copy peer ID скопировать идентификатор узла + + Set nickname... + Установить никнейм... + + + My status + Мой статус + + + Online + В сети + + + Away + Отошёл + + + Busy + Занят + + + Set nickname + Установить никнейм + + + Nickname: + Никнейм: + IdentitySettings From e0270b6df170abf9f84c4f7bb01d797bc774eb9b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 21:31:02 +0200 Subject: [PATCH 45/73] fix(groups): use default nickname when group nickname is cleared --- src/model/group.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/model/group.cpp b/src/model/group.cpp index 81533375f2..907b409ec6 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -251,12 +251,13 @@ bool Group::setGroupPrivacyState(GroupPrivacyState privacyState_) bool Group::setGroupNickname(const QString& nickname_) { - if (groupQuery.setGroupSelfName(toxGroupNum, nickname_)) { + const QString nameToSet = nickname_.isEmpty() ? idHandler.getUsername() : nickname_; + if (groupQuery.setGroupSelfName(toxGroupNum, nameToSet)) { nickname = nickname_; emit nicknameChanged(nickname); const uint32_t selfPeerId = groupQuery.getGroupSelfPeerId(toxGroupNum); if (selfPeerId != std::numeric_limits::max()) { - onPeerNameChanged(selfPeerId, nickname_); + onPeerNameChanged(selfPeerId, nameToSet); } return true; } From 295f2726ece26b544fc463628df878b2a31e9b56 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 21:33:53 +0200 Subject: [PATCH 46/73] fix(groups): check group creation before inviting friend --- src/model/chatroom/friendchatroom.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/model/chatroom/friendchatroom.cpp b/src/model/chatroom/friendchatroom.cpp index 466fa94628..5555eca995 100644 --- a/src/model/chatroom/friendchatroom.cpp +++ b/src/model/chatroom/friendchatroom.cpp @@ -118,7 +118,9 @@ void FriendChatroom::inviteToNewGroup() { const auto friendId = frnd->getId(); const auto groupId = core.createGroup(tr("Group %1").arg(groupList.getAllGroups().size() + 1)); - core.groupInviteFriend(friendId, groupId); + if (groupId >= 0) { + core.groupInviteFriend(friendId, groupId); + } } void FriendChatroom::inviteFriend(const Group* group) From a7e2ffbb03d49b46ab26c1062abcc2d931891f51 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 21:35:04 +0200 Subject: [PATCH 47/73] fix(settings): remove group nickname when leaving group --- src/persistence/settings.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/persistence/settings.cpp b/src/persistence/settings.cpp index e2d16e28ab..3b0eba9a59 100644 --- a/src/persistence/settings.cpp +++ b/src/persistence/settings.cpp @@ -1951,6 +1951,7 @@ void Settings::removeSavedGroup(const QString& groupIdHex) savedGroups.removeAll(groupIdHex); groupNames.remove(groupIdHex); groupTopics.remove(groupIdHex); + groupNicknames.remove(groupIdHex); requestSave(); } From 034df080c04757fbbf7eae7f5974763cfbfab353 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 7 Aug 2026 22:22:35 +0200 Subject: [PATCH 48/73] fix(widget): use same icon for group button disabled state --- src/widget/widget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 3fdd221736..fb996dd9ad 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -494,7 +494,7 @@ void Widget::init() ui->groupButton->setCheckable(true); QIcon groupButtonIcon; groupButtonIcon.addPixmap(QPixmap(":/img/group.svg"), QIcon::Normal); - groupButtonIcon.addPixmap(QPixmap(":/img/group_dark.svg"), QIcon::Disabled); + groupButtonIcon.addPixmap(QPixmap(":/img/group.svg"), QIcon::Disabled); ui->groupButton->setIcon(groupButtonIcon); ui->transferButton->setCheckable(true); ui->settingsButton->setCheckable(true); From e896ff27dd43ca0ecd9e8eea38ec820db51eff0c Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 01:27:03 +0200 Subject: [PATCH 49/73] fix(groups): remove stale peer role on exit --- src/model/group.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/model/group.cpp b/src/model/group.cpp index 907b409ec6..145a925d81 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -324,6 +324,7 @@ void Group::onPeerExit(uint32_t peerId) const ToxPk pk = resolvePeerPk(peerId); peerIdToPk.remove(peerId); peerStatuses.remove(pk); + peerRoles.remove(pk); auto it = peerDisplayNames.find(pk); if (it == peerDisplayNames.end()) { return; From 3f84b8bf9e5865382690746c222ed697799a66fd Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 01:29:36 +0200 Subject: [PATCH 50/73] fix(groups): prevent unintended actions when context menu is dismissed --- src/widget/form/groupform.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 490a0cc596..c17d409c49 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -423,6 +423,7 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) contextMenu->addSeparator(); const QAction* selectedItem = contextMenu->exec(pos); + if (!selectedItem) return; if (selectedItem == toggleMuteAction) { if (isPeerBlocked) { const int index = blockList.indexOf(peerPk.toString()); @@ -529,6 +530,7 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) } const QAction* selectedItem = contextMenu->exec(pos); + if (!selectedItem) return; if (selectedItem == copyTopicAction) { auto* clipboard = QApplication::clipboard(); clipboard->setText(group->getTopic(), QClipboard::Clipboard); From e34f17a70821ee5a547b19fcfff452ce65f0408b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 01:37:55 +0200 Subject: [PATCH 51/73] fix(groups): clear peer list on self disconnect --- src/model/chatmanager.cpp | 4 +++- src/model/group.cpp | 9 +++++++++ src/model/group.h | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index e219023d9c..447fe4ce08 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -530,7 +530,9 @@ void ChatManager::onGroupSelfDisconnected(uint32_t groupNumber) { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g != nullptr) { + g->clearPeers(); + } } void ChatManager::onGroupJoinFailed(uint32_t groupNumber) diff --git a/src/model/group.cpp b/src/model/group.cpp index 145a925d81..b631fbc68b 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -336,6 +336,15 @@ void Group::onPeerExit(uint32_t peerId) emit numPeersChanged(peerDisplayNames.size()); } +void Group::clearPeers() +{ + peerIdToPk.clear(); + peerStatuses.clear(); + peerRoles.clear(); + peerDisplayNames.clear(); + emit numPeersChanged(0); +} + void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) { const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); diff --git a/src/model/group.h b/src/model/group.h index 5b2d61cb7a..6ffb60c56b 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -74,6 +74,7 @@ class Group : public Chat void onPeerJoin(uint32_t peerId); void onPeerExit(uint32_t peerId); + void clearPeers(); void onPeerNameChanged(uint32_t peerId, const QString& newName); void onPeerStatusChanged(uint32_t peerId, Status::Status status); void updatePeerRoles(); From 5891c2282308aa17ac2b0ed349628d2585351c69 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 02:54:15 +0200 Subject: [PATCH 52/73] feat(groups): add private message support --- src/chatlog/chatmessage.cpp | 9 ++- src/chatlog/chatmessage.h | 3 +- src/chatlog/chatwidget.cpp | 3 +- src/core/core.cpp | 13 +++ src/core/core.h | 5 ++ src/model/chathistory.cpp | 7 +- src/model/chatmanager.cpp | 15 ++++ src/model/chatmanager.h | 2 + src/model/group.cpp | 10 +++ src/model/group.h | 1 + src/model/groupmessagedispatcher.cpp | 40 ++++++++++ src/model/groupmessagedispatcher.h | 7 +- src/model/message.h | 3 + src/persistence/db/upgrades/dbupgrader.cpp | 19 ++++- src/persistence/db/upgrades/dbupgrader.h | 1 + src/persistence/history.cpp | 29 +++++-- src/persistence/history.h | 10 ++- src/widget/form/genericchatform.h | 2 +- src/widget/form/groupform.cpp | 83 ++++++++++++++++++++ src/widget/form/groupform.h | 13 +++ test/dbutility/include/dbutility/dbutility.h | 1 + test/dbutility/src/dbutility.cpp | 33 ++++++++ themes/dark/chatArea/innerStyle.qss | 5 ++ themes/default/chatArea/innerStyle.qss | 5 ++ translations/ru.ts | 13 +++ 25 files changed, 310 insertions(+), 22 deletions(-) diff --git a/src/chatlog/chatmessage.cpp b/src/chatlog/chatmessage.cpp index 67528e23a7..e9cd123917 100644 --- a/src/chatlog/chatmessage.cpp +++ b/src/chatlog/chatmessage.cpp @@ -43,7 +43,7 @@ ChatMessage::Ptr ChatMessage::createChatMessage(const QString& sender, const QSt MessageType type, bool isMe, MessageState state, const QDateTime& date, DocumentCache& documentCache, SmileyPack& smileyPack, Settings& settings, - Style& style, bool colorizeName) + Style& style, bool colorizeName, bool isPrivate) { ChatMessage::Ptr msg = std::make_shared(documentCache, settings, style); @@ -71,6 +71,13 @@ ChatMessage::Ptr ChatMessage::createChatMessage(const QString& sender, const QSt text = TextFormatter::applyMarkdown(text, styleType == Settings::StyleType::WITH_CHARS); } + if (isPrivate) { + const QString badge = QStringLiteral( + "%1 ") + .arg(QObject::tr("private", "Label for private group messages")); + text = badge + text; + } + switch (type) { case NORMAL: diff --git a/src/chatlog/chatmessage.h b/src/chatlog/chatmessage.h index 4c24031431..39ba47ab5d 100644 --- a/src/chatlog/chatmessage.h +++ b/src/chatlog/chatmessage.h @@ -48,7 +48,8 @@ class ChatMessage : public ChatLine MessageType type, bool isMe, MessageState state, const QDateTime& date, DocumentCache& documentCache, SmileyPack& smileyPack, Settings& settings, - Style& style, bool colorizeName = false); + Style& style, bool colorizeName = false, + bool isPrivate = false); static ChatMessage::Ptr createChatInfoMessage(const QString& rawMessage, SystemMessageType type, const QDateTime& date, DocumentCache& documentCache, Settings& settings, Style& style); diff --git a/src/chatlog/chatwidget.cpp b/src/chatlog/chatwidget.cpp index 670bb8f6f4..0eded3f4e4 100644 --- a/src/chatlog/chatwidget.cpp +++ b/src/chatlog/chatwidget.cpp @@ -57,10 +57,11 @@ ChatMessage::Ptr createMessage(const QString& displayName, bool isSelf, bool col messageType = ChatMessage::MessageType::ALERT; } + const bool isPrivate = !chatLogMessage.message.recipient.isEmpty(); const auto timestamp = chatLogMessage.message.timestamp; return ChatMessage::createChatMessage(displayName, chatLogMessage.message.content, messageType, isSelf, chatLogMessage.state, timestamp, documentCache, - smileyPack, settings, style, colorizeNames); + smileyPack, settings, style, colorizeNames, isPrivate); } void renderMessageRaw(const QString& displayName, bool isSelf, bool colorizeNames, diff --git a/src/core/core.cpp b/src/core/core.cpp index 6a4f3b1e4d..37c1674da7 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -106,6 +106,7 @@ void Core::registerCallbacks(Tox* tox) tox_callback_group_invite(tox, onGroupInvite); tox_callback_group_message(tox, onGroupMessage); + tox_callback_group_private_message(tox, onGroupPrivateMessage); tox_callback_group_peer_join(tox, onGroupPeerJoin); tox_callback_group_peer_exit(tox, onGroupPeerExit); tox_callback_group_peer_name(tox, onGroupPeerNameChange); @@ -578,6 +579,18 @@ void Core::onGroupMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_M emit core->groupMessageReceived(groupNumber, peerId, message, isAction); } +void Core::onGroupPrivateMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_Message_Type type, + const uint8_t* cMessage, size_t length, Tox_Group_Message_Id messageId, + void* vCore) +{ + std::ignore = tox; + std::ignore = messageId; + Core* core = static_cast(vCore); + const bool isAction = type == TOX_MESSAGE_TYPE_ACTION; + const QString message = ToxString(cMessage, length).getQString(); + emit core->groupPrivateMessageReceived(groupNumber, peerId, message, isAction); +} + void Core::onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void* vCore) { std::ignore = tox; diff --git a/src/core/core.h b/src/core/core.h index 33aee550f0..da8c419656 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -228,6 +228,8 @@ public slots: void groupInviteReceived(const GroupInvite& inviteInfo); void groupMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, bool isAction); + void groupPrivateMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, + bool isAction); void groupPeerJoined(uint32_t groupNumber, uint32_t peerId); void groupPeerExited(uint32_t groupNumber, uint32_t peerId); void groupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, const QString& newName); @@ -284,6 +286,9 @@ public slots: static void onGroupMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_Message_Type type, const uint8_t* cMessage, size_t length, Tox_Group_Message_Id messageId, void* vCore); + static void onGroupPrivateMessage(Tox* tox, uint32_t groupNumber, uint32_t peerId, + Tox_Message_Type type, const uint8_t* cMessage, size_t length, + Tox_Group_Message_Id messageId, void* vCore); static void onGroupPeerJoin(Tox* tox, uint32_t groupNumber, uint32_t peerId, void* vCore); static void onGroupPeerExit(Tox* tox, uint32_t groupNumber, uint32_t peerId, Tox_Group_Exit_Type exitType, const uint8_t* name, size_t nameLength, diff --git a/src/model/chathistory.cpp b/src/model/chathistory.cpp index 04265d8c60..8393d93c66 100644 --- a/src/model/chathistory.cpp +++ b/src/model/chathistory.cpp @@ -262,7 +262,8 @@ void ChatHistory::onMessageReceived(const ToxPk& sender, const Message& message) content = ChatForm::ACTION_PREFIX + content; } - history->addNewMessage(chatId, content, sender, message.timestamp, true, displayName); + history->addNewMessage(chatId, content, sender, message.timestamp, true, displayName, {}, + message.recipient); } sessionChatLog.onMessageReceived(sender, message); @@ -284,7 +285,7 @@ void ChatHistory::onMessageSent(DispatchedMessageId id, const Message& message) auto onInsertion = [this, id](RowId historyId) { handleDispatchedMessage(id, historyId); }; history->addNewMessage(chatId, content, selfPk, message.timestamp, false, username, - onInsertion); + onInsertion, message.recipient); } sessionChatLog.onMessageSent(id, message); @@ -370,7 +371,7 @@ void ChatHistory::loadHistoryIntoSessionChatLog(ChatLogIdx start) const // we hit IMessageDispatcher's signals which history listens for. // Items added to history have already been sent so we know they already // reflect what was sent/received. - auto processedMessage = Message{isAction, messageContent, message.timestamp, {}}; + auto processedMessage = Message{isAction, messageContent, message.timestamp, {}, message.recipient}; auto dispatchedMessageIt = std::find_if(dispatchedMessageRowIdMap.begin(), dispatchedMessageRowIdMap.end(), diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index 447fe4ce08..fee1c0e3a4 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -59,6 +59,7 @@ void ChatManager::connectToCore(Core& core_) connect(core, &Core::conferencePeerNameChanged, this, &ChatManager::onConferencePeerNameChanged); connect(core, &Core::conferenceTitleChanged, this, &ChatManager::onConferenceTitleChanged); connect(core, &Core::groupMessageReceived, this, &ChatManager::onGroupMessageReceived); + connect(core, &Core::groupPrivateMessageReceived, this, &ChatManager::onGroupPrivateMessageReceived); connect(core, &Core::emptyGroupCreated, this, &ChatManager::onEmptyGroupCreated); connect(core, &Core::groupJoined, this, &ChatManager::onGroupJoined); connect(core, &Core::groupPeerJoined, this, &ChatManager::onGroupPeerJoined); @@ -390,6 +391,20 @@ void ChatManager::onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, groupMessageDispatchers[groupId]->onMessageReceived(author, isAction, message); } +void ChatManager::onGroupPrivateMessageReceived(uint32_t groupNumber, uint32_t peerId, + const QString& message, bool isAction) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + return; + } + + const ToxPk author = core->getGroupPeerPk(groupNumber, peerId); + + groupMessageDispatchers[groupId]->onPrivateMessageReceived(author, isAction, message); +} + void ChatManager::onEmptyGroupCreated(uint32_t groupNumber, const GroupId& groupId, const QString& groupName) { diff --git a/src/model/chatmanager.h b/src/model/chatmanager.h index 4dafc9c7e6..dca433e5c8 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -101,6 +101,8 @@ private slots: void onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, bool isAction); + void onGroupPrivateMessageReceived(uint32_t groupNumber, uint32_t peerId, const QString& message, + bool isAction); void onEmptyGroupCreated(uint32_t groupNumber, const GroupId& groupId, const QString& groupName); void onGroupJoined(uint32_t groupNumber, const GroupId& groupId); void onGroupPeerJoined(uint32_t groupNumber, uint32_t peerId); diff --git a/src/model/group.cpp b/src/model/group.cpp index b631fbc68b..1dba42ca0e 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -441,3 +441,13 @@ ToxPk Group::resolvePeerPk(uint32_t peerId) const { return peerIdToPk.value(peerId, ToxPk{}); } + +uint32_t Group::getPeerId(const ToxPk& pk) const +{ + for (auto it = peerIdToPk.cbegin(); it != peerIdToPk.cend(); ++it) { + if (it.value() == pk) { + return it.key(); + } + } + return std::numeric_limits::max(); +} diff --git a/src/model/group.h b/src/model/group.h index 6ffb60c56b..2fc29755e6 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -83,6 +83,7 @@ class Group : public Chat bool setPeerRole(const ToxPk& pk, GroupRole role); bool kickPeer(const ToxPk& pk); ToxPk resolvePeerPk(uint32_t peerId) const; + uint32_t getPeerId(const ToxPk& pk) const; signals: void titleChanged(const QString& author, const QString& title); diff --git a/src/model/groupmessagedispatcher.cpp b/src/model/groupmessagedispatcher.cpp index e16a881555..a9ff3f0b4e 100644 --- a/src/model/groupmessagedispatcher.cpp +++ b/src/model/groupmessagedispatcher.cpp @@ -44,6 +44,27 @@ GroupMessageDispatcher::sendMessage(bool isAction, const QString& content) return std::make_pair(firstMessageId, lastMessageId); } +std::pair +GroupMessageDispatcher::sendPrivateMessage(uint32_t peerId, bool isAction, const QString& content) +{ + const auto firstMessageId = nextMessageId; + auto lastMessageId = firstMessageId; + const ToxPk recipientPk = group.resolvePeerPk(peerId); + + for (const auto& message : processor.processOutgoingMessage(isAction, content)) { + auto messageId = nextMessageId++; + lastMessageId = messageId; + messageSender.sendGroupPrivateMessage(group.getId(), peerId, message.content); + + Message messageWithRecipient = message; + messageWithRecipient.recipient = recipientPk; + emit messageSent(messageId, messageWithRecipient); + emit messageComplete(messageId); + } + + return std::make_pair(firstMessageId, lastMessageId); +} + /** * @brief Processes and dispatches received message from toxcore * @param[in] sender @@ -66,3 +87,22 @@ void GroupMessageDispatcher::onMessageReceived(const ToxPk& sender, bool isActio emit messageReceived(sender, processor.processIncomingCoreMessage(isAction, content)); } + +void GroupMessageDispatcher::onPrivateMessageReceived(const ToxPk& sender, bool isAction, + const QString& content) +{ + const bool isSelf = sender == idHandler.getSelfPublicKey(); + + if (isSelf) { + return; + } + + if (settings.getBlockList().contains(sender.toString())) { + qDebug() << "onGroupPrivateMessageReceived: Filtered:" << sender.toString(); + return; + } + + Message message = processor.processIncomingCoreMessage(isAction, content); + message.recipient = idHandler.getSelfPublicKey(); + emit messageReceived(sender, message); +} diff --git a/src/model/groupmessagedispatcher.h b/src/model/groupmessagedispatcher.h index 52d0d1dc84..c0184b6da3 100644 --- a/src/model/groupmessagedispatcher.h +++ b/src/model/groupmessagedispatcher.h @@ -23,9 +23,14 @@ class GroupMessageDispatcher : public IMessageDispatcher ICoreGroupMessageSender& messageSender, Settings& settings); std::pair sendMessage(bool isAction, - const QString& content) override; + const QString& content) override; + + std::pair sendPrivateMessage(uint32_t peerId, + bool isAction, + const QString& content); void onMessageReceived(const ToxPk& sender, bool isAction, const QString& content); + void onPrivateMessageReceived(const ToxPk& sender, bool isAction, const QString& content); private: Group& group; diff --git a/src/model/message.h b/src/model/message.h index d035123eb0..44ac54283f 100644 --- a/src/model/message.h +++ b/src/model/message.h @@ -5,6 +5,8 @@ #pragma once +#include "src/core/toxpk.h" + #include #include #include @@ -37,6 +39,7 @@ struct Message QString content; QDateTime timestamp; std::vector metadata; + ToxPk recipient; }; diff --git a/src/persistence/db/upgrades/dbupgrader.cpp b/src/persistence/db/upgrades/dbupgrader.cpp index 084cef56b5..2c5e01938a 100644 --- a/src/persistence/db/upgrades/dbupgrader.cpp +++ b/src/persistence/db/upgrades/dbupgrader.cpp @@ -18,7 +18,7 @@ #include namespace { -constexpr int SCHEMA_VERSION = 11; +constexpr int SCHEMA_VERSION = 12; std::vector getInvalidPeers(RawDatabase& db) { @@ -247,9 +247,9 @@ bool DbUpgrader::dbSchemaUpgrade(std::shared_ptr& db, IMessageBoxMa using DbSchemaUpgradeFn = bool (*)(RawDatabase&); std::vector upgradeFns = {dbSchema0to1, dbSchema1to2, dbSchema2to3, - dbSchema3to4, dbSchema4to5, dbSchema5to6, - dbSchema6to7, dbSchema7to8, dbSchema8to9, - dbSchema9to10, DbTo11::dbSchema10to11}; + dbSchema3to4, dbSchema4to5, dbSchema5to6, + dbSchema6to7, dbSchema7to8, dbSchema8to9, + dbSchema9to10, DbTo11::dbSchema10to11, dbSchema11to12}; assert(databaseSchemaVersion < static_cast(upgradeFns.size())); assert(upgradeFns.size() == SCHEMA_VERSION); @@ -301,6 +301,7 @@ bool DbUpgrader::createCurrentSchema(RawDatabase& db) // ensure that our blob vector always has the right number of fields. Better to just // leave this as NOT NULL for now. "message BLOB NOT NULL, " + "recipient BLOB, " "FOREIGN KEY (id, message_type) REFERENCES history(id, message_type), " "FOREIGN KEY (sender_alias) REFERENCES aliases(id)); " "CREATE TABLE file_transfers " @@ -623,6 +624,16 @@ bool DbUpgrader::dbSchema9to10(RawDatabase& db) return db.execNow(std::move(upgradeQueries)); } +bool DbUpgrader::dbSchema11to12(RawDatabase& db) +{ + std::vector upgradeQueries; + upgradeQueries.emplace_back(QStringLiteral( // + "ALTER TABLE text_messages " + "ADD COLUMN recipient BLOB;")); + upgradeQueries.emplace_back(QStringLiteral("PRAGMA user_version = 12;")); + return db.execNow(std::move(upgradeQueries)); +} + void DbUpgrader::mergeDuplicatePeers(std::vector& upgradeQueries, RawDatabase& db, const std::vector& badPeers) { diff --git a/src/persistence/db/upgrades/dbupgrader.h b/src/persistence/db/upgrades/dbupgrader.h index 1569f6abb3..03b54b3f58 100644 --- a/src/persistence/db/upgrades/dbupgrader.h +++ b/src/persistence/db/upgrades/dbupgrader.h @@ -27,6 +27,7 @@ bool dbSchema6to7(RawDatabase& db); bool dbSchema7to8(RawDatabase& db); bool dbSchema8to9(RawDatabase& db); bool dbSchema9to10(RawDatabase& db); +bool dbSchema11to12(RawDatabase& db); // 10to11 from DbTo11::dbSchema10to11 struct BadEntry diff --git a/src/persistence/history.cpp b/src/persistence/history.cpp index 3328f8e4f3..a6d263ba93 100644 --- a/src/persistence/history.cpp +++ b/src/persistence/history.cpp @@ -103,7 +103,7 @@ RawDatabase::Query generateHistoryTableInsertion(char type, const QDateTime& tim std::vector generateNewTextMessageQueries(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, - std::function insertIdCallback) + std::function insertIdCallback, const ToxPk& recipient) { std::vector queries; @@ -114,7 +114,7 @@ generateNewTextMessageQueries(const ChatId& chatId, const QString& message, cons QVector boundParams; QString queryString = QStringLiteral( // - "INSERT INTO text_messages (id, message_type, sender_alias, message) " + "INSERT INTO text_messages (id, message_type, sender_alias, message, recipient) " "VALUES ( " " last_insert_rowid(), " " 'T', " @@ -124,6 +124,12 @@ generateNewTextMessageQueries(const ChatId& chatId, const QString& message, cons boundParams += dispName.toUtf8(); queryString += "), ?"; boundParams += message.toUtf8(); + if (!recipient.isEmpty()) { + queryString += ", ?"; + boundParams += recipient.getByteArray(); + } else { + queryString += ", NULL"; + } queryString += ");"; queries.emplace_back(queryString, boundParams, insertIdCallback); @@ -498,14 +504,15 @@ void History::addNewSystemMessage(const ChatId& chatId, const SystemMessage& sys */ void History::addNewMessage(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, - const std::function& insertIdCallback) + const std::function& insertIdCallback, + const ToxPk& recipient) { if (historyAccessBlocked()) { return; } db->execLater(generateNewTextMessageQueries(chatId, message, sender, time, isDelivered, - dispName, insertIdCallback)); + dispName, insertIdCallback, recipient)); } void History::setFileFinished(const QByteArray& fileId, bool success, const QString& filePath, @@ -580,6 +587,7 @@ QList History::getMessagesForChat(const ChatId& chatId, si constexpr auto fileOffset = 6; constexpr auto senderOffset = 12; constexpr auto systemOffset = 14; + constexpr auto recipientOffset = 19; auto it = row.begin(); @@ -601,8 +609,10 @@ QList History::getMessagesForChat(const ChatId& chatId, si it = std::next(row.begin(), senderOffset); const auto senderKey = ToxPk{(*it++).toByteArray()}; const auto senderName = QString::fromUtf8((*it++).toByteArray().replace('\0', "")); + it = std::next(row.begin(), recipientOffset); + const auto recipientKey = it->isNull() ? ToxPk{} : ToxPk{it->toByteArray()}; messages += HistMessage(id, messageState, timestamp, chatId.clone(), senderName, - senderKey, messageContent); + senderKey, messageContent, recipientKey); break; } case 'F': { @@ -668,7 +678,8 @@ QList History::getMessagesForChat(const ChatId& chatId, si " system_messages.arg1,\n" " system_messages.arg2,\n" " system_messages.arg3,\n" - " system_messages.arg4\n" + " system_messages.arg4,\n" + " text_messages.recipient\n" "FROM history " "LEFT JOIN text_messages ON history.id = text_messages.id " "LEFT JOIN file_transfers ON history.id = file_transfers.id " @@ -705,11 +716,12 @@ QList History::getUndeliveredMessagesForChat(const ChatId& auto messageContent = (*it++).toString(); auto senderKey = ToxPk{(*it++).toByteArray()}; auto displayName = QString::fromUtf8((*it++).toByteArray().replace('\0', "")); + const auto recipientKey = it->isNull() ? ToxPk{} : ToxPk{it->toByteArray()}; const MessageState messageState = getMessageState(isPending, isBroken); ret += - {id, messageState, timestamp, chatId.clone(), displayName, senderKey, messageContent}; + {id, messageState, timestamp, chatId.clone(), displayName, senderKey, messageContent, recipientKey}; }; QString queryString = QStringLiteral( // @@ -720,7 +732,8 @@ QList History::getUndeliveredMessagesForChat(const ChatId& " broken_messages.id,\n" " text_messages.message,\n" " authors.public_key as sender_key,\n" - " aliases.display_name\n" + " aliases.display_name,\n" + " text_messages.recipient\n" "FROM history " "JOIN text_messages ON history.id = text_messages.id " "JOIN aliases ON text_messages.sender_alias = aliases.id " diff --git a/src/persistence/history.h b/src/persistence/history.h index 4990708548..a155369a95 100644 --- a/src/persistence/history.h +++ b/src/persistence/history.h @@ -130,7 +130,8 @@ class History : public QObject, public std::enable_shared_from_this struct HistMessage { HistMessage(RowId id_, MessageState state_, QDateTime timestamp_, - std::unique_ptr chat_, QString dispName_, ToxPk sender_, QString message) + std::unique_ptr chat_, QString dispName_, ToxPk sender_, QString message, + ToxPk recipient_ = {}) : chat{std::move(chat_)} , sender{std::move(sender_)} , dispName{std::move(dispName_)} @@ -138,6 +139,7 @@ class History : public QObject, public std::enable_shared_from_this , id{id_} , state{state_} , content(std::move(message)) + , recipient{std::move(recipient_)} { } @@ -171,6 +173,7 @@ class History : public QObject, public std::enable_shared_from_this , id{other.id} , state{other.state} , content{other.content} + , recipient{other.recipient} { } @@ -183,6 +186,7 @@ class History : public QObject, public std::enable_shared_from_this id = other.id; state = other.state; content = other.content; + recipient = other.recipient; return *this; } @@ -193,6 +197,7 @@ class History : public QObject, public std::enable_shared_from_this RowId id; MessageState state; HistMessageContent content; + ToxPk recipient; }; struct DateIdx @@ -213,7 +218,8 @@ class History : public QObject, public std::enable_shared_from_this void removeChatHistory(const ChatId& chatId); void addNewMessage(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, - const std::function& insertIdCallback = {}); + const std::function& insertIdCallback = {}, + const ToxPk& recipient = {}); void addNewFileMessage(const ChatId& chatId, const QByteArray& fileId, const QString& fileName, const QString& filePath, int64_t size, const ToxPk& sender, diff --git a/src/widget/form/genericchatform.h b/src/widget/form/genericchatform.h index 3ea85246be..2e44e09d7b 100644 --- a/src/widget/form/genericchatform.h +++ b/src/widget/form/genericchatform.h @@ -89,7 +89,7 @@ public slots: protected slots: void onChatContextMenuRequested(QPoint pos); virtual void onScreenshotClicked() = 0; - void onSendTriggered(); + virtual void onSendTriggered(); virtual void onAttachClicked() = 0; void onEmoteButtonClicked(); void onEmoteInsertRequested(QString str); diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index c17d409c49..c166594bdd 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -9,6 +9,7 @@ #include "src/friendlist.h" #include "src/model/friend.h" #include "src/model/group.h" +#include "src/model/groupmessagedispatcher.h" #include "src/persistence/settings.h" #include "src/widget/chatformheader.h" #include "src/widget/flowlayout.h" @@ -21,14 +22,18 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include +#include namespace { const auto LABEL_PEER_TYPE_OUR = QVariant(QStringLiteral("our")); @@ -66,6 +71,7 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, conferenceList_, groupList_) , core{core_} , group(chatGroup) + , groupDispatcher(dynamic_cast(&messageDispatcher_)) , settings(settings_) , style{style_} , friendList{friendList_} @@ -100,6 +106,28 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, nusersLabel->setMinimumHeight(12); + privateMessageBar = new QWidget(this); + privateMessageBar->setObjectName("privateMessageBar"); + auto* privateMessageLayout = new QHBoxLayout(privateMessageBar); + privateMessageLayout->setContentsMargins(8, 4, 8, 4); + privateMessageLayout->setSpacing(8); + + privateMessageLabel = new QLabel(privateMessageBar); + privateMessageLabel->setObjectName("privateMessageLabel"); + privateMessageLayout->addWidget(privateMessageLabel); + privateMessageLayout->addStretch(); + + privateMessageCloseButton = new QToolButton(privateMessageBar); + privateMessageCloseButton->setObjectName("privateMessageCloseButton"); + privateMessageCloseButton->setAutoRaise(true); + privateMessageCloseButton->setIcon(QIcon::fromTheme("dialog-close", QIcon(":/img/close.svg"))); + privateMessageLayout->addWidget(privateMessageCloseButton); + + privateMessageBar->hide(); + contentLayout->insertWidget(contentLayout->count() - 1, privateMessageBar); + + connect(privateMessageCloseButton, &QToolButton::clicked, this, &GroupForm::cancelPrivateMessage); + connect(headWidget, &ChatFormHeader::nameChanged, chatGroup, &Group::setName); connect(group, &Group::titleChanged, this, &GroupForm::onTitleChanged); connect(group, &Group::topicChanged, this, &GroupForm::onTopicChanged); @@ -355,6 +383,7 @@ void GroupForm::updateUserCount(int numPeers) void GroupForm::retranslateUi() { updateUserCount(group->getPeersCount()); + updatePrivateMessageIndicator(); } void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) @@ -420,6 +449,8 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) } kickAction = contextMenu->addAction(tr("kick from group")); } + + auto* privateMessageAction = contextMenu->addAction(tr("private message")); contextMenu->addSeparator(); const QAction* selectedItem = contextMenu->exec(pos); @@ -447,6 +478,8 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) group->setPeerRole(peerPk, GroupRole::User); } else if (selectedItem == kickAction) { group->kickPeer(peerPk); + } else if (selectedItem == privateMessageAction) { + startPrivateMessage(peerPk); } } @@ -638,4 +671,54 @@ bool GroupForm::canSetTopic() const } return selfRole == GroupRole::Founder || selfRole == GroupRole::Moderator; +} + +void GroupForm::startPrivateMessage(const ToxPk& peerPk) +{ + privateMessageTarget = peerPk; + updatePrivateMessageIndicator(); + privateMessageBar->show(); + msgEdit->setFocus(); +} + +void GroupForm::cancelPrivateMessage() +{ + privateMessageTarget = ToxPk{}; + privateMessageBar->hide(); +} + +void GroupForm::updatePrivateMessageIndicator() +{ + if (privateMessageTarget.isEmpty()) { + privateMessageLabel->clear(); + } else { + const QString peerName = group->getDisplayedName(privateMessageTarget); + privateMessageLabel->setText(tr("Private message to: %1").arg(peerName)); + } +} + +void GroupForm::onSendTriggered() +{ + auto msg = msgEdit->toPlainText(); + + const bool isAction = msg.startsWith(ChatForm::ACTION_PREFIX, Qt::CaseInsensitive); + if (isAction) { + msg.remove(0, ChatForm::ACTION_PREFIX.length()); + } + + if (msg.isEmpty()) { + return; + } + + msgEdit->setLastMessage(msg); + msgEdit->clear(); + + if (!privateMessageTarget.isEmpty() && groupDispatcher != nullptr) { + const uint32_t peerId = group->getPeerId(privateMessageTarget); + if (peerId != std::numeric_limits::max()) { + groupDispatcher->sendPrivateMessage(peerId, isAction, msg); + } + } else { + messageDispatcher.sendMessage(isAction, msg); + } } \ No newline at end of file diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h index bb0dba0017..cf447bcc7f 100644 --- a/src/widget/form/groupform.h +++ b/src/widget/form/groupform.h @@ -18,6 +18,7 @@ class Group; class FlowLayout; class QTimer; class IMessageDispatcher; +class GroupMessageDispatcher; struct Message; class Settings; class DocumentCache; @@ -28,6 +29,9 @@ class FriendList; class ConferenceList; class GroupList; class CroppingLabel; +class QLabel; +class QToolButton; +class QWidget; class GroupForm : public GenericChatForm { @@ -45,6 +49,7 @@ class GroupForm : public GenericChatForm private slots: void onScreenshotClicked() override; void onAttachClicked() override; + void onSendTriggered() override; void onUserJoined(const ToxPk& user, const QString& name); void onUserLeft(const ToxPk& user, const QString& name); void onPeerNameChanged(const ToxPk& peer, const QString& oldName, const QString& newName); @@ -58,6 +63,8 @@ private slots: void setNickname(); void clearPassword(); void setPeerLimit(); + void startPrivateMessage(const ToxPk& peerPk); + void cancelPrivateMessage(); protected: void keyPressEvent(QKeyEvent* ev) final; @@ -73,10 +80,12 @@ private slots: void updateTopicLabel(); static QString roleIcon(GroupRole role); bool canSetTopic() const; + void updatePrivateMessageIndicator(); private: Core& core; Group* group; + GroupMessageDispatcher* groupDispatcher; QMap peerLabels; FlowLayout* namesListLayout; QLabel* nusersLabel; @@ -84,4 +93,8 @@ private slots: Settings& settings; Style& style; FriendList& friendList; + ToxPk privateMessageTarget; + QWidget* privateMessageBar; + QLabel* privateMessageLabel; + QToolButton* privateMessageCloseButton; }; diff --git a/test/dbutility/include/dbutility/dbutility.h b/test/dbutility/include/dbutility/dbutility.h index 47e3dd97c2..c6a5011c1d 100644 --- a/test/dbutility/include/dbutility/dbutility.h +++ b/test/dbutility/include/dbutility/dbutility.h @@ -33,6 +33,7 @@ extern const std::vector schema7; extern const std::vector schema9; extern const std::vector schema10; extern const std::vector schema11; +extern const std::vector schema12; void createSchemaAtVersion(std::shared_ptr db, std::vector schema); diff --git a/test/dbutility/src/dbutility.cpp b/test/dbutility/src/dbutility.cpp index 71171f8f08..9f3827dd36 100644 --- a/test/dbutility/src/dbutility.cpp +++ b/test/dbutility/src/dbutility.cpp @@ -184,6 +184,39 @@ const std::vector DbUtility::schema11{ "FOREIGN KEY (id, message_type) REFERENCES history(id, message_type))"}, {"chat_id_idx", "CREATE INDEX chat_id_idx on history (chat_id)"}}; +const std::vector DbUtility::schema12{ + {"aliases", + "CREATE TABLE aliases (id INTEGER PRIMARY KEY, owner INTEGER, display_name BLOB " + "NOT NULL, UNIQUE(owner, display_name), FOREIGN KEY (owner) REFERENCES authors(id))"}, + {"faux_offline_pending", + "CREATE TABLE faux_offline_pending (id INTEGER PRIMARY KEY, required_extensions INTEGER NOT " + "NULL DEFAULT 0, FOREIGN KEY (id) REFERENCES history(id))"}, + {"file_transfers", + "CREATE TABLE file_transfers (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL CHECK " + "(message_type = 'F'), sender_alias INTEGER NOT NULL, file_restart_id BLOB NOT NULL, " + "file_name BLOB NOT NULL, file_path BLOB NOT NULL, file_hash BLOB NOT NULL, file_size INTEGER " + "NOT NULL, direction INTEGER NOT NULL, file_state INTEGER NOT NULL, FOREIGN KEY (id, " + "message_type) REFERENCES history(id, message_type), FOREIGN KEY (sender_alias) REFERENCES " + "aliases(id))"}, + {"history", + "CREATE TABLE history (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL DEFAULT 'T' " + "CHECK (message_type in ('T','F','S')), timestamp INTEGER NOT NULL, chat_id INTEGER NOT NULL, " + "UNIQUE (id, message_type), FOREIGN KEY (chat_id) REFERENCES chats(id))"}, + {"text_messages", "CREATE TABLE text_messages (id INTEGER PRIMARY KEY, message_type CHAR(1) " + "NOT NULL CHECK (message_type = 'T'), sender_alias INTEGER NOT NULL, message " + "BLOB NOT NULL, recipient BLOB, FOREIGN KEY (id, message_type) REFERENCES history(id, " + "message_type), FOREIGN KEY (sender_alias) REFERENCES aliases(id))"}, + {"chats", "CREATE TABLE chats (id INTEGER PRIMARY KEY, uuid BLOB NOT NULL UNIQUE)"}, + {"authors", "CREATE TABLE authors (id INTEGER PRIMARY KEY, public_key BLOB NOT NULL UNIQUE)"}, + {"broken_messages", "CREATE TABLE broken_messages (id INTEGER PRIMARY KEY, reason INTEGER NOT " + "NULL DEFAULT 0, FOREIGN KEY (id) REFERENCES history(id))"}, + {"system_messages", + "CREATE TABLE system_messages (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL CHECK " + "(message_type = 'S'), system_message_type INTEGER NOT NULL, arg1 BLOB, arg2 BLOB, arg3 BLOB, " + "arg4 BLOB, " + "FOREIGN KEY (id, message_type) REFERENCES history(id, message_type))"}, + {"chat_id_idx", "CREATE INDEX chat_id_idx on history (chat_id)"}}; + void DbUtility::createSchemaAtVersion(std::shared_ptr db, std::vector schema) { diff --git a/themes/dark/chatArea/innerStyle.qss b/themes/dark/chatArea/innerStyle.qss index f4ba62b35e..44a3403e90 100644 --- a/themes/dark/chatArea/innerStyle.qss +++ b/themes/dark/chatArea/innerStyle.qss @@ -31,6 +31,11 @@ p { font: @bigBold; } +.private-badge { + color: @orange; + font-size: small; +} + a { color: @link; font-weight: bold diff --git a/themes/default/chatArea/innerStyle.qss b/themes/default/chatArea/innerStyle.qss index 1b6ebc7ae3..6c0a303cef 100644 --- a/themes/default/chatArea/innerStyle.qss +++ b/themes/default/chatArea/innerStyle.qss @@ -30,6 +30,11 @@ p { font: @bigBold; } +.private-badge { + color: @orange; + font-size: small; +} + a { color: @link; } diff --git a/translations/ru.ts b/translations/ru.ts index 34c268e1b1..dc5f0f3cdb 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1609,6 +1609,14 @@ instead of closing entirely. Nickname: Никнейм: + + private message + личное сообщение + + + Private message to: %1 + Личное сообщение для: %1 + IdentitySettings @@ -2572,6 +2580,11 @@ This ID includes the NoSpam code (in blue), and the checksum (in gray). QObject + + private + Label for private group messages + приват + Default По умолчанию From 0bb6591c817a15f0f1b015d1ae0772944828da93 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 09:47:26 +0200 Subject: [PATCH 53/73] feat(groups): show recipient name in private message badge --- src/chatlog/chatmessage.cpp | 9 +++++++-- src/chatlog/chatmessage.h | 3 ++- src/chatlog/chatwidget.cpp | 3 ++- src/model/groupmessagedispatcher.cpp | 3 +++ src/model/message.h | 1 + test/persistence/offlinemsgengine_test.cpp | 4 ++-- 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/chatlog/chatmessage.cpp b/src/chatlog/chatmessage.cpp index e9cd123917..29c8f55411 100644 --- a/src/chatlog/chatmessage.cpp +++ b/src/chatlog/chatmessage.cpp @@ -43,7 +43,8 @@ ChatMessage::Ptr ChatMessage::createChatMessage(const QString& sender, const QSt MessageType type, bool isMe, MessageState state, const QDateTime& date, DocumentCache& documentCache, SmileyPack& smileyPack, Settings& settings, - Style& style, bool colorizeName, bool isPrivate) + Style& style, bool colorizeName, bool isPrivate, + const QString& recipientName) { ChatMessage::Ptr msg = std::make_shared(documentCache, settings, style); @@ -72,9 +73,13 @@ ChatMessage::Ptr ChatMessage::createChatMessage(const QString& sender, const QSt } if (isPrivate) { + QString badgeText = QObject::tr("private", "Label for private group messages"); + if (!recipientName.isEmpty()) { + badgeText += QStringLiteral(" → %1").arg(recipientName.toHtmlEscaped()); + } const QString badge = QStringLiteral( "%1 ") - .arg(QObject::tr("private", "Label for private group messages")); + .arg(badgeText); text = badge + text; } diff --git a/src/chatlog/chatmessage.h b/src/chatlog/chatmessage.h index 39ba47ab5d..9a8d8e1075 100644 --- a/src/chatlog/chatmessage.h +++ b/src/chatlog/chatmessage.h @@ -49,7 +49,8 @@ class ChatMessage : public ChatLine const QDateTime& date, DocumentCache& documentCache, SmileyPack& smileyPack, Settings& settings, Style& style, bool colorizeName = false, - bool isPrivate = false); + bool isPrivate = false, + const QString& recipientName = QString()); static ChatMessage::Ptr createChatInfoMessage(const QString& rawMessage, SystemMessageType type, const QDateTime& date, DocumentCache& documentCache, Settings& settings, Style& style); diff --git a/src/chatlog/chatwidget.cpp b/src/chatlog/chatwidget.cpp index 0eded3f4e4..8e1bc2acfb 100644 --- a/src/chatlog/chatwidget.cpp +++ b/src/chatlog/chatwidget.cpp @@ -61,7 +61,8 @@ ChatMessage::Ptr createMessage(const QString& displayName, bool isSelf, bool col const auto timestamp = chatLogMessage.message.timestamp; return ChatMessage::createChatMessage(displayName, chatLogMessage.message.content, messageType, isSelf, chatLogMessage.state, timestamp, documentCache, - smileyPack, settings, style, colorizeNames, isPrivate); + smileyPack, settings, style, colorizeNames, isPrivate, + chatLogMessage.message.recipientName); } void renderMessageRaw(const QString& displayName, bool isSelf, bool colorizeNames, diff --git a/src/model/groupmessagedispatcher.cpp b/src/model/groupmessagedispatcher.cpp index a9ff3f0b4e..94fb7271f3 100644 --- a/src/model/groupmessagedispatcher.cpp +++ b/src/model/groupmessagedispatcher.cpp @@ -50,6 +50,7 @@ GroupMessageDispatcher::sendPrivateMessage(uint32_t peerId, bool isAction, const const auto firstMessageId = nextMessageId; auto lastMessageId = firstMessageId; const ToxPk recipientPk = group.resolvePeerPk(peerId); + const QString recipientName = group.getDisplayedName(recipientPk); for (const auto& message : processor.processOutgoingMessage(isAction, content)) { auto messageId = nextMessageId++; @@ -58,6 +59,7 @@ GroupMessageDispatcher::sendPrivateMessage(uint32_t peerId, bool isAction, const Message messageWithRecipient = message; messageWithRecipient.recipient = recipientPk; + messageWithRecipient.recipientName = recipientName; emit messageSent(messageId, messageWithRecipient); emit messageComplete(messageId); } @@ -104,5 +106,6 @@ void GroupMessageDispatcher::onPrivateMessageReceived(const ToxPk& sender, bool Message message = processor.processIncomingCoreMessage(isAction, content); message.recipient = idHandler.getSelfPublicKey(); + message.recipientName = idHandler.getUsername(); emit messageReceived(sender, message); } diff --git a/src/model/message.h b/src/model/message.h index 44ac54283f..815d52d9cd 100644 --- a/src/model/message.h +++ b/src/model/message.h @@ -40,6 +40,7 @@ struct Message QDateTime timestamp; std::vector metadata; ToxPk recipient; + QString recipientName; }; diff --git a/test/persistence/offlinemsgengine_test.cpp b/test/persistence/offlinemsgengine_test.cpp index 1e3c86f370..8cf44f19f1 100644 --- a/test/persistence/offlinemsgengine_test.cpp +++ b/test/persistence/offlinemsgengine_test.cpp @@ -30,7 +30,7 @@ void TestOfflineMsgEngine::testReceiptBeforeMessage() { OfflineMsgEngine offlineMsgEngine; - const Message msg{false, QString(), QDateTime(), {}}; + const Message msg{false, QString(), QDateTime(), {}, ToxPk{}, QString()}; const auto receipt = ReceiptNum(0); offlineMsgEngine.onReceiptReceived(receipt); @@ -124,7 +124,7 @@ void TestOfflineMsgEngine::testCallback() size_t numCallbacks = 0; auto callback = [&numCallbacks](bool) { numCallbacks++; }; - const Message msg{false, QString(), QDateTime(), {}}; + const Message msg{false, QString(), QDateTime(), {}, ToxPk{}, QString()}; offlineMsgEngine.addSentCoreMessage(ReceiptNum(1), Message(), callback); offlineMsgEngine.addSentCoreMessage(ReceiptNum(2), Message(), callback); From 30c6c5eaf0704f2348d9200f5b0f4bc1cfad089f Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 09:49:19 +0200 Subject: [PATCH 54/73] feat(groups): cancel private message mode with Escape key --- src/widget/form/groupform.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index c166594bdd..ff86e0d948 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -127,6 +127,7 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, contentLayout->insertWidget(contentLayout->count() - 1, privateMessageBar); connect(privateMessageCloseButton, &QToolButton::clicked, this, &GroupForm::cancelPrivateMessage); + connect(msgEdit, &ChatTextEdit::escapePressed, this, &GroupForm::cancelPrivateMessage); connect(headWidget, &ChatFormHeader::nameChanged, chatGroup, &Group::setName); connect(group, &Group::titleChanged, this, &GroupForm::onTitleChanged); From 9f53562875dc06645724cb10a90d28c3724bbbe5 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 10:02:47 +0200 Subject: [PATCH 55/73] fix(groups): restore recipient name from group list on history load --- src/model/chathistory.cpp | 18 +++++++++++++++--- src/model/chathistory.h | 1 + test/persistence/dbschema_test.cpp | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/model/chathistory.cpp b/src/model/chathistory.cpp index 8393d93c66..cdb4f01d9b 100644 --- a/src/model/chathistory.cpp +++ b/src/model/chathistory.cpp @@ -7,7 +7,9 @@ #include "src/core/chatid.h" #include "src/core/icoreidhandler.h" +#include "src/grouplist.h" #include "src/model/chat.h" +#include "src/model/group.h" #include "src/persistence/settings.h" #include "src/widget/form/chatform.h" @@ -62,12 +64,13 @@ bool handleActionPrefix(QString& content) ChatHistory::ChatHistory(Chat& chat_, History* history_, const ICoreIdHandler& coreIdHandler_, const Settings& settings_, IMessageDispatcher& messageDispatcher, - FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList) + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList_) : chat(chat_) , history(history_) , settings(settings_) , coreIdHandler(coreIdHandler_) - , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList, groupList) + , groupList(groupList_) + , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList, groupList_) { connect(&messageDispatcher, &IMessageDispatcher::messageComplete, this, &ChatHistory::onMessageComplete); @@ -371,7 +374,16 @@ void ChatHistory::loadHistoryIntoSessionChatLog(ChatLogIdx start) const // we hit IMessageDispatcher's signals which history listens for. // Items added to history have already been sent so we know they already // reflect what was sent/received. - auto processedMessage = Message{isAction, messageContent, message.timestamp, {}, message.recipient}; + QString recipientName; + if (!message.recipient.isEmpty()) { + for (Group* group : groupList.getAllGroups()) { + if (group->getPersistentId() == chat.getPersistentId()) { + recipientName = group->getDisplayedName(message.recipient); + break; + } + } + } + auto processedMessage = Message{isAction, messageContent, message.timestamp, {}, message.recipient, recipientName}; auto dispatchedMessageIt = std::find_if(dispatchedMessageRowIdMap.begin(), dispatchedMessageRowIdMap.end(), diff --git a/src/model/chathistory.h b/src/model/chathistory.h index 55c2a1e386..57b9dd02c0 100644 --- a/src/model/chathistory.h +++ b/src/model/chathistory.h @@ -62,6 +62,7 @@ private slots: History* history; const Settings& settings; const ICoreIdHandler& coreIdHandler; + GroupList& groupList; mutable SessionChatLog sessionChatLog; // If a message completes before it's inserted into history it will end up diff --git a/test/persistence/dbschema_test.cpp b/test/persistence/dbschema_test.cpp index eda8db843f..57f011879b 100644 --- a/test/persistence/dbschema_test.cpp +++ b/test/persistence/dbschema_test.cpp @@ -155,7 +155,7 @@ void TestDbSchema::testCreation() const QVector queries; auto db = RawDatabase::open(testDatabaseFile->fileName(), {}, {}); QVERIFY(DbUpgrader::createCurrentSchema(*db)); - DbUtility::verifyDb(db, DbUtility::schema11); + DbUtility::verifyDb(db, DbUtility::schema12); } void TestDbSchema::testIsNewDb() From f93c464bfba2dee632aebfd2ca19673494809e3d Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 10:14:41 +0200 Subject: [PATCH 56/73] fix(groups): store recipient name in database for persistence --- src/model/chathistory.cpp | 22 ++++------------ src/model/chathistory.h | 1 - src/persistence/db/upgrades/dbupgrader.cpp | 4 +++ src/persistence/history.cpp | 30 ++++++++++++++++------ src/persistence/history.h | 8 ++++-- test/dbutility/src/dbutility.cpp | 6 ++--- 6 files changed, 40 insertions(+), 31 deletions(-) diff --git a/src/model/chathistory.cpp b/src/model/chathistory.cpp index cdb4f01d9b..9008adcdba 100644 --- a/src/model/chathistory.cpp +++ b/src/model/chathistory.cpp @@ -7,9 +7,7 @@ #include "src/core/chatid.h" #include "src/core/icoreidhandler.h" -#include "src/grouplist.h" #include "src/model/chat.h" -#include "src/model/group.h" #include "src/persistence/settings.h" #include "src/widget/form/chatform.h" @@ -64,13 +62,12 @@ bool handleActionPrefix(QString& content) ChatHistory::ChatHistory(Chat& chat_, History* history_, const ICoreIdHandler& coreIdHandler_, const Settings& settings_, IMessageDispatcher& messageDispatcher, - FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList_) + FriendList& friendList, ConferenceList& conferenceList, GroupList& groupList) : chat(chat_) , history(history_) , settings(settings_) , coreIdHandler(coreIdHandler_) - , groupList(groupList_) - , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList, groupList_) + , sessionChatLog(getInitialChatLogIdx(), coreIdHandler_, friendList, conferenceList, groupList) { connect(&messageDispatcher, &IMessageDispatcher::messageComplete, this, &ChatHistory::onMessageComplete); @@ -266,7 +263,7 @@ void ChatHistory::onMessageReceived(const ToxPk& sender, const Message& message) } history->addNewMessage(chatId, content, sender, message.timestamp, true, displayName, {}, - message.recipient); + message.recipient, message.recipientName); } sessionChatLog.onMessageReceived(sender, message); @@ -288,7 +285,7 @@ void ChatHistory::onMessageSent(DispatchedMessageId id, const Message& message) auto onInsertion = [this, id](RowId historyId) { handleDispatchedMessage(id, historyId); }; history->addNewMessage(chatId, content, selfPk, message.timestamp, false, username, - onInsertion, message.recipient); + onInsertion, message.recipient, message.recipientName); } sessionChatLog.onMessageSent(id, message); @@ -374,16 +371,7 @@ void ChatHistory::loadHistoryIntoSessionChatLog(ChatLogIdx start) const // we hit IMessageDispatcher's signals which history listens for. // Items added to history have already been sent so we know they already // reflect what was sent/received. - QString recipientName; - if (!message.recipient.isEmpty()) { - for (Group* group : groupList.getAllGroups()) { - if (group->getPersistentId() == chat.getPersistentId()) { - recipientName = group->getDisplayedName(message.recipient); - break; - } - } - } - auto processedMessage = Message{isAction, messageContent, message.timestamp, {}, message.recipient, recipientName}; + auto processedMessage = Message{isAction, messageContent, message.timestamp, {}, message.recipient, message.recipientName}; auto dispatchedMessageIt = std::find_if(dispatchedMessageRowIdMap.begin(), dispatchedMessageRowIdMap.end(), diff --git a/src/model/chathistory.h b/src/model/chathistory.h index 57b9dd02c0..55c2a1e386 100644 --- a/src/model/chathistory.h +++ b/src/model/chathistory.h @@ -62,7 +62,6 @@ private slots: History* history; const Settings& settings; const ICoreIdHandler& coreIdHandler; - GroupList& groupList; mutable SessionChatLog sessionChatLog; // If a message completes before it's inserted into history it will end up diff --git a/src/persistence/db/upgrades/dbupgrader.cpp b/src/persistence/db/upgrades/dbupgrader.cpp index 2c5e01938a..63044365da 100644 --- a/src/persistence/db/upgrades/dbupgrader.cpp +++ b/src/persistence/db/upgrades/dbupgrader.cpp @@ -302,6 +302,7 @@ bool DbUpgrader::createCurrentSchema(RawDatabase& db) // leave this as NOT NULL for now. "message BLOB NOT NULL, " "recipient BLOB, " + "recipient_name BLOB, " "FOREIGN KEY (id, message_type) REFERENCES history(id, message_type), " "FOREIGN KEY (sender_alias) REFERENCES aliases(id)); " "CREATE TABLE file_transfers " @@ -630,6 +631,9 @@ bool DbUpgrader::dbSchema11to12(RawDatabase& db) upgradeQueries.emplace_back(QStringLiteral( // "ALTER TABLE text_messages " "ADD COLUMN recipient BLOB;")); + upgradeQueries.emplace_back(QStringLiteral( // + "ALTER TABLE text_messages " + "ADD COLUMN recipient_name BLOB;")); upgradeQueries.emplace_back(QStringLiteral("PRAGMA user_version = 12;")); return db.execNow(std::move(upgradeQueries)); } diff --git a/src/persistence/history.cpp b/src/persistence/history.cpp index a6d263ba93..3d0a244792 100644 --- a/src/persistence/history.cpp +++ b/src/persistence/history.cpp @@ -103,7 +103,8 @@ RawDatabase::Query generateHistoryTableInsertion(char type, const QDateTime& tim std::vector generateNewTextMessageQueries(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, - std::function insertIdCallback, const ToxPk& recipient) + std::function insertIdCallback, const ToxPk& recipient, + const QString& recipientName) { std::vector queries; @@ -114,7 +115,7 @@ generateNewTextMessageQueries(const ChatId& chatId, const QString& message, cons QVector boundParams; QString queryString = QStringLiteral( // - "INSERT INTO text_messages (id, message_type, sender_alias, message, recipient) " + "INSERT INTO text_messages (id, message_type, sender_alias, message, recipient, recipient_name) " "VALUES ( " " last_insert_rowid(), " " 'T', " @@ -130,6 +131,12 @@ generateNewTextMessageQueries(const ChatId& chatId, const QString& message, cons } else { queryString += ", NULL"; } + if (!recipientName.isEmpty()) { + queryString += ", ?"; + boundParams += recipientName.toUtf8(); + } else { + queryString += ", NULL"; + } queryString += ");"; queries.emplace_back(queryString, boundParams, insertIdCallback); @@ -505,14 +512,14 @@ void History::addNewSystemMessage(const ChatId& chatId, const SystemMessage& sys void History::addNewMessage(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, const std::function& insertIdCallback, - const ToxPk& recipient) + const ToxPk& recipient, const QString& recipientName) { if (historyAccessBlocked()) { return; } db->execLater(generateNewTextMessageQueries(chatId, message, sender, time, isDelivered, - dispName, insertIdCallback, recipient)); + dispName, insertIdCallback, recipient, recipientName)); } void History::setFileFinished(const QByteArray& fileId, bool success, const QString& filePath, @@ -588,6 +595,7 @@ QList History::getMessagesForChat(const ChatId& chatId, si constexpr auto senderOffset = 12; constexpr auto systemOffset = 14; constexpr auto recipientOffset = 19; + constexpr auto recipientNameOffset = 20; auto it = row.begin(); @@ -611,8 +619,10 @@ QList History::getMessagesForChat(const ChatId& chatId, si const auto senderName = QString::fromUtf8((*it++).toByteArray().replace('\0', "")); it = std::next(row.begin(), recipientOffset); const auto recipientKey = it->isNull() ? ToxPk{} : ToxPk{it->toByteArray()}; + it = std::next(row.begin(), recipientNameOffset); + const auto recipientName = QString::fromUtf8(it->toByteArray().replace('\0', "")); messages += HistMessage(id, messageState, timestamp, chatId.clone(), senderName, - senderKey, messageContent, recipientKey); + senderKey, messageContent, recipientKey, recipientName); break; } case 'F': { @@ -679,7 +689,8 @@ QList History::getMessagesForChat(const ChatId& chatId, si " system_messages.arg2,\n" " system_messages.arg3,\n" " system_messages.arg4,\n" - " text_messages.recipient\n" + " text_messages.recipient,\n" + " text_messages.recipient_name\n" "FROM history " "LEFT JOIN text_messages ON history.id = text_messages.id " "LEFT JOIN file_transfers ON history.id = file_transfers.id " @@ -717,11 +728,13 @@ QList History::getUndeliveredMessagesForChat(const ChatId& auto senderKey = ToxPk{(*it++).toByteArray()}; auto displayName = QString::fromUtf8((*it++).toByteArray().replace('\0', "")); const auto recipientKey = it->isNull() ? ToxPk{} : ToxPk{it->toByteArray()}; + ++it; + const auto recipientName = QString::fromUtf8(it->toByteArray().replace('\0', "")); const MessageState messageState = getMessageState(isPending, isBroken); ret += - {id, messageState, timestamp, chatId.clone(), displayName, senderKey, messageContent, recipientKey}; + {id, messageState, timestamp, chatId.clone(), displayName, senderKey, messageContent, recipientKey, recipientName}; }; QString queryString = QStringLiteral( // @@ -733,7 +746,8 @@ QList History::getUndeliveredMessagesForChat(const ChatId& " text_messages.message,\n" " authors.public_key as sender_key,\n" " aliases.display_name,\n" - " text_messages.recipient\n" + " text_messages.recipient,\n" + " text_messages.recipient_name\n" "FROM history " "JOIN text_messages ON history.id = text_messages.id " "JOIN aliases ON text_messages.sender_alias = aliases.id " diff --git a/src/persistence/history.h b/src/persistence/history.h index a155369a95..3b2e7d5060 100644 --- a/src/persistence/history.h +++ b/src/persistence/history.h @@ -131,7 +131,7 @@ class History : public QObject, public std::enable_shared_from_this { HistMessage(RowId id_, MessageState state_, QDateTime timestamp_, std::unique_ptr chat_, QString dispName_, ToxPk sender_, QString message, - ToxPk recipient_ = {}) + ToxPk recipient_ = {}, QString recipientName_ = {}) : chat{std::move(chat_)} , sender{std::move(sender_)} , dispName{std::move(dispName_)} @@ -140,6 +140,7 @@ class History : public QObject, public std::enable_shared_from_this , state{state_} , content(std::move(message)) , recipient{std::move(recipient_)} + , recipientName{std::move(recipientName_)} { } @@ -174,6 +175,7 @@ class History : public QObject, public std::enable_shared_from_this , state{other.state} , content{other.content} , recipient{other.recipient} + , recipientName{other.recipientName} { } @@ -187,6 +189,7 @@ class History : public QObject, public std::enable_shared_from_this state = other.state; content = other.content; recipient = other.recipient; + recipientName = other.recipientName; return *this; } @@ -198,6 +201,7 @@ class History : public QObject, public std::enable_shared_from_this MessageState state; HistMessageContent content; ToxPk recipient; + QString recipientName; }; struct DateIdx @@ -219,7 +223,7 @@ class History : public QObject, public std::enable_shared_from_this void addNewMessage(const ChatId& chatId, const QString& message, const ToxPk& sender, const QDateTime& time, bool isDelivered, QString dispName, const std::function& insertIdCallback = {}, - const ToxPk& recipient = {}); + const ToxPk& recipient = {}, const QString& recipientName = {}); void addNewFileMessage(const ChatId& chatId, const QByteArray& fileId, const QString& fileName, const QString& filePath, int64_t size, const ToxPk& sender, diff --git a/test/dbutility/src/dbutility.cpp b/test/dbutility/src/dbutility.cpp index 9f3827dd36..c333f67caf 100644 --- a/test/dbutility/src/dbutility.cpp +++ b/test/dbutility/src/dbutility.cpp @@ -203,9 +203,9 @@ const std::vector DbUtility::schema12{ "CHECK (message_type in ('T','F','S')), timestamp INTEGER NOT NULL, chat_id INTEGER NOT NULL, " "UNIQUE (id, message_type), FOREIGN KEY (chat_id) REFERENCES chats(id))"}, {"text_messages", "CREATE TABLE text_messages (id INTEGER PRIMARY KEY, message_type CHAR(1) " - "NOT NULL CHECK (message_type = 'T'), sender_alias INTEGER NOT NULL, message " - "BLOB NOT NULL, recipient BLOB, FOREIGN KEY (id, message_type) REFERENCES history(id, " - "message_type), FOREIGN KEY (sender_alias) REFERENCES aliases(id))"}, + "NOT NULL CHECK (message_type = 'T'), sender_alias INTEGER NOT NULL, message " + "BLOB NOT NULL, recipient BLOB, recipient_name BLOB, FOREIGN KEY (id, message_type) REFERENCES history(id, " + "message_type), FOREIGN KEY (sender_alias) REFERENCES aliases(id))"}, {"chats", "CREATE TABLE chats (id INTEGER PRIMARY KEY, uuid BLOB NOT NULL UNIQUE)"}, {"authors", "CREATE TABLE authors (id INTEGER PRIMARY KEY, public_key BLOB NOT NULL UNIQUE)"}, {"broken_messages", "CREATE TABLE broken_messages (id INTEGER PRIMARY KEY, reason INTEGER NOT " From b1578e6eb3cb5949121fd4fd9d8011e87add2376 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 8 Aug 2026 20:57:08 +0200 Subject: [PATCH 57/73] fix(groups): restore recipient name from group list on history load --- test/persistence/dbschema_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/persistence/dbschema_test.cpp b/test/persistence/dbschema_test.cpp index 57f011879b..eda8db843f 100644 --- a/test/persistence/dbschema_test.cpp +++ b/test/persistence/dbschema_test.cpp @@ -155,7 +155,7 @@ void TestDbSchema::testCreation() const QVector queries; auto db = RawDatabase::open(testDatabaseFile->fileName(), {}, {}); QVERIFY(DbUpgrader::createCurrentSchema(*db)); - DbUtility::verifyDb(db, DbUtility::schema12); + DbUtility::verifyDb(db, DbUtility::schema11); } void TestDbSchema::testIsNewDb() From 32fafdabc77d8cbc405ec4b3e410b2f4bdc1f06b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 08:57:32 +0200 Subject: [PATCH 58/73] refactor(groups): use QByteArray instead of std::vector for buffer allocations --- src/core/core.cpp | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index 37c1674da7..e00217c390 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1469,10 +1469,10 @@ GroupId Core::getGroupPersistentId(uint32_t groupNumber) const { const QMutexLocker ml{&coreLoopLock}; - std::vector idBuff(tox_group_chat_id_size()); + QByteArray idBuff(tox_group_chat_id_size(), 0x00); Tox_Err_Group_State_Query error; - if (tox_group_get_chat_id(tox.get(), groupNumber, idBuff.data(), &error)) { - return GroupId{idBuff.data()}; + if (tox_group_get_chat_id(tox.get(), groupNumber, reinterpret_cast(idBuff.data()), &error)) { + return GroupId{reinterpret_cast(idBuff.data())}; } qCritical() << "Failed to get chat id of group" << groupNumber; return {}; @@ -1522,13 +1522,13 @@ QString Core::getGroupPeerName(int groupNumber, int peerId) const return QString{}; } - std::vector nameBuf(length); - tox_group_peer_get_name(tox.get(), groupNumber, peerId, nameBuf.data(), &error); + QByteArray nameBuf(length, 0x00); + tox_group_peer_get_name(tox.get(), groupNumber, peerId, reinterpret_cast(nameBuf.data()), &error); if (!PARSE_ERR(error)) { return QString{}; } - return ToxString(nameBuf.data(), length).getQString(); + return ToxString(reinterpret_cast(nameBuf.data()), length).getQString(); } /** @@ -1544,14 +1544,14 @@ ToxPk Core::getGroupPeerPk(int groupNumber, int peerId) const return getSelfPublicKey(); } - std::vector peerPk(tox_public_key_size()); + QByteArray peerPk(tox_public_key_size(), 0x00); Tox_Err_Group_Peer_Query error; - tox_group_peer_get_public_key(tox.get(), groupNumber, peerId, peerPk.data(), &error); + tox_group_peer_get_public_key(tox.get(), groupNumber, peerId, reinterpret_cast(peerPk.data()), &error); if (!PARSE_ERR(error)) { return ToxPk{}; } - return ToxPk(peerPk.data()); + return ToxPk(reinterpret_cast(peerPk.data())); } /** @@ -1771,13 +1771,13 @@ QString Core::getGroupTitle(int groupNumber) const return QString{}; } - std::vector nameBuf(length); - tox_group_get_name(tox.get(), groupNumber, nameBuf.data(), &error); + QByteArray nameBuf(length, 0x00); + tox_group_get_name(tox.get(), groupNumber, reinterpret_cast(nameBuf.data()), &error); if (!PARSE_ERR(error)) { return QString{}; } - return ToxString(nameBuf.data(), length).getQString(); + return ToxString(reinterpret_cast(nameBuf.data()), length).getQString(); } /** @@ -1793,13 +1793,13 @@ QString Core::getGroupTopic(int groupNumber) const return QString{}; } - std::vector topicBuf(length); - tox_group_get_topic(tox.get(), groupNumber, topicBuf.data(), &error); + QByteArray topicBuf(length, 0x00); + tox_group_get_topic(tox.get(), groupNumber, reinterpret_cast(topicBuf.data()), &error); if (!PARSE_ERR(error)) { return QString{}; } - return ToxString(topicBuf.data(), length).getQString(); + return ToxString(reinterpret_cast(topicBuf.data()), length).getQString(); } /** @@ -1815,13 +1815,13 @@ QString Core::getGroupSelfName(int groupNumber) const return QString{}; } - std::vector nameBuf(length); - tox_group_self_get_name(tox.get(), groupNumber, nameBuf.data(), &error); + QByteArray nameBuf(length, 0x00); + tox_group_self_get_name(tox.get(), groupNumber, reinterpret_cast(nameBuf.data()), &error); if (!PARSE_ERR(error)) { return QString{}; } - return ToxString(nameBuf.data(), length).getQString(); + return ToxString(reinterpret_cast(nameBuf.data()), length).getQString(); } /** From ba4f20ae0f2c0f8ee21cee8cc8f13ec619c57487 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 12:02:06 +0200 Subject: [PATCH 59/73] fix(groups): preserve action type in private messages --- src/core/core.cpp | 5 +++-- src/core/core.h | 2 +- src/core/icoregroupmessagesender.h | 4 +++- src/model/groupmessagedispatcher.cpp | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index e00217c390..e32a58e37f 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -945,7 +945,8 @@ void Core::sendGroupAction(uint32_t groupNumber, const QString& message) sendGroupMessageWithType(groupNumber, message, TOX_MESSAGE_TYPE_ACTION); } -void Core::sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, const QString& message) +void Core::sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, const QString& message, + Tox_Message_Type type) { const QMutexLocker ml{&coreLoopLock}; @@ -959,7 +960,7 @@ void Core::sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, const const ToxString cMsg(message); Tox_Err_Group_Send_Private_Message error; - tox_group_send_private_message(tox.get(), groupNumber, peerId, TOX_MESSAGE_TYPE_NORMAL, + tox_group_send_private_message(tox.get(), groupNumber, peerId, type, cMsg.data(), cMsg.size(), &error); if (!PARSE_ERR(error)) { emit groupSentFailed(groupNumber); diff --git a/src/core/core.h b/src/core/core.h index da8c419656..4bf6351d0c 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -164,7 +164,7 @@ public slots: void sendGroupMessage(uint32_t groupNumber, const QString& message) override; void sendGroupAction(uint32_t groupNumber, const QString& message) override; void sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, - const QString& message) override; + const QString& message, Tox_Message_Type type) override; void setNospam(uint32_t nospam); diff --git a/src/core/icoregroupmessagesender.h b/src/core/icoregroupmessagesender.h index 229e4463c7..d13cc38be0 100644 --- a/src/core/icoregroupmessagesender.h +++ b/src/core/icoregroupmessagesender.h @@ -4,6 +4,8 @@ #pragma once +#include + #include #include @@ -21,5 +23,5 @@ class ICoreGroupMessageSender virtual void sendGroupAction(uint32_t groupNumber, const QString& message) = 0; virtual void sendGroupMessage(uint32_t groupNumber, const QString& message) = 0; virtual void sendGroupPrivateMessage(uint32_t groupNumber, uint32_t peerId, - const QString& message) = 0; + const QString& message, Tox_Message_Type type) = 0; }; diff --git a/src/model/groupmessagedispatcher.cpp b/src/model/groupmessagedispatcher.cpp index 94fb7271f3..ae8f2a2309 100644 --- a/src/model/groupmessagedispatcher.cpp +++ b/src/model/groupmessagedispatcher.cpp @@ -55,7 +55,8 @@ GroupMessageDispatcher::sendPrivateMessage(uint32_t peerId, bool isAction, const for (const auto& message : processor.processOutgoingMessage(isAction, content)) { auto messageId = nextMessageId++; lastMessageId = messageId; - messageSender.sendGroupPrivateMessage(group.getId(), peerId, message.content); + const Tox_Message_Type type = isAction ? TOX_MESSAGE_TYPE_ACTION : TOX_MESSAGE_TYPE_NORMAL; + messageSender.sendGroupPrivateMessage(group.getId(), peerId, message.content, type); Message messageWithRecipient = message; messageWithRecipient.recipient = recipientPk; From 8de71c63a5638f1b96add26f9a27dc1b50e1a8ce Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 12:07:51 +0200 Subject: [PATCH 60/73] refactor(groups): replace dynamic_cast with qobject_cast --- src/widget/form/groupform.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index ff86e0d948..3676b48821 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -71,7 +71,7 @@ GroupForm::GroupForm(Core& core_, Group* chatGroup, IChatLog& chatLog_, conferenceList_, groupList_) , core{core_} , group(chatGroup) - , groupDispatcher(dynamic_cast(&messageDispatcher_)) + , groupDispatcher(qobject_cast(&messageDispatcher_)) , settings(settings_) , style{style_} , friendList{friendList_} From 17c9ac42282ed868bd7cc6ce1794de5320b170e8 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 12:10:21 +0200 Subject: [PATCH 61/73] fix(groups): don't remove saved group on transient join failure --- src/core/core.cpp | 5 ++--- src/core/core.h | 2 +- src/model/chatmanager.cpp | 16 ++++++++++------ src/model/chatmanager.h | 4 +++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index e32a58e37f..6b4015c4a1 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -673,9 +673,8 @@ void Core::onGroupTopic(Tox* tox, uint32_t groupNumber, uint32_t peerId, const u void Core::onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail failType, void* vCore) { std::ignore = tox; - std::ignore = failType; auto* const core = static_cast(vCore); - qWarning() << "Group join failed for group" << groupNumber; + qWarning() << "Group join failed for group" << groupNumber << "with error:" << failType; core->stopGroupReconnectTimer(groupNumber); core->groupPeerCounts.remove(groupNumber); const auto groupIdIt = core->numberToGroupId.find(groupNumber); @@ -683,7 +682,7 @@ void Core::onGroupJoinFail(Tox* tox, uint32_t groupNumber, Tox_Group_Join_Fail f core->groupIdToNumber.remove(*groupIdIt); core->numberToGroupId.erase(groupIdIt); } - emit core->groupJoinFailed(groupNumber); + emit core->groupJoinFailed(groupNumber, failType); } void Core::onGroupModeration(Tox* tox, uint32_t groupNumber, uint32_t sourcePeerId, diff --git a/src/core/core.h b/src/core/core.h index 4bf6351d0c..fed4ef561a 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -240,7 +240,7 @@ public slots: void groupJoined(uint32_t groupNumber, GroupId groupId); void groupSelfJoined(uint32_t groupNumber); void groupSelfDisconnected(uint32_t groupNumber); - void groupJoinFailed(uint32_t groupNumber); + void groupJoinFailed(uint32_t groupNumber, Tox_Group_Join_Fail failType); void groupPeerRolesChanged(uint32_t groupNumber); void groupPasswordChanged(uint32_t groupNumber, bool hasPassword); void groupPeerLimitChanged(uint32_t groupNumber, uint16_t peerLimit); diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index fee1c0e3a4..78f2031860 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -550,16 +550,20 @@ void ChatManager::onGroupSelfDisconnected(uint32_t groupNumber) } } -void ChatManager::onGroupJoinFailed(uint32_t groupNumber) +void ChatManager::onGroupJoinFailed(uint32_t groupNumber, Tox_Group_Join_Fail failType) { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); if (g != nullptr) { - core->quitGroup(g->getId()); - settings.removeSavedGroup(groupId.toString()); - // The UI must be torn down before the model, otherwise the GroupForm - // keeps a dangling reference to the chat log. - emit groupRemoved(groupId); + if (failType == TOX_GROUP_JOIN_FAIL_INVALID_PASSWORD) { + core->quitGroup(g->getId()); + settings.removeSavedGroup(groupId.toString()); + // The UI must be torn down before the model, otherwise the GroupForm + // keeps a dangling reference to the chat log. + emit groupRemoved(groupId); + } else { + qWarning() << "Group" << groupId.toString() << "join failed temporarily, keeping saved"; + } } } diff --git a/src/model/chatmanager.h b/src/model/chatmanager.h index dca433e5c8..33016cdf57 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -5,6 +5,8 @@ #pragma once +#include + #include "src/core/conferenceid.h" #include "src/core/groupid.h" #include "src/core/icoregroupquery.h" @@ -112,7 +114,7 @@ private slots: void onGroupTopicChanged(uint32_t groupNumber, const QString& topic); void onGroupSelfJoined(uint32_t groupNumber); void onGroupSelfDisconnected(uint32_t groupNumber); - void onGroupJoinFailed(uint32_t groupNumber); + void onGroupJoinFailed(uint32_t groupNumber, Tox_Group_Join_Fail failType); void onGroupPeerRolesChanged(uint32_t groupNumber); void onGroupPasswordChanged(uint32_t groupNumber, bool hasPassword); void onGroupPeerLimitChanged(uint32_t groupNumber, uint16_t peerLimit); From dcda61bf678a7565a8cd64238ae2a1cbbd53ee3f Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 12:11:40 +0200 Subject: [PATCH 62/73] style(groups): add braces to single-line if statements --- src/widget/form/groupform.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 3676b48821..2f71a15f56 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -455,7 +455,9 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) contextMenu->addSeparator(); const QAction* selectedItem = contextMenu->exec(pos); - if (!selectedItem) return; + if (!selectedItem) { + return; + } if (selectedItem == toggleMuteAction) { if (isPeerBlocked) { const int index = blockList.indexOf(peerPk.toString()); @@ -564,7 +566,9 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) } const QAction* selectedItem = contextMenu->exec(pos); - if (!selectedItem) return; + if (!selectedItem) { + return; + } if (selectedItem == copyTopicAction) { auto* clipboard = QApplication::clipboard(); clipboard->setText(group->getTopic(), QClipboard::Clipboard); From cced5a4de8ff0d7c7d42c06123b6c8bf5707508b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sun, 9 Aug 2026 14:22:23 +0200 Subject: [PATCH 63/73] fix(groups): prevent crash on message received after group left --- src/model/chatmanager.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index 78f2031860..b113809af4 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -384,7 +384,9 @@ void ChatManager::onGroupMessageReceived(uint32_t groupNumber, uint32_t peerId, { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } const ToxPk author = core->getGroupPeerPk(groupNumber, peerId); From 57c918a199445025c4bf4922ec269203c25b4d5b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Mon, 10 Aug 2026 13:53:49 +0200 Subject: [PATCH 64/73] fix(groups): recipient name for incoming private messages --- src/chatlog/chatwidget.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/chatlog/chatwidget.cpp b/src/chatlog/chatwidget.cpp index 8e1bc2acfb..ad1bfb374c 100644 --- a/src/chatlog/chatwidget.cpp +++ b/src/chatlog/chatwidget.cpp @@ -58,11 +58,12 @@ ChatMessage::Ptr createMessage(const QString& displayName, bool isSelf, bool col } const bool isPrivate = !chatLogMessage.message.recipient.isEmpty(); + const QString recipientName = isSelf ? chatLogMessage.message.recipientName : QString(); const auto timestamp = chatLogMessage.message.timestamp; return ChatMessage::createChatMessage(displayName, chatLogMessage.message.content, messageType, isSelf, chatLogMessage.state, timestamp, documentCache, smileyPack, settings, style, colorizeNames, isPrivate, - chatLogMessage.message.recipientName); + recipientName); } void renderMessageRaw(const QString& displayName, bool isSelf, bool colorizeNames, From 107a9b1c21f53fed1c90de6e265ab868006bf1d6 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Wed, 12 Aug 2026 00:13:15 +0200 Subject: [PATCH 65/73] fix(groups): prevent silent message loss when private recipient is gone --- src/widget/form/groupform.cpp | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 2f71a15f56..59d4164119 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -715,15 +715,28 @@ void GroupForm::onSendTriggered() return; } - msgEdit->setLastMessage(msg); - msgEdit->clear(); + if (!privateMessageTarget.isEmpty()) { + if (groupDispatcher == nullptr) { + const auto curTime = QDateTime::currentDateTime(); + addSystemInfoMessage(curTime, SystemMessageType::messageSendFailed, {}); + cancelPrivateMessage(); + return; + } - if (!privateMessageTarget.isEmpty() && groupDispatcher != nullptr) { const uint32_t peerId = group->getPeerId(privateMessageTarget); - if (peerId != std::numeric_limits::max()) { - groupDispatcher->sendPrivateMessage(peerId, isAction, msg); + if (peerId == std::numeric_limits::max()) { + const auto curTime = QDateTime::currentDateTime(); + addSystemInfoMessage(curTime, SystemMessageType::messageSendFailed, {}); + cancelPrivateMessage(); + return; } + + msgEdit->setLastMessage(msg); + msgEdit->clear(); + groupDispatcher->sendPrivateMessage(peerId, isAction, msg); } else { + msgEdit->setLastMessage(msg); + msgEdit->clear(); messageDispatcher.sendMessage(isAction, msg); } } \ No newline at end of file From 854cb46e577a47c1d023b52d057e535bc4526191 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 14 Aug 2026 16:08:01 +0200 Subject: [PATCH 66/73] feat(groups): added message box for empty fields --- src/widget/form/groupinviteform.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/widget/form/groupinviteform.cpp b/src/widget/form/groupinviteform.cpp index ab99b080dd..978bc7aad0 100644 --- a/src/widget/form/groupinviteform.cpp +++ b/src/widget/form/groupinviteform.cpp @@ -48,6 +48,8 @@ GroupInviteForm::GroupInviteForm(Settings& settings_, Core& core_) QString(), &ok); if (ok && !groupName.isEmpty()) { emit groupCreate(groupName); + } else { + QMessageBox::warning(this, tr("Create group"), tr("Group name cannot be empty.")); } }); connect(joinButton, &QPushButton::clicked, this, [this]() { @@ -56,6 +58,7 @@ GroupInviteForm::GroupInviteForm(Settings& settings_, Core& core_) this, tr("Join group by ID"), tr("Enter the group Chat ID (64 hex characters):"), QLineEdit::Normal, QString(), &ok); if (!ok || chatIdHex.isEmpty()) { + QMessageBox::warning(this, tr("Join group by ID"), tr("Group ID cannot be empty.")); return; } const QString clean = chatIdHex.trimmed(); From 2e651a8102f76047ea472ccc5590ee6823bc50d8 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 15 Aug 2026 00:14:58 +0200 Subject: [PATCH 67/73] fix(groupinviteform): prevent error popups on cancel --- src/widget/form/groupinviteform.cpp | 35 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/widget/form/groupinviteform.cpp b/src/widget/form/groupinviteform.cpp index 978bc7aad0..bb448f6abf 100644 --- a/src/widget/form/groupinviteform.cpp +++ b/src/widget/form/groupinviteform.cpp @@ -46,10 +46,12 @@ GroupInviteForm::GroupInviteForm(Settings& settings_, Core& core_) const QString groupName = QInputDialog::getText( this, tr("Create group"), tr("Enter a name for the group"), QLineEdit::Normal, QString(), &ok); - if (ok && !groupName.isEmpty()) { - emit groupCreate(groupName); - } else { - QMessageBox::warning(this, tr("Create group"), tr("Group name cannot be empty.")); + if (ok) { + if (!groupName.isEmpty()) { + emit groupCreate(groupName); + } else { + QMessageBox::warning(this, tr("Create group"), tr("Group name cannot be empty.")); + } } }); connect(joinButton, &QPushButton::clicked, this, [this]() { @@ -57,18 +59,21 @@ GroupInviteForm::GroupInviteForm(Settings& settings_, Core& core_) const QString chatIdHex = QInputDialog::getText( this, tr("Join group by ID"), tr("Enter the group Chat ID (64 hex characters):"), QLineEdit::Normal, QString(), &ok); - if (!ok || chatIdHex.isEmpty()) { - QMessageBox::warning(this, tr("Join group by ID"), tr("Group ID cannot be empty.")); - return; + if (ok) { + if (!chatIdHex.isEmpty()) { + const QString clean = chatIdHex.trimmed(); + const QByteArray rawId = QByteArray::fromHex(clean.toLatin1()); + if (rawId.size() != TOX_GROUP_CHAT_ID_SIZE) { + QMessageBox::warning(this, tr("Join group by ID"), + tr("Invalid group ID. Expected 64 hex characters.")); + return; + } + core.joinGroup(GroupId(rawId)); + } else { + QMessageBox::warning(this, tr("Join group by ID"), tr("Group ID cannot be empty.")); + return; + } } - const QString clean = chatIdHex.trimmed(); - const QByteArray rawId = QByteArray::fromHex(clean.toLatin1()); - if (rawId.size() != TOX_GROUP_CHAT_ID_SIZE) { - QMessageBox::warning(this, tr("Join group by ID"), - tr("Invalid group ID. Expected 64 hex characters.")); - return; - } - core.joinGroup(GroupId(rawId)); }); auto* innerWidget = new QWidget(scroll); From 737ddee55585d8fa59b74dadcaff31222d78570b Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 15 Aug 2026 02:52:47 +0200 Subject: [PATCH 68/73] feat(ru): add missing Russian translations for group features --- translations/ru.ts | 205 +++++++++++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 91 deletions(-) diff --git a/translations/ru.ts b/translations/ru.ts index dc5f0f3cdb..6b00f76343 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -759,10 +759,6 @@ so you can save the file on Windows. Conference #%1 Конференция #%1 - - Group %1 - Группа %1 - ChatTextEdit @@ -874,93 +870,6 @@ so you can save the file on Windows. Отказаться - - GroupInviteForm - - Create group - Создать группу - - - Create new group - Создать новую группу - - - Enter a name for the group - Введите имя группы - - - Enter the group Chat ID (64 hex characters): - Введите Chat ID группы (64 шестнадцатеричных символа): - - - Group invites - Приглашения в группы - - - Groups - Группы - - - Invalid group ID. Expected 64 hex characters. - Недопустимый идентификатор группы. Ожидается 64 шестнадцатеричных символа. - - - Join group by ID - Присоединиться к группе по ID - - - - GroupWidget - - Open chat in new window - Перенести разговор в новое окно - - - Remove chat from this window - Исключить разговор из этого окна - - - Set title... - Установить заголовок... - - - Quit group - Menu to quit a group - Покинуть группу - - - %n user(s) in chat - Number of users in chat - - %n пользователь в чате - %n пользователя в чате - %n пользователей в чате - - - - New message - Новое сообщение - - - Online - В сети - - - - GroupInviteWidget - - Invited by %1 to %2 on %3 at %4. - Приглашён %1 в %2 от %3 в %4. - - - Join - Присоединиться - - - Decline - Отказаться - - ConferenceWidget @@ -1124,6 +1033,13 @@ so you can save the file on Windows. Переданные файлы + + FriendChatroom + + Group %1 + Группа %1 + + FriendListWidget @@ -1618,6 +1534,101 @@ instead of closing entirely. Личное сообщение для: %1 + + GroupInviteForm + + Create group + Создать группу + + + Create new group + Создать новую группу + + + Enter a name for the group + Введите имя группы + + + Enter the group Chat ID (64 hex characters): + Введите Chat ID группы (64 шестнадцатеричных символа): + + + Group invites + Приглашения в группы + + + Groups + Группы + + + Invalid group ID. Expected 64 hex characters. + Недопустимый идентификатор группы. Ожидается 64 шестнадцатеричных символа. + + + Join group by ID + Присоединиться к группе по ID + + + Group name cannot be empty. + Имя группы не может быть пустым. + + + Group ID cannot be empty. + ID группы не может быть пустым. + + + + GroupInviteWidget + + Invited by %1 to %2 on %3 at %4. + Приглашён %1 в %2 от %3 в %4. + + + Join + Присоединиться + + + Decline + Отказаться + + + + GroupWidget + + Open chat in new window + Перенести разговор в новое окно + + + Remove chat from this window + Исключить разговор из этого окна + + + Set title... + Установить заголовок... + + + Quit group + Menu to quit a group + Покинуть группу + + + %n user(s) in chat + Number of users in chat + + %n пользователь в чате + %n пользователя в чате + %n пользователей в чате + + + + New message + Новое сообщение + + + Online + В сети + + IdentitySettings @@ -2195,6 +2206,18 @@ Press Shift+F1 for more information. Incoming call Входящий звонок + + New group message + Новое сообщение в группе + + + Group invite received + Получено приглашение в группу + + + %1 invites you to join a group. + %1 приглашает вас вступить в группу. + PasswordEdit From 0ad81684509e926b37a6f6d6e5493b7303398340 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Sat, 15 Aug 2026 10:38:48 +0200 Subject: [PATCH 69/73] fix(groups): prevent crash on group events after group left --- src/model/chatmanager.cpp | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/model/chatmanager.cpp b/src/model/chatmanager.cpp index b113809af4..41fdf3a3c9 100644 --- a/src/model/chatmanager.cpp +++ b/src/model/chatmanager.cpp @@ -445,7 +445,9 @@ void ChatManager::onGroupPeerJoined(uint32_t groupNumber, uint32_t peerId) { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } g->onPeerJoin(peerId); } @@ -454,7 +456,9 @@ void ChatManager::onGroupPeerExited(uint32_t groupNumber, uint32_t peerId) { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } g->onPeerExit(peerId); } @@ -463,7 +467,9 @@ void ChatManager::onGroupPeerNameChanged(uint32_t groupNumber, uint32_t peerId, { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } g->onPeerNameChanged(peerId, newName); } @@ -472,7 +478,9 @@ void ChatManager::onGroupPeerStatusChanged(uint32_t groupNumber, uint32_t peerId { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } g->onPeerStatusChanged(peerId, status); } @@ -481,7 +489,9 @@ void ChatManager::onGroupTopicChanged(uint32_t groupNumber, const QString& topic { const GroupId& groupId = groupList.id2Key(groupNumber); Group* g = groupList.findGroup(groupId); - assert(g); + if (g == nullptr) { + return; + } g->setTopic(QString(), topic); settings.setGroupTopic(groupId.toString(), topic); From d76706ea5856443ce6c2c68c5070241b1cc357fe Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Tue, 18 Aug 2026 17:24:09 +0200 Subject: [PATCH 70/73] fix(groups): include sender username in group chat notifications --- src/model/notificationgenerator.cpp | 17 ++- src/model/notificationgenerator.h | 1 + test/mock/CMakeLists.txt | 2 + test/mock/include/mock/mockgroupquery.h | 170 ++++++++++++++++++++++ test/mock/src/mockgroupquery.cpp | 8 + test/model/notificationgenerator_test.cpp | 70 ++++++++- 6 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 test/mock/include/mock/mockgroupquery.h create mode 100644 test/mock/src/mockgroupquery.cpp diff --git a/src/model/notificationgenerator.cpp b/src/model/notificationgenerator.cpp index 8fc00e65b7..11b9b2c91b 100644 --- a/src/model/notificationgenerator.cpp +++ b/src/model/notificationgenerator.cpp @@ -23,6 +23,18 @@ QString generateContent(const QHash& conferenceNotifi return it.key()->getPeerList()[sender] + ": " + lastMessage; } +QString generateContent(const QHash& groupNotifications, + QString lastMessage, const ToxPk& sender) +{ + assert(!groupNotifications.empty()); + + auto it = groupNotifications.begin(); + if (it == groupNotifications.end()) { + qFatal("Concurrency error: group notifications got cleared while reading"); + } + return it.key()->getPeerList()[sender] + ": " + lastMessage; +} + QPixmap getSenderAvatar(Profile* profile, const ToxPk& sender) { return profile != nullptr ? profile->loadAvatar(sender) : QPixmap(); @@ -101,6 +113,8 @@ NotificationData NotificationGenerator::groupMessageNotification(const Group* g, const ToxPk& sender, const QString& message) { + groupNotifications[g]++; + NotificationData ret; ret.category = "transfer"; @@ -110,7 +124,7 @@ NotificationData NotificationGenerator::groupMessageNotification(const Group* g, } ret.title = g->getDisplayedName(); - ret.message = message; + ret.message = generateContent(groupNotifications, message, sender); ret.pixmap = getSenderAvatar(profile, sender); return ret; @@ -193,4 +207,5 @@ void NotificationGenerator::onNotificationActivated() { friendNotifications = {}; conferenceNotifications = {}; + groupNotifications = {}; } diff --git a/src/model/notificationgenerator.h b/src/model/notificationgenerator.h index 705313fc36..52db749c88 100644 --- a/src/model/notificationgenerator.h +++ b/src/model/notificationgenerator.h @@ -53,4 +53,5 @@ public slots: Profile* profile; QHash friendNotifications; QHash conferenceNotifications; + QHash groupNotifications; }; diff --git a/test/mock/CMakeLists.txt b/test/mock/CMakeLists.txt index aa5ee3ef41..4571b54da0 100644 --- a/test/mock/CMakeLists.txt +++ b/test/mock/CMakeLists.txt @@ -10,6 +10,8 @@ add_library( src/mockcoreidhandler.cpp include/mock/mockconferencequery.h src/mockconferencequery.cpp + include/mock/mockgroupquery.h + src/mockgroupquery.cpp include/mock/mockcoresettings.h src/mockcoresettings.cpp include/mock/mockbootstraplistgenerator.h diff --git a/test/mock/include/mock/mockgroupquery.h b/test/mock/include/mock/mockgroupquery.h new file mode 100644 index 0000000000..d47f4e9a52 --- /dev/null +++ b/test/mock/include/mock/mockgroupquery.h @@ -0,0 +1,170 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2020 by The qTox Project Contributors + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/core/icoregroupquery.h" + +class MockGroupQuery : public ICoreGroupQuery +{ +public: + MockGroupQuery() = default; + ~MockGroupQuery() override; + MockGroupQuery(const MockGroupQuery&) = default; + MockGroupQuery& operator=(const MockGroupQuery&) = default; + MockGroupQuery(MockGroupQuery&&) = default; + MockGroupQuery& operator=(MockGroupQuery&&) = default; + + QString getGroupPeerName(int groupNumber, int peerId) const override + { + std::ignore = groupNumber; + return QString("peer").append(QString::number(peerId)); + } + + ToxPk getGroupPeerPk(int groupNumber, int peerId) const override + { + std::ignore = groupNumber; + uint8_t id[TOX_PUBLIC_KEY_SIZE] = {static_cast(peerId)}; + return ToxPk(id); + } + + QString getGroupTitle(int groupNumber) const override + { + std::ignore = groupNumber; + return QString("group"); + } + + QString getGroupTopic(int groupNumber) const override + { + std::ignore = groupNumber; + return QString(); + } + + QString getGroupSelfName(int groupNumber) const override + { + std::ignore = groupNumber; + return QString("self"); + } + + bool setGroupSelfName(int groupNumber, const QString& name) override + { + std::ignore = groupNumber; + std::ignore = name; + return true; + } + + uint32_t getGroupSelfPeerId(int groupNumber) const override + { + std::ignore = groupNumber; + return 0; + } + + Status::Status getGroupSelfStatus(int groupNumber) const override + { + std::ignore = groupNumber; + return Status::Status::Online; + } + + bool setGroupSelfStatus(int groupNumber, Status::Status status) override + { + std::ignore = groupNumber; + std::ignore = status; + return true; + } + + Status::Status getGroupPeerStatus(int groupNumber, int peerId) const override + { + std::ignore = groupNumber; + std::ignore = peerId; + return Status::Status::Online; + } + + GroupRole getGroupPeerRole(int groupNumber, int peerId) const override + { + std::ignore = groupNumber; + std::ignore = peerId; + return GroupRole::User; + } + + bool setGroupPeerRole(int groupNumber, int peerId, GroupRole role) override + { + std::ignore = groupNumber; + std::ignore = peerId; + std::ignore = role; + return true; + } + + bool kickGroupPeer(int groupNumber, int peerId) override + { + std::ignore = groupNumber; + std::ignore = peerId; + return true; + } + + bool setGroupPassword(int groupNumber, const QByteArray& password) override + { + std::ignore = groupNumber; + std::ignore = password; + return true; + } + + bool setGroupPeerLimit(int groupNumber, uint16_t peerLimit) override + { + std::ignore = groupNumber; + std::ignore = peerLimit; + return true; + } + + bool setGroupTopicLock(int groupNumber, GroupTopicLock topicLock) override + { + std::ignore = groupNumber; + std::ignore = topicLock; + return true; + } + + bool setGroupVoiceState(int groupNumber, GroupVoiceState voiceState) override + { + std::ignore = groupNumber; + std::ignore = voiceState; + return true; + } + + bool setGroupPrivacyState(int groupNumber, GroupPrivacyState privacyState) override + { + std::ignore = groupNumber; + std::ignore = privacyState; + return true; + } + + bool getGroupHasPassword(int groupNumber) const override + { + std::ignore = groupNumber; + return false; + } + + uint16_t getGroupPeerLimit(int groupNumber) const override + { + std::ignore = groupNumber; + return 0; + } + + GroupTopicLock getGroupTopicLock(int groupNumber) const override + { + std::ignore = groupNumber; + return GroupTopicLock::Unknown; + } + + GroupVoiceState getGroupVoiceState(int groupNumber) const override + { + std::ignore = groupNumber; + return GroupVoiceState::Unknown; + } + + GroupPrivacyState getGroupPrivacyState(int groupNumber) const override + { + std::ignore = groupNumber; + return GroupPrivacyState::Unknown; + } +}; diff --git a/test/mock/src/mockgroupquery.cpp b/test/mock/src/mockgroupquery.cpp new file mode 100644 index 0000000000..8090095c00 --- /dev/null +++ b/test/mock/src/mockgroupquery.cpp @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2021 by The qTox Project Contributors + * Copyright © 2024-2026 The TokTok team. + */ + +#include "mock/mockgroupquery.h" + +MockGroupQuery::~MockGroupQuery() = default; diff --git a/test/model/notificationgenerator_test.cpp b/test/model/notificationgenerator_test.cpp index 22a1f99b3d..1ad634592e 100644 --- a/test/model/notificationgenerator_test.cpp +++ b/test/model/notificationgenerator_test.cpp @@ -7,6 +7,7 @@ #include "mock/mockconferencequery.h" #include "mock/mockcoreidhandler.h" +#include "mock/mockgroupquery.h" #include "src/friendlist.h" #include @@ -117,6 +118,10 @@ private slots: void testMultipleFriendSourceMessages(); void testMultipleConferenceSourceMessages(); void testMixedSourceMessages(); + void testGroupMessage(); + void testMultipleGroupMessages(); + void testMultipleGroupSourceMessages(); + void testSimpleGroupMessage(); void testFileTransfer(); void testFileTransferAfterMessage(); void testConferenceInvitation(); @@ -134,6 +139,7 @@ private slots: std::unique_ptr notificationSettings; std::unique_ptr notificationGenerator; std::unique_ptr conferenceQuery; + std::unique_ptr groupQuery; std::unique_ptr coreIdHandler; std::unique_ptr friendList; }; @@ -144,6 +150,7 @@ void TestNotificationGenerator::init() notificationSettings = std::make_unique(); notificationGenerator = std::make_unique(*notificationSettings, nullptr); conferenceQuery = std::make_unique(); + groupQuery = std::make_unique(); coreIdHandler = std::make_unique(); } @@ -377,7 +384,7 @@ void TestNotificationGenerator::testSimpleFileTransfer() void TestNotificationGenerator::testSimpleConferenceMessage() { Conference g(0, ConferenceId(nullptr), "conferenceName", false, "selfName", *conferenceQuery, - *coreIdHandler, *friendList); + *coreIdHandler, *friendList); auto sender = conferenceQuery->getConferencePeerPk(0, 0); g.updateUsername(sender, "sender1"); @@ -388,6 +395,67 @@ void TestNotificationGenerator::testSimpleConferenceMessage() QCOMPARE(notificationData.message, ""); } +void TestNotificationGenerator::testGroupMessage() +{ + Group g(0, GroupId(nullptr), "groupName", "selfName", *groupQuery, *coreIdHandler, *friendList); + auto sender = groupQuery->getGroupPeerPk(0, 1); + g.onPeerJoin(1); + + auto notificationData = notificationGenerator->groupMessageNotification(&g, sender, "test"); + QCOMPARE(notificationData.title, "groupName"); + QCOMPARE(notificationData.message, "peer1: test"); +} + +void TestNotificationGenerator::testMultipleGroupMessages() +{ + Group g(0, GroupId(nullptr), "groupName", "selfName", *groupQuery, *coreIdHandler, *friendList); + + auto sender = groupQuery->getGroupPeerPk(0, 0); + g.onPeerJoin(0); + + auto sender2 = groupQuery->getGroupPeerPk(0, 1); + g.onPeerJoin(1); + + notificationGenerator->groupMessageNotification(&g, sender, "test1"); + + auto notificationData = notificationGenerator->groupMessageNotification(&g, sender2, "test2"); + QCOMPARE(notificationData.title, "groupName"); + QCOMPARE(notificationData.message, "peer1: test2"); +} + +void TestNotificationGenerator::testMultipleGroupSourceMessages() +{ + Group g1(0, GroupId(QByteArray(32, 0)), "groupName1", "selfName", *groupQuery, *coreIdHandler, + *friendList); + Group g2(1, GroupId(QByteArray(32, 1)), "groupName2", "selfName", *groupQuery, *coreIdHandler, + *friendList); + + auto sender_g1 = groupQuery->getGroupPeerPk(0, 1); + g1.onPeerJoin(1); + + auto sender_g2 = groupQuery->getGroupPeerPk(1, 1); + g2.onPeerJoin(1); + + notificationGenerator->groupMessageNotification(&g1, sender_g1, "test1"); + auto notificationData = notificationGenerator->groupMessageNotification(&g2, sender_g2, "test1"); + + QCOMPARE(notificationData.title, "groupName2"); + QCOMPARE(notificationData.message, "peer1: test1"); +} + +void TestNotificationGenerator::testSimpleGroupMessage() +{ + Group g(0, GroupId(nullptr), "groupName", "selfName", *groupQuery, *coreIdHandler, *friendList); + auto sender = groupQuery->getGroupPeerPk(0, 0); + g.onPeerJoin(0); + + notificationSettings->setNotifyHide(true); + + auto notificationData = notificationGenerator->groupMessageNotification(&g, sender, "test"); + QCOMPARE(notificationData.title, "New group message"); + QCOMPARE(notificationData.message, ""); +} + void TestNotificationGenerator::testSimpleFriendRequest() { const ToxPk sender(QByteArray(32, 0)); From 31eee4c191a68917da04c8b93a1b7a3964303ec2 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 21 Aug 2026 12:40:30 +0200 Subject: [PATCH 71/73] fix(test): update dbschema test for schema version 12 --- test/dbutility/include/dbutility/dbutility.h | 2 +- test/dbutility/src/dbutility.cpp | 4 ++-- test/persistence/dbschema_test.cpp | 12 ++++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/test/dbutility/include/dbutility/dbutility.h b/test/dbutility/include/dbutility/dbutility.h index c6a5011c1d..cd367f2d0a 100644 --- a/test/dbutility/include/dbutility/dbutility.h +++ b/test/dbutility/include/dbutility/dbutility.h @@ -21,7 +21,7 @@ struct SqliteMasterEntry bool operator==(const DbUtility::SqliteMasterEntry& rhs) const; }; -extern const std::array testFileList; +extern const std::array testFileList; extern const std::vector schema0; extern const std::vector schema1; extern const std::vector schema2; diff --git a/test/dbutility/src/dbutility.cpp b/test/dbutility/src/dbutility.cpp index c333f67caf..d445300bf5 100644 --- a/test/dbutility/src/dbutility.cpp +++ b/test/dbutility/src/dbutility.cpp @@ -13,10 +13,10 @@ #include #include -const std::array DbUtility::testFileList = { +const std::array DbUtility::testFileList = { "testCreation.db", "testIsNewDbTrue.db", "testIsNewDbFalse.db", "test0to1.db", "test1to2.db", "test2to3.db", "test3to4.db", "test4to5.db", - "test5to6.db", "test6to7.db", "test9to10.db", + "test5to6.db", "test6to7.db", "test9to10.db", "test11to12.db", }; // db schemas can be select with "SELECT name, sql FROM sqlite_master;" on the database. diff --git a/test/persistence/dbschema_test.cpp b/test/persistence/dbschema_test.cpp index eda8db843f..a00dd0cfbf 100644 --- a/test/persistence/dbschema_test.cpp +++ b/test/persistence/dbschema_test.cpp @@ -130,7 +130,7 @@ private slots: // test8to9 omitted, data corruption correction upgrade with no schema change void test9to10(); // test10to11 handled in dbTo11_test - // test suite + void test11to12(); private: std::unique_ptr testDatabaseFile; @@ -155,7 +155,7 @@ void TestDbSchema::testCreation() const QVector queries; auto db = RawDatabase::open(testDatabaseFile->fileName(), {}, {}); QVERIFY(DbUpgrader::createCurrentSchema(*db)); - DbUtility::verifyDb(db, DbUtility::schema11); + DbUtility::verifyDb(db, DbUtility::schema12); } void TestDbSchema::testIsNewDb() @@ -422,5 +422,13 @@ void TestDbSchema::test9to10() verifyDb(db, DbUtility::schema10); } +void TestDbSchema::test11to12() +{ + auto db = RawDatabase::open(testDatabaseFile->fileName(), {}, {}); + createSchemaAtVersion(db, DbUtility::schema11); + QVERIFY(DbUpgrader::dbSchema11to12(*db)); + DbUtility::verifyDb(db, DbUtility::schema12); +} + QTEST_GUILESS_MAIN(TestDbSchema) #include "dbschema_test.moc" From 9cdf7417f418cf87303fc049b646220d094d32b3 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Fri, 21 Aug 2026 14:08:34 +0200 Subject: [PATCH 72/73] fix(tidy): resolve clang-tidy warnings --- src/chatlog/chatwidget.cpp | 2 +- src/core/core.cpp | 4 ++-- src/persistence/personalsettingsupgrader.cpp | 2 +- src/widget/form/groupform.cpp | 4 ++-- test/mock/include/mock/mockgroupquery.h | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/chatlog/chatwidget.cpp b/src/chatlog/chatwidget.cpp index ad1bfb374c..cb388563c7 100644 --- a/src/chatlog/chatwidget.cpp +++ b/src/chatlog/chatwidget.cpp @@ -1078,7 +1078,7 @@ void ChatWidget::onWorkerTimeout() return; } - if (static_cast(workerLastIndex) >= chatLineStorage->size()) { + if (workerLastIndex >= chatLineStorage->size()) { break; } diff --git a/src/core/core.cpp b/src/core/core.cpp index 6b4015c4a1..a70b164ebb 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1574,7 +1574,7 @@ bool Core::setGroupPeerRole(int groupNumber, int peerId, GroupRole role) { const QMutexLocker ml{&coreLoopLock}; - const Tox_Group_Role toxRole = static_cast(role); + const auto toxRole = static_cast(role); Tox_Err_Group_Set_Role error; const bool success = tox_group_set_role(tox.get(), groupNumber, peerId, toxRole, &error); if (!success) { @@ -1834,7 +1834,7 @@ bool Core::setGroupSelfName(int groupNumber, const QString& name) const ToxString toxName(name); Tox_Err_Group_Self_Name_Set error; const bool success = tox_group_self_set_name(tox.get(), groupNumber, - reinterpret_cast(toxName.data()), + toxName.data(), toxName.size(), &error); if (!success) { qWarning() << "Failed to set group self name for group" << groupNumber << ":" diff --git a/src/persistence/personalsettingsupgrader.cpp b/src/persistence/personalsettingsupgrader.cpp index 3f96227669..afb6849eb6 100644 --- a/src/persistence/personalsettingsupgrader.cpp +++ b/src/persistence/personalsettingsupgrader.cpp @@ -7,7 +7,7 @@ #include "settingsserializer.h" -#include "src/core/toxpk.h" +#include #include diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 59d4164119..6b763ff87c 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -455,7 +455,7 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) contextMenu->addSeparator(); const QAction* selectedItem = contextMenu->exec(pos); - if (!selectedItem) { + if (selectedItem == nullptr) { return; } if (selectedItem == toggleMuteAction) { @@ -566,7 +566,7 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) } const QAction* selectedItem = contextMenu->exec(pos); - if (!selectedItem) { + if (selectedItem == nullptr) { return; } if (selectedItem == copyTopicAction) { diff --git a/test/mock/include/mock/mockgroupquery.h b/test/mock/include/mock/mockgroupquery.h index d47f4e9a52..d61c40a6cb 100644 --- a/test/mock/include/mock/mockgroupquery.h +++ b/test/mock/include/mock/mockgroupquery.h @@ -33,19 +33,19 @@ class MockGroupQuery : public ICoreGroupQuery QString getGroupTitle(int groupNumber) const override { std::ignore = groupNumber; - return QString("group"); + return {"group"}; } QString getGroupTopic(int groupNumber) const override { std::ignore = groupNumber; - return QString(); + return {}; } QString getGroupSelfName(int groupNumber) const override { std::ignore = groupNumber; - return QString("self"); + return {"self"}; } bool setGroupSelfName(int groupNumber, const QString& name) override From bbef3ab6e680e0a938f5ca714f3aecdf995150a4 Mon Sep 17 00:00:00 2001 From: Nikolay Borodin Date: Tue, 25 Aug 2026 01:44:54 +0200 Subject: [PATCH 73/73] fix(groups): use group peer key instead of profile tox id for self --- src/core/core.cpp | 23 ++++++++++++++++++++++- src/core/core.h | 1 + src/core/icoregroupquery.h | 1 + src/model/group.cpp | 13 +++++++++---- src/model/group.h | 1 + src/model/groupmessagedispatcher.cpp | 6 +++--- src/widget/form/groupform.cpp | 8 ++++---- test/mock/include/mock/mockgroupquery.h | 7 +++++++ 8 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/core/core.cpp b/src/core/core.cpp index a70b164ebb..fc939fd814 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -1541,7 +1541,7 @@ ToxPk Core::getGroupPeerPk(int groupNumber, int peerId) const Tox_Err_Group_Self_Query selfError; const uint32_t selfPeerId = tox_group_self_get_peer_id(tox.get(), groupNumber, &selfError); if (PARSE_ERR(selfError) && selfPeerId == static_cast(peerId)) { - return getSelfPublicKey(); + return getGroupSelfPk(groupNumber); } QByteArray peerPk(tox_public_key_size(), 0x00); @@ -1554,6 +1554,27 @@ ToxPk Core::getGroupPeerPk(int groupNumber, int peerId) const return ToxPk(reinterpret_cast(peerPk.data())); } +/** + * @brief Get the public key identifying us in a group. + * + * Unlike friend chats, our identity within a group is a dedicated per-group + * peer public key generated by toxcore, not our profile's public key. + */ +ToxPk Core::getGroupSelfPk(int groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + QByteArray selfPk(tox_public_key_size(), 0x00); + Tox_Err_Group_Self_Query error; + tox_group_self_get_public_key(tox.get(), groupNumber, + reinterpret_cast(selfPk.data()), &error); + if (!PARSE_ERR(error)) { + return ToxPk{}; + } + + return ToxPk(reinterpret_cast(selfPk.data())); +} + /** * @brief Get the role of a peer in a group */ diff --git a/src/core/core.h b/src/core/core.h index fed4ef561a..04ceea2ce3 100644 --- a/src/core/core.h +++ b/src/core/core.h @@ -92,6 +92,7 @@ class Core : public QObject, uint32_t getGroupSelfPeerId(int groupNumber) const override; QString getGroupPeerName(int groupNumber, int peerId) const override; ToxPk getGroupPeerPk(int groupNumber, int peerId) const override; + ToxPk getGroupSelfPk(int groupNumber) const override; QString getGroupTitle(int groupNumber) const override; QString getGroupTopic(int groupNumber) const override; QString getGroupSelfName(int groupNumber) const override; diff --git a/src/core/icoregroupquery.h b/src/core/icoregroupquery.h index a62bbb6cb9..96bbca6999 100644 --- a/src/core/icoregroupquery.h +++ b/src/core/icoregroupquery.h @@ -55,6 +55,7 @@ class ICoreGroupQuery virtual QString getGroupPeerName(int groupNumber, int peerId) const = 0; virtual ToxPk getGroupPeerPk(int groupNumber, int peerId) const = 0; + virtual ToxPk getGroupSelfPk(int groupNumber) const = 0; virtual QString getGroupTitle(int groupNumber) const = 0; virtual QString getGroupTopic(int groupNumber) const = 0; virtual QString getGroupSelfName(int groupNumber) const = 0; diff --git a/src/model/group.cpp b/src/model/group.cpp index 1dba42ca0e..bb9b98ec11 100644 --- a/src/model/group.cpp +++ b/src/model/group.cpp @@ -135,6 +135,11 @@ QString Group::resolveToxPk(const ToxPk& id) const return {}; } +ToxPk Group::getSelfPeerPk() const +{ + return groupQuery.getGroupSelfPk(toxGroupNum); +} + void Group::setSelfName(const QString& name) { selfName = name; @@ -290,7 +295,7 @@ Status::Status Group::getGroupStatus() const QString Group::resolvePeerName(uint32_t peerId) const { const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); - if (pk == idHandler.getSelfPublicKey()) { + if (pk == getSelfPeerPk()) { return idHandler.getUsername(); } @@ -353,7 +358,7 @@ void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) const QString displayName = friendList.decideNickname(pk, newName); if (!peerDisplayNames.contains(pk)) { peerDisplayNames[pk] = displayName; - if (pk == idHandler.getSelfPublicKey()) { + if (pk == getSelfPeerPk()) { selfName = displayName; } emit userJoined(pk, displayName); @@ -364,7 +369,7 @@ void Group::onPeerNameChanged(uint32_t peerId, const QString& newName) if (peerDisplayNames[pk] != displayName) { const auto oldName = peerDisplayNames[pk]; peerDisplayNames[pk] = displayName; - if (pk == idHandler.getSelfPublicKey()) { + if (pk == getSelfPeerPk()) { selfName = displayName; } emit peerNameChanged(pk, oldName, displayName); @@ -376,7 +381,7 @@ void Group::onPeerStatusChanged(uint32_t peerId, Status::Status status) const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); peerIdToPk[peerId] = pk; - if (pk == idHandler.getSelfPublicKey()) { + if (pk == getSelfPeerPk()) { selfStatus = status; } diff --git a/src/model/group.h b/src/model/group.h index 2fc29755e6..a493e9fcd3 100644 --- a/src/model/group.h +++ b/src/model/group.h @@ -45,6 +45,7 @@ class Group : public Chat QString getDisplayedName() const override; QString getDisplayedName(const ToxPk& contact) const override; QString resolveToxPk(const ToxPk& id) const; + ToxPk getSelfPeerPk() const; void setSelfName(const QString& name); QString getSelfName() const; diff --git a/src/model/groupmessagedispatcher.cpp b/src/model/groupmessagedispatcher.cpp index ae8f2a2309..b9f7000716 100644 --- a/src/model/groupmessagedispatcher.cpp +++ b/src/model/groupmessagedispatcher.cpp @@ -77,7 +77,7 @@ GroupMessageDispatcher::sendPrivateMessage(uint32_t peerId, bool isAction, const void GroupMessageDispatcher::onMessageReceived(const ToxPk& sender, bool isAction, const QString& content) { - const bool isSelf = sender == idHandler.getSelfPublicKey(); + const bool isSelf = sender == group.getSelfPeerPk(); if (isSelf) { return; @@ -94,7 +94,7 @@ void GroupMessageDispatcher::onMessageReceived(const ToxPk& sender, bool isActio void GroupMessageDispatcher::onPrivateMessageReceived(const ToxPk& sender, bool isAction, const QString& content) { - const bool isSelf = sender == idHandler.getSelfPublicKey(); + const bool isSelf = sender == group.getSelfPeerPk(); if (isSelf) { return; @@ -106,7 +106,7 @@ void GroupMessageDispatcher::onPrivateMessageReceived(const ToxPk& sender, bool } Message message = processor.processIncomingCoreMessage(isAction, content); - message.recipient = idHandler.getSelfPublicKey(); + message.recipient = group.getSelfPeerPk(); message.recipientName = idHandler.getUsername(); emit messageReceived(sender, message); } diff --git a/src/widget/form/groupform.cpp b/src/widget/form/groupform.cpp index 6b763ff87c..885f9d1ee8 100644 --- a/src/widget/form/groupform.cpp +++ b/src/widget/form/groupform.cpp @@ -239,7 +239,7 @@ void GroupForm::updateUserNames() /* we store the peer labels by their ToxPk, but the namelist layout * needs it in alphabetical order, so we first create and store the labels * and then sort them by their text and add them to the layout in that order */ - const auto selfPk = core.getSelfPublicKey(); + const auto selfPk = core.getGroupSelfPk(group->getId()); for (const auto& peerPk : peers.keys()) { const QString peerName = peers.value(peerPk); const QString editedName = editName(peerName); @@ -400,7 +400,7 @@ void GroupForm::onLabelContextMenuRequested(const QPoint& localPos) const QString unmuteString = tr("unmute"); QStringList blockList = settings.getBlockList(); auto* const contextMenu = new QMenu(this); - const ToxPk selfPk = core.getSelfPublicKey(); + const ToxPk selfPk = core.getGroupSelfPk(group->getId()); ToxPk peerPk; // delete menu after it stops being used @@ -496,7 +496,7 @@ void GroupForm::onTopicContextMenuRequested(const QPoint& localPos) auto* copyTopicAction = contextMenu->addAction(tr("Copy topic")); auto* copyIdAction = contextMenu->addAction(tr("Copy group ID")); - const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); + const GroupRole selfRole = group->getPeerRole(core.getGroupSelfPk(group->getId())); QAction* setTopicAction = nullptr; QAction* setNicknameAction = nullptr; QMenu* statusMenu = nullptr; @@ -666,7 +666,7 @@ void GroupForm::editTopic() bool GroupForm::canSetTopic() const { - const GroupRole selfRole = group->getPeerRole(core.getSelfPublicKey()); + const GroupRole selfRole = group->getPeerRole(core.getGroupSelfPk(group->getId())); if (selfRole == GroupRole::Observer) { return false; } diff --git a/test/mock/include/mock/mockgroupquery.h b/test/mock/include/mock/mockgroupquery.h index d61c40a6cb..40f47c6ad2 100644 --- a/test/mock/include/mock/mockgroupquery.h +++ b/test/mock/include/mock/mockgroupquery.h @@ -30,6 +30,13 @@ class MockGroupQuery : public ICoreGroupQuery return ToxPk(id); } + ToxPk getGroupSelfPk(int groupNumber) const override + { + std::ignore = groupNumber; + uint8_t id[TOX_PUBLIC_KEY_SIZE] = {static_cast(0)}; + return ToxPk(id); + } + QString getGroupTitle(int groupNumber) const override { std::ignore = groupNumber;