diff --git a/CMakeLists.txt b/CMakeLists.txt index d609a04029..627e8e6615 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/chatlog/chatmessage.cpp b/src/chatlog/chatmessage.cpp index 67528e23a7..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) + Style& style, bool colorizeName, bool isPrivate, + const QString& recipientName) { ChatMessage::Ptr msg = std::make_shared(documentCache, settings, style); @@ -71,6 +72,17 @@ ChatMessage::Ptr ChatMessage::createChatMessage(const QString& sender, const QSt text = TextFormatter::applyMarkdown(text, styleType == Settings::StyleType::WITH_CHARS); } + 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(badgeText); + text = badge + text; + } + switch (type) { case NORMAL: diff --git a/src/chatlog/chatmessage.h b/src/chatlog/chatmessage.h index 4c24031431..9a8d8e1075 100644 --- a/src/chatlog/chatmessage.h +++ b/src/chatlog/chatmessage.h @@ -48,7 +48,9 @@ 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, + 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 e612055cfd..31ea2b0a60 100644 --- a/src/chatlog/chatwidget.cpp +++ b/src/chatlog/chatwidget.cpp @@ -57,10 +57,13 @@ ChatMessage::Ptr createMessage(const QString& displayName, bool isSelf, bool col messageType = ChatMessage::MessageType::ALERT; } + 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); + smileyPack, settings, style, colorizeNames, isPrivate, + recipientName); } void renderMessageRaw(const QString& displayName, bool isSelf, bool colorizeNames, @@ -1075,7 +1078,7 @@ void ChatWidget::onWorkerTimeout() return; } - if (static_cast(workerLastIndex) >= chatLineStorage->size()) { + if (workerLastIndex >= chatLineStorage->size()) { break; } 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..fc939fd814 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" @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -34,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 { @@ -57,9 +63,10 @@ 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); @@ -96,6 +103,23 @@ 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_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); + 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); + 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 +238,7 @@ void Core::onStarted() loadFriends(); loadConferences(); + loadGroups(); process(); // starts its own timer } @@ -530,6 +555,193 @@ 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::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; + auto* const core = static_cast(vCore); + qDebug("Group %u peer %u joined", groupNumber, peerId); + ++core->groupPeerCounts[groupNumber]; + core->stopGroupReconnectTimer(groupNumber); + 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); + 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) { + core->startGroupReconnectTimer(groupNumber); + core->groupPeerCounts.erase(it); + } else { + --it.value(); + } + } + 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::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; + auto* const core = static_cast(vCore); + qDebug("Joined group %u", groupNumber); + const GroupId groupId = core->getGroupPersistentId(groupNumber); + if (!groupId.isEmpty()) { + core->numberToGroupId[groupNumber] = groupId; + core->groupIdToNumber[groupId] = groupNumber; + core->startGroupReconnectTimer(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; + auto* const core = static_cast(vCore); + qWarning() << "Group join failed for group" << groupNumber << "with error:" << failType; + 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, failType); +} + +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); + qDebug() << "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; + std::ignore = password; + 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 +908,77 @@ 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, + 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::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, type, + 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 +1263,62 @@ void Core::loadConferences() } } +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; + startGroupReconnectTimer(groupNumber); + emit groupJoined(groupNumber, groupId); + } + + 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) || alreadyLoaded.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; + startGroupReconnectTimer(groupNumber); + emit groupJoined(groupNumber, groupId); + } +} + void Core::checkLastOnline(uint32_t friendId) { const QMutexLocker ml{&coreLoopLock}; @@ -1126,74 +1465,526 @@ bool Core::getConferenceAvEnabled(int conferenceId) const return type == TOX_CONFERENCE_TYPE_AV; } +GroupId Core::getGroupPersistentId(uint32_t groupNumber) const +{ + const QMutexLocker ml{&coreLoopLock}; + + QByteArray idBuff(tox_group_chat_id_size(), 0x00); + Tox_Err_Group_State_Query error; + 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 {}; +} + /** - * @brief Accept a conference invite. - * @param inviteInfo Object which contains info about conference invitation - * - * @return Conference number on success, UINT32_MAX on failure. + * @brief Get the number of peers in a group. + * @return The number of peers in the group. UINT32_MAX on failure. */ -uint32_t Core::joinConference(const ConferenceInvite& inviteInfo) +uint32_t Core::getGroupNumberPeers(int groupNumber) const { const QMutexLocker ml{&coreLoopLock}; - const uint32_t friendId = inviteInfo.getFriendId(); - const uint8_t confType = inviteInfo.getType(); - const QByteArray invite = inviteInfo.getInvite(); - const auto* const cookie = reinterpret_cast(invite.data()); - const size_t cookieLength = invite.length(); - uint32_t conferenceNum{std::numeric_limits::max()}; - switch (confType) { - case TOX_CONFERENCE_TYPE_TEXT: { - qDebug() << "Trying to accept invite for text conference sent by friend" << friendId; - Tox_Err_Conference_Join error; - conferenceNum = tox_conference_join(tox.get(), friendId, cookie, cookieLength, &error); - if (!PARSE_ERR(error)) { - conferenceNum = std::numeric_limits::max(); - } - break; - } - case TOX_CONFERENCE_TYPE_AV: { - qDebug() << "Trying to join AV conference invite sent by friend" << friendId; - conferenceNum = toxav_join_av_groupchat(tox.get(), friendId, cookie, cookieLength, - CoreAV::conferenceCallCallback, this); - break; + // 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(); } - default: - qWarning() << "joinConference: Unknown conference type" << confType; + + 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{}; } - if (conferenceNum != std::numeric_limits::max()) { - emit saveRequest(); - emit conferenceJoined(conferenceNum, getConferencePersistentId(conferenceNum)); + + 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 conferenceNum; + + return ToxString(reinterpret_cast(nameBuf.data()), length).getQString(); } -void Core::conferenceInviteFriend(uint32_t friendId, int conferenceId) +/** + * @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_Conference_Invite error; - tox_conference_invite(tox.get(), friendId, conferenceId, &error); - PARSE_ERR(error); + 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 getGroupSelfPk(groupNumber); + } + + QByteArray peerPk(tox_public_key_size(), 0x00); + Tox_Err_Group_Peer_Query error; + tox_group_peer_get_public_key(tox.get(), groupNumber, peerId, reinterpret_cast(peerPk.data()), &error); + if (!PARSE_ERR(error)) { + return ToxPk{}; + } + + return ToxPk(reinterpret_cast(peerPk.data())); } -int Core::createConference(uint8_t type) +/** + * @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}; - if (type == TOX_CONFERENCE_TYPE_TEXT) { - Tox_Err_Conference_New error; - const uint32_t conferenceId = tox_conference_new(tox.get(), &error); - if (PARSE_ERR(error)) { - emit saveRequest(); - emit emptyConferenceCreated(conferenceId, getConferencePersistentId(conferenceId)); - return conferenceId; - } - return std::numeric_limits::max(); + 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{}; } - if (type == TOX_CONFERENCE_TYPE_AV) { - // unlike tox_conference_new, toxav_add_av_groupchat does not have an error enum, so -1 - // conference number is our only indication of an error + + return ToxPk(reinterpret_cast(selfPk.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 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) { + 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; + } + + emit groupPasswordChanged(groupNumber, !password.isEmpty()); + 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; + } + + emit groupPeerLimitChanged(groupNumber, peerLimit); + 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; + } + + emit groupTopicLockChanged(groupNumber, topicLock); + 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; + } + + emit groupVoiceStateChanged(groupNumber, voiceState); + 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; + } + + emit groupPrivacyStateChanged(groupNumber, 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 + */ +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{}; + } + + QByteArray nameBuf(length, 0x00); + tox_group_get_name(tox.get(), groupNumber, reinterpret_cast(nameBuf.data()), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(reinterpret_cast(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{}; + } + + QByteArray topicBuf(length, 0x00); + tox_group_get_topic(tox.get(), groupNumber, reinterpret_cast(topicBuf.data()), &error); + if (!PARSE_ERR(error)) { + return QString{}; + } + + return ToxString(reinterpret_cast(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{}; + } + + 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(reinterpret_cast(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, + 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 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 + * + * @return Conference number on success, UINT32_MAX on failure. + */ +uint32_t Core::joinConference(const ConferenceInvite& inviteInfo) +{ + const QMutexLocker ml{&coreLoopLock}; + + const uint32_t friendId = inviteInfo.getFriendId(); + const uint8_t confType = inviteInfo.getType(); + const QByteArray invite = inviteInfo.getInvite(); + const auto* const cookie = reinterpret_cast(invite.data()); + const size_t cookieLength = invite.length(); + uint32_t conferenceNum{std::numeric_limits::max()}; + switch (confType) { + case TOX_CONFERENCE_TYPE_TEXT: { + qDebug() << "Trying to accept invite for text conference sent by friend" << friendId; + Tox_Err_Conference_Join error; + conferenceNum = tox_conference_join(tox.get(), friendId, cookie, cookieLength, &error); + if (!PARSE_ERR(error)) { + conferenceNum = std::numeric_limits::max(); + } + break; + } + case TOX_CONFERENCE_TYPE_AV: { + qDebug() << "Trying to join AV conference invite sent by friend" << friendId; + conferenceNum = toxav_join_av_groupchat(tox.get(), friendId, cookie, cookieLength, + CoreAV::conferenceCallCallback, this); + break; + } + default: + qWarning() << "joinConference: Unknown conference type" << confType; + } + if (conferenceNum != std::numeric_limits::max()) { + emit saveRequest(); + emit conferenceJoined(conferenceNum, getConferencePersistentId(conferenceNum)); + } + return conferenceNum; +} + +void Core::conferenceInviteFriend(uint32_t friendId, int conferenceId) +{ + const QMutexLocker ml{&coreLoopLock}; + + Tox_Err_Conference_Invite error; + tox_conference_invite(tox.get(), friendId, conferenceId, &error); + PARSE_ERR(error); +} + +int Core::createConference(uint8_t type) +{ + const QMutexLocker ml{&coreLoopLock}; + + if (type == TOX_CONFERENCE_TYPE_TEXT) { + Tox_Err_Conference_New error; + const uint32_t conferenceId = tox_conference_new(tox.get(), &error); + if (PARSE_ERR(error)) { + emit saveRequest(); + emit emptyConferenceCreated(conferenceId, getConferencePersistentId(conferenceId)); + return conferenceId; + } + return std::numeric_limits::max(); + } + if (type == TOX_CONFERENCE_TYPE_AV) { + // unlike tox_conference_new, toxav_add_av_groupchat does not have an error enum, so -1 + // conference number is our only indication of an error const int conferenceId = toxav_add_av_groupchat(tox.get(), CoreAV::conferenceCallCallback, this); if (conferenceId != -1) { @@ -1208,6 +1999,203 @@ 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(), groupNumber, friendId, &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; + startGroupReconnectTimer(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(); + + 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; + 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(); + } + + numberToGroupId[groupNumber] = groupId; + groupIdToNumber[groupId] = groupNumber; + startGroupReconnectTimer(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; + startGroupReconnectTimer(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()) { + groupIdToNumber.remove(*groupIdIt); + numberToGroupId.erase(groupIdIt); + } + stopGroupReconnectTimer(groupNumber); + groupPeerCounts.remove(groupNumber); + emit saveRequest(); + emit groupSelfDisconnected(groupNumber); + } +} + +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) +{ + 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; + timer->start(); + }); +} + +void Core::stopGroupReconnectTimer(uint32_t groupNumber) +{ + QMetaObject::invokeMethod(this, [this, 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 62a81c6272..0a898a4998 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" @@ -19,6 +22,7 @@ #include "src/model/conferenceinvite.h" #include "src/model/status.h" +#include #include #include #include @@ -31,6 +35,8 @@ class CoreAV; class CoreFile; class IAudioControl; class ICoreSettings; +class ConferenceInvite; +class GroupInvite; class Profile; class Core; class IBootstrapListGenerator; @@ -42,7 +48,9 @@ class Core : public QObject, public ICoreFriendMessageSender, public ICoreIdHandler, public ICoreConferenceMessageSender, - public ICoreConferenceQuery + public ICoreConferenceQuery, + public ICoreGroupMessageSender, + public ICoreGroupQuery { Q_OBJECT public: @@ -81,11 +89,32 @@ 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 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; + 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; + GroupId getGroupPersistentId(uint32_t groupNumber) const; + bool reconnectGroup(uint32_t groupNumber); + 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 +134,21 @@ 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 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; + 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); @@ -119,6 +163,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, Tox_Message_Type type) override; + void setNospam(uint32_t nospam); signals: @@ -177,6 +226,30 @@ 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 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); + 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); + void groupJoined(uint32_t groupNumber, GroupId groupId); + void groupSelfJoined(uint32_t groupNumber); + void groupSelfDisconnected(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); + 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 +282,44 @@ 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 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, + 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); + 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,10 +327,15 @@ public slots: void makeTox(QByteArray savedata, ICoreSettings* s); void loadFriends(); void loadConferences(); + void loadGroups(); void bootstrapDht(); 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); @@ -261,4 +374,10 @@ private slots: const ICoreSettings& settings; bool isConnected = false; int tolerance = CORE_DISCONNECT_TOLERANCE; + + QHash numberToGroupId; + QHash groupIdToNumber; + QHash groupReconnectTimers; + // number of group members other than ourselves + QHash groupPeerCounts; }; 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..d13cc38be0 --- /dev/null +++ b/src/core/icoregroupmessagesender.h @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include + +#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, Tox_Message_Type type) = 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..96bbca6999 --- /dev/null +++ b/src/core/icoregroupquery.h @@ -0,0 +1,80 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Copyright © 2024-2026 The TokTok team. + */ + +#pragma once + +#include "src/model/status.h" +#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 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; + 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; + 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; + 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/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/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(); 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..6ce5b39c18 --- /dev/null +++ b/src/grouplist.cpp @@ -0,0 +1,82 @@ +/* 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::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); + 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..a0cf21f195 --- /dev/null +++ b/src/grouplist.h @@ -0,0 +1,33 @@ +/* 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 setToxGroupNum(uint32_t oldGroupNum, uint32_t newGroupNum, const GroupId& groupId); + 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..9008adcdba 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); @@ -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, message.recipientName); } 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, message.recipientName); } 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, message.recipientName}; auto dispatchedMessageIt = std::find_if(dispatchedMessageRowIdMap.begin(), dispatchedMessageRowIdMap.end(), 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..41fdf3a3c9 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,24 @@ 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::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); + 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); + 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 +132,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 +213,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); @@ -170,7 +242,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), @@ -179,7 +252,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 +379,260 @@ 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); + if (g == nullptr) { + return; + } + + const ToxPk author = core->getGroupPeerPk(groupNumber, 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) +{ + Group* group = createGroup(groupNumber, groupId, QString()); + if (group == nullptr) { + return; + } + if (!groupId.isEmpty()) { + settings.addSavedGroup(groupId.toString()); + if (!groupName.isEmpty()) { + settings.setGroupName(groupId.toString(), groupName); + group->setName(groupName); + } + } + addSelfToGroup(group); +} + +void ChatManager::onGroupJoined(uint32_t groupNumber, const GroupId& groupId) +{ + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + const QString groupName = core->getGroupTitle(groupNumber); + g = createGroup(groupNumber, groupId, groupName); + } else { + updateGroupNumber(g, groupNumber); + } + 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); + if (g == nullptr) { + return; + } + + g->onPeerJoin(peerId); +} + +void ChatManager::onGroupPeerExited(uint32_t groupNumber, uint32_t peerId) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + return; + } + + 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); + if (g == nullptr) { + return; + } + + 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); + if (g == nullptr) { + return; + } + + g->onPeerStatusChanged(peerId, status); +} + +void ChatManager::onGroupTopicChanged(uint32_t groupNumber, const QString& topic) +{ + const GroupId& groupId = groupList.id2Key(groupNumber); + Group* g = groupList.findGroup(groupId); + if (g == nullptr) { + return; + } + + g->setTopic(QString(), topic); + settings.setGroupTopic(groupId.toString(), 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) { + const QString groupName = core->getGroupTitle(groupNumber); + g = createGroup(groupNumber, persistentId, groupName); + } + } + if (g != nullptr) { + 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() && 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); + settings.setGroupTopic(g->getPersistentId().toString(), groupTopic); + } + } +} + +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::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); + Group* g = groupList.findGroup(groupId); + if (g != nullptr) { + g->clearPeers(); + } +} + +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) { + 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"; + } + } +} + +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 +667,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 +680,52 @@ 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; + + 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); + + 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)); + + 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..33016cdf57 100644 --- a/src/model/chatmanager.h +++ b/src/model/chatmanager.h @@ -5,11 +5,16 @@ #pragma once +#include + #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 +30,9 @@ class Core; class Friend; class FriendChatroom; class FriendList; +class Group; +class GroupList; +class GroupRoom; class IChatLog; class IDialogsManager; class Profile; @@ -36,8 +44,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 +55,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 +65,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 +78,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 +101,39 @@ 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 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); + 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); + 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); + 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); + void updateGroupNumber(Group* g, uint32_t groupNumber); Profile& profile; Core* core = nullptr; Settings& settings; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; IDialogsManager* dialogsManager; std::unique_ptr sharedMessageProcessorParams; @@ -103,4 +145,8 @@ private slots: QMap> conferenceMessageDispatchers; QMap> conferenceLogs; QMap> conferenceRooms; + + QMap> groupMessageDispatchers; + QMap> groupLogs; + QMap> groupRooms; }; diff --git a/src/model/chatroom/friendchatroom.cpp b/src/model/chatroom/friendchatroom.cpp index 6784f5ffc2..5555eca995 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,21 @@ 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)); + if (groupId >= 0) { + 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 +141,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/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/friendlist/friendlistmanager.cpp b/src/model/friendlist/friendlistmanager.cpp index 6db31c3f0f..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(); } @@ -115,6 +117,10 @@ void FriendListManager::applyFilter() if (filterParams.hideConferences && itemTmp->isConference()) { itemTmp->setWidgetVisible(false); } + + if (filterParams.hideGroups && itemTmp->isGroup()) { + itemTmp->setWidgetVisible(false); + } } hideCircles = filterParams.hideOnline && filterParams.hideOffline; 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/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/model/group.cpp b/src/model/group.cpp new file mode 100644 index 0000000000..bb9b98ec11 --- /dev/null +++ b/src/model/group.cpp @@ -0,0 +1,458 @@ +/* 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 + +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_)} + , toxcoreName{std::move(name)} + , toxGroupNum(groupId_) + , groupId{persistentGroupId} + , friendList{friendList_} +{ + hasNewMessages = false; + userWasMentioned = false; +} + +void Group::setName(const QString& newTitle) +{ + const QString shortTitle = newTitle.left(TOX_GROUP_MAX_GROUP_NAME_LENGTH); + if (groupName != shortTitle) { + groupName = shortTitle; + emit displayedNameChanged(getDisplayedName()); + emit titleChangedByUser(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() && toxcoreName != shortTitle) { + toxcoreName = shortTitle; + emit displayedNameChanged(getDisplayedName()); + emit titleChanged(selfName, getDisplayedName()); + } +} + +QString Group::getName() const +{ + return groupName; +} + +QString Group::getDisplayedName() const +{ + 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 +{ + return resolveToxPk(contact); +} + +uint32_t Group::getId() const +{ + return toxGroupNum; +} + +void Group::setToxGroupNumber(uint32_t groupNumber) +{ + toxGroupNum = groupNumber; +} + +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 {}; +} + +ToxPk Group::getSelfPeerPk() const +{ + return groupQuery.getGroupSelfPk(toxGroupNum); +} + +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(TOX_GROUP_MAX_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_); +} + +bool Group::setGroupNickname(const QString& 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, nameToSet); + } + return true; + } + return false; +} + +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); + if (pk == getSelfPeerPk()) { + 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); + peerStatuses[pk] = groupQuery.getGroupPeerStatus(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); + peerStatuses.remove(pk); + peerRoles.remove(pk); + 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::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); + peerIdToPk[peerId] = pk; + + const QString displayName = friendList.decideNickname(pk, newName); + if (!peerDisplayNames.contains(pk)) { + peerDisplayNames[pk] = displayName; + if (pk == getSelfPeerPk()) { + selfName = displayName; + } + emit userJoined(pk, displayName); + emit numPeersChanged(peerDisplayNames.size()); + return; + } + + if (peerDisplayNames[pk] != displayName) { + const auto oldName = peerDisplayNames[pk]; + peerDisplayNames[pk] = displayName; + if (pk == getSelfPeerPk()) { + selfName = displayName; + } + emit peerNameChanged(pk, oldName, displayName); + } +} + +void Group::onPeerStatusChanged(uint32_t peerId, Status::Status status) +{ + const ToxPk pk = groupQuery.getGroupPeerPk(toxGroupNum, peerId); + peerIdToPk[peerId] = pk; + + if (pk == getSelfPeerPk()) { + selfStatus = status; + } + + if (peerStatuses.value(pk, Status::Status::Online) != status) { + peerStatuses[pk] = status; + emit peerStatusChanged(pk, status); + } +} + +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); +} + +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) { + 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) { + if (groupQuery.kickGroupPeer(toxGroupNum, it.key())) { + onPeerExit(it.key()); + return true; + } + return false; + } + } + qWarning() << "kickPeer: unknown peer" << pk.toString(); + return false; +} + +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 new file mode 100644 index 0000000000..a493e9fcd3 --- /dev/null +++ b/src/model/group.h @@ -0,0 +1,132 @@ +/* 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; + void setToxGroupNumber(uint32_t groupNumber); + 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; + ToxPk getSelfPeerPk() 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); + 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 clearPeers(); + 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; + uint32_t getPeerId(const ToxPk& pk) const; + +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); + 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); + void topicLockChanged(GroupTopicLock topicLock); + void voiceStateChanged(GroupVoiceState voiceState); + void privacyStateChanged(GroupPrivacyState privacyState); + void nicknameChanged(const QString& nickname); + +private: + QString resolvePeerName(uint32_t peerId) const; + +private: + ICoreGroupQuery& groupQuery; + ICoreIdHandler& idHandler; + QString selfName; + QString groupName; + 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; + 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..b9f7000716 --- /dev/null +++ b/src/model/groupmessagedispatcher.cpp @@ -0,0 +1,112 @@ +/* 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); +} + +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); + const QString recipientName = group.getDisplayedName(recipientPk); + + for (const auto& message : processor.processOutgoingMessage(isAction, content)) { + auto messageId = nextMessageId++; + lastMessageId = messageId; + 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; + messageWithRecipient.recipientName = recipientName; + emit messageSent(messageId, messageWithRecipient); + 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 == group.getSelfPeerPk(); + + if (isSelf) { + return; + } + + if (settings.getBlockList().contains(sender.toString())) { + qDebug() << "onGroupMessageReceived: Filtered:" << sender.toString(); + return; + } + + emit messageReceived(sender, processor.processIncomingCoreMessage(isAction, content)); +} + +void GroupMessageDispatcher::onPrivateMessageReceived(const ToxPk& sender, bool isAction, + const QString& content) +{ + const bool isSelf = sender == group.getSelfPeerPk(); + + if (isSelf) { + return; + } + + if (settings.getBlockList().contains(sender.toString())) { + qDebug() << "onGroupPrivateMessageReceived: Filtered:" << sender.toString(); + return; + } + + Message message = processor.processIncomingCoreMessage(isAction, content); + message.recipient = group.getSelfPeerPk(); + message.recipientName = idHandler.getUsername(); + emit messageReceived(sender, message); +} diff --git a/src/model/groupmessagedispatcher.h b/src/model/groupmessagedispatcher.h new file mode 100644 index 0000000000..c0184b6da3 --- /dev/null +++ b/src/model/groupmessagedispatcher.h @@ -0,0 +1,42 @@ +/* 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; + + 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; + MessageProcessor processor; + ICoreIdHandler& idHandler; + ICoreGroupMessageSender& messageSender; + Settings& settings; + DispatchedMessageId nextMessageId{0}; +}; diff --git a/src/model/message.h b/src/model/message.h index d035123eb0..815d52d9cd 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,8 @@ struct Message QString content; QDateTime timestamp; std::vector metadata; + ToxPk recipient; + QString recipientName; }; diff --git a/src/model/notificationgenerator.cpp b/src/model/notificationgenerator.cpp index ccf7344d9f..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(); @@ -97,6 +109,27 @@ NotificationData NotificationGenerator::conferenceMessageNotification(const Conf return ret; } +NotificationData NotificationGenerator::groupMessageNotification(const Group* g, + const ToxPk& sender, + const QString& message) +{ + groupNotifications[g]++; + + NotificationData ret; + ret.category = "transfer"; + + if (notificationSettings.getNotifyHide()) { + ret.title = tr("New group message"); + return ret; + } + + ret.title = g->getDisplayedName(); + ret.message = generateContent(groupNotifications, message, sender); + ret.pixmap = getSenderAvatar(profile, sender); + + return ret; +} + NotificationData NotificationGenerator::fileTransferNotification(const Friend* f, const QString& filename, size_t fileSize) @@ -136,6 +169,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) { @@ -157,4 +207,5 @@ void NotificationGenerator::onNotificationActivated() { friendNotifications = {}; conferenceNotifications = {}; + groupNotifications = {}; } diff --git a/src/model/notificationgenerator.h b/src/model/notificationgenerator.h index 6a6d3e63c8..52db749c88 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: @@ -49,4 +53,5 @@ public slots: Profile* profile; QHash friendNotifications; QHash conferenceNotifications; + QHash groupNotifications; }; 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/db/upgrades/dbupgrader.cpp b/src/persistence/db/upgrades/dbupgrader.cpp index 084cef56b5..63044365da 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,8 @@ 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, " + "recipient_name BLOB, " "FOREIGN KEY (id, message_type) REFERENCES history(id, message_type), " "FOREIGN KEY (sender_alias) REFERENCES aliases(id)); " "CREATE TABLE file_transfers " @@ -623,6 +625,19 @@ 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( // + "ALTER TABLE text_messages " + "ADD COLUMN recipient_name 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..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) + 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) " + "INSERT INTO text_messages (id, message_type, sender_alias, message, recipient, recipient_name) " "VALUES ( " " last_insert_rowid(), " " 'T', " @@ -124,6 +125,18 @@ 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"; + } + if (!recipientName.isEmpty()) { + queryString += ", ?"; + boundParams += recipientName.toUtf8(); + } else { + queryString += ", NULL"; + } queryString += ");"; queries.emplace_back(queryString, boundParams, insertIdCallback); @@ -498,14 +511,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, const QString& recipientName) { if (historyAccessBlocked()) { return; } db->execLater(generateNewTextMessageQueries(chatId, message, sender, time, isDelivered, - dispName, insertIdCallback)); + dispName, insertIdCallback, recipient, recipientName)); } void History::setFileFinished(const QByteArray& fileId, bool success, const QString& filePath, @@ -580,6 +594,8 @@ QList History::getMessagesForChat(const ChatId& chatId, si constexpr auto fileOffset = 6; constexpr auto senderOffset = 12; constexpr auto systemOffset = 14; + constexpr auto recipientOffset = 19; + constexpr auto recipientNameOffset = 20; auto it = row.begin(); @@ -601,8 +617,12 @@ 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()}; + 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); + senderKey, messageContent, recipientKey, recipientName); break; } case 'F': { @@ -668,7 +688,9 @@ 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" + " 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 " @@ -705,11 +727,14 @@ 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()}; + ++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}; + {id, messageState, timestamp, chatId.clone(), displayName, senderKey, messageContent, recipientKey, recipientName}; }; QString queryString = QStringLiteral( // @@ -720,7 +745,9 @@ 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" + " 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 4990708548..3b2e7d5060 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_ = {}, QString recipientName_ = {}) : chat{std::move(chat_)} , sender{std::move(sender_)} , dispName{std::move(dispName_)} @@ -138,6 +139,8 @@ class History : public QObject, public std::enable_shared_from_this , id{id_} , state{state_} , content(std::move(message)) + , recipient{std::move(recipient_)} + , recipientName{std::move(recipientName_)} { } @@ -171,6 +174,8 @@ class History : public QObject, public std::enable_shared_from_this , id{other.id} , state{other.state} , content{other.content} + , recipient{other.recipient} + , recipientName{other.recipientName} { } @@ -183,6 +188,8 @@ class History : public QObject, public std::enable_shared_from_this id = other.id; state = other.state; content = other.content; + recipient = other.recipient; + recipientName = other.recipientName; return *this; } @@ -193,6 +200,8 @@ class History : public QObject, public std::enable_shared_from_this RowId id; MessageState state; HistMessageContent content; + ToxPk recipient; + QString recipientName; }; struct DateIdx @@ -213,7 +222,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 = {}, 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/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..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 @@ -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..3b0eba9a59 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,28 @@ 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()); + } + 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()); + } + 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] { inArray(ps, "Friend", &friendLst, [this, &ps] { FriendProp fp{ps.value("addr").toString()}; @@ -657,11 +681,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 +895,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 +935,28 @@ 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))); + 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))); + 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] { // ps.setValue("settingsVersion", personalSettingsVersion); }); @@ -1493,6 +1541,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 +1923,96 @@ 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; + requestSave(); +} + +void Settings::addSavedGroup(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + if (!savedGroups.contains(groupIdHex)) { + savedGroups.append(groupIdHex); + requestSave(); + } +} + +void Settings::removeSavedGroup(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + savedGroups.removeAll(groupIdHex); + groupNames.remove(groupIdHex); + groupTopics.remove(groupIdHex); + groupNicknames.remove(groupIdHex); + requestSave(); +} + +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); + } + requestSave(); +} + +void Settings::removeGroupAlias(const QString& groupIdHex) +{ + const QMutexLocker locker{&bigLock}; + groupNames.remove(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}; + 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); + } + requestSave(); +} + QString Settings::getInDev() const { const QMutexLocker locker{&bigLock}; diff --git a/src/persistence/settings.h b/src/persistence/settings.h index 70c527c2e4..1502d6232d 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,19 @@ public slots: void setShowConferenceJoinLeaveMessages(bool newValue) override; SIGNAL_IMPL(Settings, showConferenceJoinLeaveMessagesChanged, bool show) + // Groups + QStringList getSavedGroups() const override; + 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); + 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); + // State QByteArray getWindowGeometry() const; void setWindowGeometry(const QByteArray& value); @@ -516,6 +532,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 +708,12 @@ private slots: Db::syncType dbSyncType; QStringList blockList; + // Groups + QStringList savedGroups; + QHash groupNames; + QHash groupNicknames; + QHash groupTopics; + // Audio QString inDev; bool audioInDevEnabled; @@ -724,6 +747,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..672a67bd92 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(); @@ -208,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/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 168ddf3ff1..9b8b9ebdfb 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..2e44e09d7b 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); @@ -87,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); @@ -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..885f9d1ee8 --- /dev/null +++ b/src/widget/form/groupform.cpp @@ -0,0 +1,742 @@ +/* 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/model/groupmessagedispatcher.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 +#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) + , groupDispatcher(qobject_cast(&messageDispatcher_)) + , 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->getDisplayedName()); + + 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); + + 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(msgEdit, &ChatTextEdit::escapePressed, this, &GroupForm::cancelPrivateMessage); + + 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::peerStatusChanged, this, &GroupForm::onPeerStatusChanged); + 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.getGroupSelfPk(group->getId()); + for (const auto& peerPk : peers.keys()) { + const QString peerName = peers.value(peerPk); + const QString editedName = editName(peerName); + 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 (roleIconStr.isEmpty()) { + label = new QLabel(statusIcon + editedName.toHtmlEscaped() + QLatin1String(", ")); + label->setTextFormat(Qt::RichText); + } else { + label = new QLabel(statusIcon + roleIconStr + 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()) { + 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::onPeerStatusChanged(const ToxPk& peer, Status::Status status) +{ + std::ignore = peer; + std::ignore = status; + 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) +{ + std::ignore = ev; + if (msgEdit->hasFocus()) { + return; + } +} + +void GroupForm::keyReleaseEvent(QKeyEvent* ev) +{ + std::ignore = 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()); + updatePrivateMessageIndicator(); +} + +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.getGroupSelfPk(group->getId()); + 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); + } + + // 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(); + + 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")); + } + + auto* privateMessageAction = contextMenu->addAction(tr("private message")); + contextMenu->addSeparator(); + + const QAction* selectedItem = contextMenu->exec(pos); + if (selectedItem == nullptr) { + return; + } + 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); + } else if (selectedItem == privateMessageAction) { + startPrivateMessage(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)); + + 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.getGroupSelfPk(group->getId())); + 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(QIcon(Status::getIconPath(Status::Status::Online)), tr("Online")); + statusOnlineAction->setCheckable(true); + statusOnlineAction->setChecked(currentStatus == Status::Status::Online); + statusAwayAction = statusMenu->addAction(QIcon(Status::getIconPath(Status::Status::Away)), tr("Away")); + statusAwayAction->setCheckable(true); + statusAwayAction->setChecked(currentStatus == Status::Status::Away); + statusBusyAction = statusMenu->addAction(QIcon(Status::getIconPath(Status::Status::Busy)), tr("Busy")); + statusBusyAction->setCheckable(true); + statusBusyAction->setChecked(currentStatus == Status::Status::Busy); + + 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 == nullptr) { + return; + } + 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 == 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) { + 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::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({}); +} + +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() +{ + if (!canSetTopic()) { + 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); + } +} + +bool GroupForm::canSetTopic() const +{ + const GroupRole selfRole = group->getPeerRole(core.getGroupSelfPk(group->getId())); + if (selfRole == GroupRole::Observer) { + return false; + } + + if (group->getTopicLock() == GroupTopicLock::Disabled) { + return true; + } + + 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; + } + + if (!privateMessageTarget.isEmpty()) { + if (groupDispatcher == nullptr) { + const auto curTime = QDateTime::currentDateTime(); + addSystemInfoMessage(curTime, SystemMessageType::messageSendFailed, {}); + cancelPrivateMessage(); + return; + } + + const uint32_t peerId = group->getPeerId(privateMessageTarget); + 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 diff --git a/src/widget/form/groupform.h b/src/widget/form/groupform.h new file mode 100644 index 0000000000..cf447bcc7f --- /dev/null +++ b/src/widget/form/groupform.h @@ -0,0 +1,100 @@ +/* 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; +class GroupMessageDispatcher; +struct Message; +class Settings; +class DocumentCache; +class SmileyPack; +class Style; +class IMessageBoxManager; +class FriendList; +class ConferenceList; +class GroupList; +class CroppingLabel; +class QLabel; +class QToolButton; +class QWidget; + +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 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); + 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); + void onTopicContextMenuRequested(const QPoint& localPos); + void editTopic(); + void setPassword(); + void setNickname(); + void clearPassword(); + void setPeerLimit(); + void startPrivateMessage(const ToxPk& peerPk); + void cancelPrivateMessage(); + +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); + bool canSetTopic() const; + void updatePrivateMessageIndicator(); + +private: + Core& core; + Group* group; + GroupMessageDispatcher* groupDispatcher; + QMap peerLabels; + FlowLayout* namesListLayout; + QLabel* nusersLabel; + CroppingLabel* topicLabel; + Settings& settings; + Style& style; + FriendList& friendList; + ToxPk privateMessageTarget; + QWidget* privateMessageBar; + QLabel* privateMessageLabel; + QToolButton* privateMessageCloseButton; +}; diff --git a/src/widget/form/groupinviteform.cpp b/src/widget/form/groupinviteform.cpp new file mode 100644 index 0000000000..bb448f6abf --- /dev/null +++ b/src/widget/form/groupinviteform.cpp @@ -0,0 +1,201 @@ +/* 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) { + 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]() { + 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) { + 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; + } + } + }); + + 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) { + const GroupInvite& existingInvite = existing->getInviteInfo(); + if (existingInvite.getFriendId() == inviteInfo.getFriendId() + && existingInvite.getInviteData() == inviteInfo.getInviteData()) { + 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..70e5e4e320 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(); @@ -408,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) @@ -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..d1c5ea10fe 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,20 +41,24 @@ 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); 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); @@ -97,5 +103,6 @@ private slots: IMessageBoxManager& messageBoxManager; FriendList& friendList; ConferenceList& conferenceList; + GroupList& groupList; Profile& profile; }; diff --git a/src/widget/friendwidget.cpp b/src/widget/friendwidget.cpp index 4d2e33f1b4..2520e71c9e 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")); @@ -378,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/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..8d97c2bbac --- /dev/null +++ b/src/widget/groupwidget.cpp @@ -0,0 +1,300 @@ +/* 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->getDisplayedName()); + + 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->getDisplayedName()); + 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; +} + +bool GroupWidget::isGroup() const +{ + return true; +} + +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..c0095df417 --- /dev/null +++ b/src/widget/groupwidget.h @@ -0,0 +1,69 @@ +/* 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; + bool isGroup() 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..fb996dd9ad 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}) @@ -250,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); @@ -260,14 +273,17 @@ 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); + connect(chatManager.get(), &ChatManager::groupRemoved, this, + [this](const GroupId& groupId) { removeGroup(groupList->findGroup(groupId), true); }); 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 +314,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 +337,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 +348,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); @@ -472,6 +491,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.svg"), QIcon::Disabled); + ui->groupButton->setIcon(groupButtonIcon); ui->transferButton->setCheckable(true); ui->settingsButton->setCheckable(true); ui->debugButton->setCheckable(true); @@ -488,7 +512,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 +523,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); @@ -633,6 +662,10 @@ Widget::~Widget() removeConference(c, true); } + for (Group* g : groupList->getAllGroups()) { + removeGroup(g, true); + } + for (Friend* f : friendList->getAllFriends()) { removeFriend(f, true); } @@ -645,6 +678,7 @@ Widget::~Widget() delete profileInfo; delete addFriendForm; delete conferenceInviteForm; + delete groupInviteForm; delete filesForm; delete timer; delete contentLayout; @@ -737,6 +771,8 @@ 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::groupSentFailed, this, &Widget::onGroupSendFailed); connect(core, &Core::usernameSet, this, &Widget::refreshPeerListsLocal); connect(this, &Widget::statusSet, core, &Core::setStatus); @@ -906,6 +942,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 +1287,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 +1453,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 +1465,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 +1572,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 +1721,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 +1768,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 +1923,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 +1948,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 +1959,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 +2109,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 +2229,138 @@ 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); }, Qt::QueuedConnection); + 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(); + }); + connect(newGroup, &Group::titleChangedByUser, this, + [this, groupId](const QString& title) { + if (title.isEmpty()) { + settings.removeGroupAlias(groupId.toString()); + } else { + 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, std::shared_ptr messageDispatcher, std::shared_ptr chatHistory) @@ -2064,7 +2385,7 @@ void Widget::onConferenceModelAdded(Conference* newConference, std::shared_ptrsetColorizedNames(settings.getEnableConferencesColor()); conferenceWidgets[conferenceId] = widget; @@ -2264,6 +2585,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); @@ -2312,11 +2646,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::Groups: + return true; + default: + return false; + } +} + bool Widget::filterGroups(FilterCriteria index) { switch (index) { case FilterCriteria::Offline: case FilterCriteria::Friends: + case FilterCriteria::Conferences: return true; default: return false; @@ -2328,6 +2675,7 @@ bool Widget::filterOffline(FilterCriteria index) switch (index) { case FilterCriteria::Online: case FilterCriteria::Conferences: + case FilterCriteria::Groups: return true; default: return false; @@ -2339,6 +2687,7 @@ bool Widget::filterOnline(FilterCriteria index) switch (index) { case FilterCriteria::Offline: case FilterCriteria::Conferences: + case FilterCriteria::Groups: return true; default: return false; @@ -2421,7 +2770,7 @@ void Widget::searchChats() const FilterCriteria filter = getFilterCriteria(); chatListWidget->searchChatRooms(searchString, filterOnline(filter), filterOffline(filter), - filterGroups(filter)); + filterConferences(filter), filterGroups(filter)); updateFilterText(); } @@ -2460,8 +2809,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; } @@ -2478,7 +2829,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) @@ -2542,12 +2893,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); @@ -2568,7 +2945,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(); @@ -2585,6 +2963,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..405db10d12 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, }; @@ -111,7 +122,8 @@ class Widget final : public QMainWindow Online, Offline, Friends, - Conferences + Conferences, + Groups }; public: @@ -126,10 +138,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,14 +188,18 @@ 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); + void onGroupSendFailed(uint32_t groupNumber); void onFriendTypingChanged(uint32_t friendNumber, bool isTyping); void nextChat(); 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 +219,7 @@ public slots: private slots: void onAddClicked(); void onConferenceClicked(); + void onGroupClicked(); void onTransferClicked(); void showProfile(); void openNewDialog(GenericChatroomWidget* widget); @@ -208,6 +228,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 +243,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 +269,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 +288,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); @@ -269,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); @@ -298,6 +326,7 @@ private slots: QAction* filterOnlineAction; QAction* filterOfflineAction; QAction* filterFriendsAction; + QAction* filterConferencesAction; QAction* filterGroupsAction; QActionGroup* filterDisplayGroup; @@ -310,6 +339,7 @@ private slots: ContentLayout* contentLayout; AddFriendForm* addFriendForm; ConferenceInviteForm* conferenceInviteForm; + GroupInviteForm* groupInviteForm; ProfileInfo* profileInfo; ProfileForm* profileForm; @@ -330,13 +360,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 +387,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 +412,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/dbutility/include/dbutility/dbutility.h b/test/dbutility/include/dbutility/dbutility.h index 47e3dd97c2..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; @@ -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..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. @@ -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, 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 " + "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/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/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/mock/include/mock/mockgroupquery.h b/test/mock/include/mock/mockgroupquery.h new file mode 100644 index 0000000000..40f47c6ad2 --- /dev/null +++ b/test/mock/include/mock/mockgroupquery.h @@ -0,0 +1,177 @@ +/* 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); + } + + 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; + return {"group"}; + } + + QString getGroupTopic(int groupNumber) const override + { + std::ignore = groupNumber; + return {}; + } + + QString getGroupSelfName(int groupNumber) const override + { + std::ignore = groupNumber; + return {"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/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/friendlistmanager_test.cpp b/test/model/friendlistmanager_test.cpp index d5b22c09cd..e3bde8de7a 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; @@ -135,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: @@ -451,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); } @@ -470,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(); @@ -499,7 +565,7 @@ void TestFriendListManager::testApplyFilterSearchString() } } - manager->setFilter("", false, false, false); + manager->setFilter("", false, false, false, false); manager->applyFilter(); resultVec = manager->getItems(); @@ -517,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()) { @@ -529,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()) { @@ -540,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()) { @@ -551,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/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)); 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/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" 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); 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/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/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/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 f862e8bbee..6b00f76343 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -1033,6 +1033,13 @@ so you can save the file on Windows. Переданные файлы + + FriendChatroom + + Group %1 + Группа %1 + + FriendListWidget @@ -1088,6 +1095,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 @@ -1351,6 +1371,264 @@ instead of closing entirely. Вы уверены, что вы хотите удалить все отображаемые сообщения? + + Group + + Group %1 + Группа %1 + + + + 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 пользователей в чате + + + + mute + выключить звук + + + unmute + включить звук + + + copy peer ID + скопировать идентификатор узла + + + Set nickname... + Установить никнейм... + + + My status + Мой статус + + + Online + В сети + + + Away + Отошёл + + + Busy + Занят + + + Set nickname + Установить никнейм + + + Nickname: + Никнейм: + + + private message + личное сообщение + + + Private message to: %1 + Личное сообщение для: %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 @@ -1750,6 +2028,18 @@ Press Shift+F1 for more information. Open conference management page Открыть страницу управления конференцией + + Create a group + Создать группу + + + Group + Группа + + + Open group management page + Открыть страницу управления группой + File transfers history История передачи файлов @@ -1916,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 @@ -2301,6 +2603,11 @@ This ID includes the NoSpam code (in blue), and the checksum (in gray). QObject + + private + Label for private group messages + приват + Default По умолчанию @@ -3065,6 +3372,10 @@ number here may cause the scroll bar to disappear. Conferences Конференции + + Groups + Группы + Search Contacts Поиск контактов @@ -3153,6 +3464,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; +}