// SPDX-FileCopyrightText: 2022 Tobias Fella // SPDX-License-Identifier: LGPL-2.0-or-later #include "actionsmodel.h" #include "chatbarcache.h" #include "enums/messagetype.h" #include "neochatconfig.h" #include "neochatconnection.h" #include "neochatroom.h" #include "roommanager.h" #include #include #include #include #include using Action = ActionsModel::Action; using namespace Quotient; using namespace Qt::StringLiterals; QStringList rainbowColors{"#ff2b00"_L1, "#ff5500"_L1, "#ff8000"_L1, "#ffaa00"_L1, "#ffd500"_L1, "#ffff00"_L1, "#d4ff00"_L1, "#aaff00"_L1, "#80ff00"_L1, "#55ff00"_L1, "#2bff00"_L1, "#00ff00"_L1, "#00ff2b"_L1, "#00ff55"_L1, "#00ff80"_L1, "#00ffaa"_L1, "#00ffd5"_L1, "#00ffff"_L1, "#00d4ff"_L1, "#00aaff"_L1, "#007fff"_L1, "#0055ff"_L1, "#002bff"_L1, "#0000ff"_L1, "#2a00ff"_L1, "#5500ff"_L1, "#7f00ff"_L1, "#aa00ff"_L1, "#d400ff"_L1, "#ff00ff"_L1, "#ff00d4"_L1, "#ff00aa"_L1, "#ff0080"_L1, "#ff0055"_L1, "#ff002b"_L1, "#ff0000"_L1}; auto leaveRoomLambda = [](const QString &text, NeoChatRoom *room, ChatBarCache *) { if (text.isEmpty()) { Q_EMIT room->showMessage(MessageType::Information, i18n("Leaving this room.")); room->connection()->leaveRoom(room); } else { QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s); auto regexMatch = roomRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); return QString(); } auto leaving = room->connection()->room(text); if (!leaving) { leaving = room->connection()->roomByAlias(text); } if (leaving) { Q_EMIT room->showMessage(MessageType::Information, i18nc("Leaving room .", "Leaving room %1.", text)); room->connection()->leaveRoom(leaving); } else { Q_EMIT room->showMessage(MessageType::Information, i18nc("Room not found", "Room %1 not found.", text)); } } return QString(); }; auto roomNickLambda = [](const QString &text, NeoChatRoom *room, ChatBarCache *) { if (text.isEmpty()) { Q_EMIT room->showMessage(MessageType::Error, i18n("No new nickname provided, no changes will happen.")); } else { room->connection()->user()->rename(text, room); } return QString(); }; QList actions{ Action{ u"shrug"_s, [](const QString &message, NeoChatRoom *, ChatBarCache *) { return u"¯\\\\_(ツ)_/¯ %1"_s.arg(message); }, Quotient::RoomMessageEvent::MsgType::Text, kli18n(""), kli18n("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), }, Action{ u"lenny"_s, [](const QString &message, NeoChatRoom *, ChatBarCache *) { return u"( ͡° ͜ʖ ͡°) %1"_s.arg(message); }, Quotient::RoomMessageEvent::MsgType::Text, kli18n(""), kli18n("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), }, Action{ u"tableflip"_s, [](const QString &message, NeoChatRoom *, ChatBarCache *) { return u"(╯°□°)╯︵ ┻━┻ %1"_s.arg(message); }, Quotient::RoomMessageEvent::MsgType::Text, kli18n(""), kli18n("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), }, Action{ u"unflip"_s, [](const QString &message, NeoChatRoom *, ChatBarCache *) { return u"┬──┬ ノ( ゜-゜ノ) %1"_s.arg(message); }, Quotient::RoomMessageEvent::MsgType::Text, kli18n(""), kli18n("Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message"), }, Action{ u"rainbow"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) { QString rainbowText; for (int i = 0; i < text.length(); i++) { rainbowText += u"%3"_s.arg(rainbowColors[i % rainbowColors.length()], text.at(i)); } // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. auto content = std::make_unique(rainbowText, u"text/html"_s); EventRelation relatesTo = chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId()); room->post("/rainbow %1"_L1.arg(text), MessageEventType::Text, std::move(content), relatesTo); return QString(); }, std::nullopt, kli18n(""), kli18n("Sends the given message colored as a rainbow"), }, Action{ u"rainbowme"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) { QString rainbowText; for (int i = 0; i < text.length(); i++) { rainbowText += u"%3"_s.arg(rainbowColors[i % rainbowColors.length()], text.at(i)); } // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. auto content = std::make_unique(rainbowText, u"text/html"_s); EventRelation relatesTo = chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId()); room->post(u"/rainbow %1"_s.arg(text), MessageEventType::Emote, std::move(content), relatesTo); return QString(); }, std::nullopt, kli18n(""), kli18n("Sends the given emote colored as a rainbow"), }, Action{ u"plain"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { #if Quotient_VERSION_MINOR > 9 room->postText(text.toHtmlEscaped()); #else room->postPlainText(text.toHtmlEscaped()); #endif return QString(); }, std::nullopt, kli18n(""), kli18n("Sends the given message as plain text"), }, Action{ u"spoiler"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) { // Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML. auto content = std::make_unique(u"%1"_s.arg(text), u"text/html"_s); EventRelation relatesTo = chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId()); room->post(u"/spoiler %1"_s.arg(text), MessageEventType::Text, std::move(content), relatesTo); return QString(); }, std::nullopt, kli18n(""), kli18n("Sends the given message as a spoiler"), }, Action{ u"me"_s, [](const QString &text, NeoChatRoom *, ChatBarCache *) { return text; }, RoomMessageEvent::MsgType::Emote, kli18n(""), kli18n("Sends the given emote"), }, Action{ u"notice"_s, [](const QString &text, NeoChatRoom *, ChatBarCache *) { return text; }, RoomMessageEvent::MsgType::Notice, kli18n(""), kli18n("Sends the given message as a notice"), }, Action{ u"invite"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); return QString(); } const RoomMemberEvent *roomMemberEvent = room->currentState().get(text); if (roomMemberEvent && roomMemberEvent->membership() == Membership::Invite) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is already invited to this room.", "%1 is already invited to this room.", text)); return QString(); } if (roomMemberEvent && roomMemberEvent->membership() == Membership::Ban) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is banned from this room.", "%1 is banned from this room.", text)); return QString(); } if (room->localMember().id() == text) { Q_EMIT room->showMessage(MessageType::Positive, i18n("You are already in this room.")); return QString(); } if (room->joinedMemberIds().contains(text)) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is already in this room.", "%1 is already in this room.", text)); return QString(); } room->inviteToRoom(text); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" was invited into this room.", "%1 was invited into this room.", text)); return QString(); }, std::nullopt, kli18n(""), kli18n("Invites the user to this room"), }, Action{ u"join"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s); auto regexMatch = roomRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); return QString(); } auto targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text); if (targetRoom) { RoomManager::instance().resolveResource(targetRoom->id()); return QString(); } Q_EMIT room->showMessage(MessageType::Information, i18nc("Joining room .", "Joining room %1.", text)); RoomManager::instance().resolveResource(text, "join"_L1); return QString(); }, std::nullopt, kli18n(""), kli18n("Joins the given room"), }, Action{ u"knock"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { auto parts = text.split(u" "_s); QString roomName = parts[0]; QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s); auto regexMatch = roomRegex.match(roomName); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); return QString(); } auto targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text); if (targetRoom) { RoomManager::instance().resolveResource(targetRoom->id()); return QString(); } Q_EMIT room->showMessage(MessageType::Information, i18nc("Knocking room .", "Knocking room %1.", text)); auto connection = dynamic_cast(room->connection()); const auto knownServer = roomName.mid(roomName.indexOf(":"_L1) + 1); if (parts.length() >= 2) { RoomManager::instance().knockRoom(connection, roomName, parts[1], QStringList{knownServer}); } else { RoomManager::instance().knockRoom(connection, roomName, QString(), QStringList{knownServer}); } return QString(); }, std::nullopt, kli18n(" []"), kli18n("Requests to join the given room"), }, Action{ u"j"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s); auto regexMatch = roomRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text)); return QString(); } if (room->connection()->room(text) || room->connection()->roomByAlias(text)) { Q_EMIT room->showMessage(MessageType::Information, i18nc("You are already in room .", "You are already in room %1.", text)); return QString(); } Q_EMIT room->showMessage(MessageType::Information, i18nc("Joining room .", "Joining room %1.", text)); RoomManager::instance().resolveResource(text, "join"_L1); return QString(); }, std::nullopt, kli18n(""), kli18n("Joins the given room"), }, Action{ u"part"_s, leaveRoomLambda, std::nullopt, kli18n("[]"), kli18n("Leaves the given room or this room, if there is none given"), }, Action{ u"leave"_s, leaveRoomLambda, std::nullopt, kli18n("[]"), kli18n("Leaves the given room or this room, if there is none given"), }, Action{ u"nick"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { if (text.isEmpty()) { Q_EMIT room->showMessage(MessageType::Error, i18n("No new nickname provided, no changes will happen.")); } else { room->connection()->user()->rename(text); } return QString(); }, std::nullopt, kli18n(""), kli18n("Changes your global display name"), }, Action{ u"roomnick"_s, roomNickLambda, std::nullopt, kli18n(""), kli18n("Changes your display name in this room"), }, Action{ u"myroomnick"_s, roomNickLambda, std::nullopt, kli18n(""), kli18n("Changes your display name in this room"), }, Action{ u"ignore"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); return QString(); } if (room->connection()->ignoredUsers().contains(text)) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is already ignored.", "%1 is already ignored.", text)); return QString(); } room->connection()->addToIgnoredUsers(text); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" is now ignored", "%1 is now ignored.", text)); return QString(); }, std::nullopt, kli18n(""), kli18n("Ignores the given user"), }, Action{ u"unignore"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); return QString(); } if (!room->connection()->ignoredUsers().contains(text)) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is not ignored.", "%1 is not ignored.", text)); return QString(); } room->connection()->removeFromIgnoredUsers(text); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" is no longer ignored.", "%1 is no longer ignored.", text)); return QString(); }, std::nullopt, kli18n(""), kli18n("Unignores the given user"), }, Action{ u"react"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) { if (chatBarCache->replyId().isEmpty()) { for (auto it = room->messageEvents().crbegin(); it != room->messageEvents().crend(); it++) { const auto &evt = **it; if (const auto event = eventCast(&evt)) { room->toggleReaction(event->id(), text); return QString(); } } } room->toggleReaction(chatBarCache->replyId(), text); return QString(); }, std::nullopt, kli18n(""), kli18n("React to the message with the given text"), }, Action{ u"ban"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { auto parts = text.split(u" "_s); static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(parts[0]); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); return QString(); } auto state = room->currentState().get(parts[0]); if (state && state->membership() == Membership::Ban) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is already banned from this room.", "%1 is already banned from this room.", text)); return QString(); } auto plEvent = room->currentState().get(); if (!plEvent) { return QString(); } if (plEvent->ban() > plEvent->powerLevelForUser(room->localMember().id())) { Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to ban users from this room.")); return QString(); } if (plEvent->powerLevelForUser(room->localMember().id()) <= plEvent->powerLevelForUser(parts[0])) { Q_EMIT room->showMessage( MessageType::Error, i18nc("You are not allowed to ban from this room.", "You are not allowed to ban %1 from this room.", parts[0])); return QString(); } room->ban(parts[0], parts.size() > 1 ? parts.mid(1).join(QLatin1Char(' ')) : QString()); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" was banned from this room.", "%1 was banned from this room.", parts[0])); return QString(); }, std::nullopt, kli18n(" []"), kli18n("Bans the given user"), }, Action{ u"unban"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(text); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", text)); return QString(); } auto plEvent = room->currentState().get(); if (!plEvent) { return QString(); } if (plEvent->ban() > plEvent->powerLevelForUser(room->localMember().id())) { Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to unban users from this room.")); return QString(); } auto state = room->currentState().get(text); if (state && state->membership() != Membership::Ban) { Q_EMIT room->showMessage(MessageType::Information, i18nc(" is not banned from this room.", "%1 is not banned from this room.", text)); return QString(); } room->unban(text); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" was unbanned from this room.", "%1 was unbanned from this room.", text)); return QString(); }, std::nullopt, kli18n(""), kli18n("Removes the ban of the given user"), }, Action{ u"kick"_s, [](const QString &text, NeoChatRoom *room, ChatBarCache *) { auto parts = text.split(u" "_s); static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s); auto regexMatch = mxidRegex.match(parts[0]); if (!regexMatch.hasMatch()) { Q_EMIT room->showMessage(MessageType::Error, i18nc("'' does not look like a matrix id.", "'%1' does not look like a matrix id.", parts[0])); return QString(); } if (parts[0] == room->localMember().id()) { Q_EMIT room->showMessage(MessageType::Error, i18n("You cannot kick yourself from the room.")); return QString(); } if (!room->isMember(parts[0])) { Q_EMIT room->showMessage(MessageType::Error, i18nc(" is not in this room", "%1 is not in this room.", parts[0])); return QString(); } auto plEvent = room->currentState().get(); if (!plEvent) { return QString(); } auto kick = plEvent->kick(); if (plEvent->powerLevelForUser(room->localMember().id()) < kick) { Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to kick users from this room.")); return QString(); } if (plEvent->powerLevelForUser(room->localMember().id()) <= plEvent->powerLevelForUser(parts[0])) { Q_EMIT room->showMessage( MessageType::Error, i18nc("You are not allowed to kick from this room", "You are not allowed to kick %1 from this room.", parts[0])); return QString(); } room->kickMember(parts[0], parts.size() > 1 ? parts.mid(1).join(QLatin1Char(' ')) : QString()); Q_EMIT room->showMessage(MessageType::Positive, i18nc(" was kicked from this room.", "%1 was kicked from this room.", parts[0])); return QString(); }, std::nullopt, kli18n(" []"), kli18n("Removes the user from the room"), }, }; int ActionsModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent); return actions.size(); } QVariant ActionsModel::data(const QModelIndex &index, int role) const { if (index.row() < 0 || index.row() >= actions.size()) { return {}; } if (role == Prefix) { return actions[index.row()].prefix; } if (role == Description) { return actions[index.row()].description.toString(); } if (role == CompletionType) { return u"action"_s; } if (role == Parameters) { return actions[index.row()].parameters.toString(); } return {}; } QHash ActionsModel::roleNames() const { return { {Prefix, "prefix"}, {Description, "description"}, {CompletionType, "completionType"}, }; } QList &ActionsModel::allActions() { return actions; } bool ActionsModel::handleQuickEditAction(NeoChatRoom *room, const QString &messageText) { if (room == nullptr) { return false; } if (NeoChatConfig::allowQuickEdit()) { QRegularExpression sed(u"^s/([^/]*)/([^/]*)(/g)?$"_s); auto match = sed.match(messageText); if (match.hasMatch()) { const QString regex = match.captured(1); const QString replacement = match.captured(2).toHtmlEscaped(); const QString flags = match.captured(3); for (auto it = room->messageEvents().crbegin(); it != room->messageEvents().crend(); it++) { if (const auto event = eventCast(&**it)) { if (event->senderId() == room->localMember().id() && event->has()) { QString originalString; if (event->content()) { originalString = static_cast(event->content().get())->body; } else { originalString = event->plainBody(); } QString replaceId = event->id(); const auto eventRelation = event->relatesTo(); if (eventRelation && eventRelation->type == "m.replace"_L1) { replaceId = eventRelation->eventId; } std::unique_ptr content = nullptr; if (flags == "/g"_L1) { content = std::make_unique(originalString.replace(regex, replacement), u"text/html"_s); } else { content = std::make_unique(originalString.replace(regex, replacement), u"text/html"_s); } Quotient::EventRelation relatesTo = Quotient::EventRelation::replace(replaceId); room->post(messageText, event->msgtype(), std::move(content), relatesTo); return true; } } } } } return false; } std::pair, std::optional> ActionsModel::handleAction(NeoChatRoom *room, ChatBarCache *chatBarCache) { auto sendText = chatBarCache->sendText(); const auto edited = handleQuickEditAction(room, sendText); if (edited) { return std::make_pair(std::nullopt, std::nullopt); } std::optional messageType = Quotient::RoomMessageEvent::MsgType::Text; if (sendText.startsWith(QLatin1Char('/'))) { for (const auto &action : ActionsModel::instance().allActions()) { if (sendText.indexOf(action.prefix) == 1 && (sendText.indexOf(" "_L1) == action.prefix.length() + 1 || sendText.length() == action.prefix.length() + 1)) { sendText = action.handle(sendText.mid(action.prefix.length() + 1).trimmed(), room, chatBarCache); if (action.messageType.has_value()) { messageType = action.messageType; } else { messageType = std::nullopt; } } } } return std::make_pair(messageType.has_value() ? std::make_optional(sendText) : std::nullopt, messageType); }