1 // SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
2 // SPDX-License-Identifier: GPL-3.0-only
3 
4 #include "neochatroom.h"
5 
6 #include <cmark.h>
7 
8 #include <QFileInfo>
9 #include <QImageReader>
10 #include <QMetaObject>
11 #include <QMimeDatabase>
12 #include <QTextDocument>
13 #include <functional>
14 
15 #include "connection.h"
16 #include "csapi/account-data.h"
17 #include "csapi/content-repo.h"
18 #include "csapi/leaving.h"
19 #include "csapi/room_state.h"
20 #include "csapi/rooms.h"
21 #include "csapi/typing.h"
22 #include "events/accountdataevents.h"
23 #include "events/reactionevent.h"
24 #include "events/roomcanonicalaliasevent.h"
25 #include "events/roommessageevent.h"
26 #include "events/roompowerlevelsevent.h"
27 #include "events/typingevent.h"
28 #include "jobs/downloadfilejob.h"
29 #include "neochatconfig.h"
30 #include "notificationsmanager.h"
31 #include "stickerevent.h"
32 #include "user.h"
33 #include "utils.h"
34 
35 #include <KLocalizedString>
36 
NeoChatRoom(Connection * connection,QString roomId,JoinState joinState)37 NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinState)
38     : Room(connection, std::move(roomId), joinState)
39 {
40     connect(this, &NeoChatRoom::notificationCountChanged, this, &NeoChatRoom::countChanged);
41     connect(this, &NeoChatRoom::highlightCountChanged, this, &NeoChatRoom::countChanged);
42     connect(this, &Room::fileTransferCompleted, this, [=] {
43         setFileUploadingProgress(0);
44         setHasFileUploading(false);
45     });
46 
47     connect(this, &Room::aboutToAddHistoricalMessages, this, &NeoChatRoom::readMarkerLoadedChanged);
48 
49     connect(this, &Quotient::Room::eventsHistoryJobChanged, this, &NeoChatRoom::lastActiveTimeChanged);
50 
51     connect(this, &Room::joinStateChanged, this, [=](JoinState oldState, JoinState newState) {
52         if (oldState == JoinState::Invite && newState != JoinState::Invite) {
53             Q_EMIT isInviteChanged();
54         }
55     });
56 }
57 
uploadFile(const QUrl & url,const QString & body)58 void NeoChatRoom::uploadFile(const QUrl &url, const QString &body)
59 {
60     if (url.isEmpty()) {
61         return;
62     }
63 
64     QString txnId = postFile(body.isEmpty() ? url.fileName() : body, url, false);
65     setHasFileUploading(true);
66     connect(this, &Room::fileTransferCompleted, [=](const QString &id, const QUrl & /*localFile*/, const QUrl & /*mxcUrl*/) {
67         if (id == txnId) {
68             setFileUploadingProgress(0);
69             setHasFileUploading(false);
70         }
71     });
72     connect(this, &Room::fileTransferFailed, [=](const QString &id, const QString & /*error*/) {
73         if (id == txnId) {
74             setFileUploadingProgress(0);
75             setHasFileUploading(false);
76         }
77     });
78     connect(this, &Room::fileTransferProgress, [=](const QString &id, qint64 progress, qint64 total) {
79         if (id == txnId) {
80             setFileUploadingProgress(int(float(progress) / float(total) * 100));
81         }
82     });
83 }
84 
acceptInvitation()85 void NeoChatRoom::acceptInvitation()
86 {
87     connection()->joinRoom(id());
88 }
89 
forget()90 void NeoChatRoom::forget()
91 {
92     connection()->forgetRoom(id());
93 }
94 
getUsersTyping() const95 QVariantList NeoChatRoom::getUsersTyping() const
96 {
97     auto users = usersTyping();
98     users.removeAll(localUser());
99     QVariantList userVariants;
100     for (User *user : users) {
101         userVariants.append(QVariantMap{
102             {"id", user->id()},
103             {"avatarMediaId", user->avatarMediaId(this)},
104             {"displayName", user->displayname(this)},
105             {"display", user->name()},
106         });
107     }
108     return userVariants;
109 }
110 
sendTypingNotification(bool isTyping)111 void NeoChatRoom::sendTypingNotification(bool isTyping)
112 {
113     connection()->callApi<SetTypingJob>(BackgroundRequest, localUser()->id(), id(), isTyping, 10000);
114 }
115 
lastEvent(bool ignoreStateEvent) const116 const RoomMessageEvent *NeoChatRoom::lastEvent(bool ignoreStateEvent) const
117 {
118     for (auto timelineItem = messageEvents().rbegin(); timelineItem < messageEvents().rend(); timelineItem++) {
119         const RoomEvent *event = timelineItem->get();
120 
121         if (is<RedactionEvent>(*event) || is<ReactionEvent>(*event)) {
122             continue;
123         }
124         if (event->isRedacted()) {
125             continue;
126         }
127 
128         if (event->isStateEvent()
129             && (ignoreStateEvent || !NeoChatConfig::self()->showLeaveJoinEvent() || static_cast<const StateEventBase &>(*event).repeatsState())) {
130             continue;
131         }
132 
133         if (auto roomEvent = eventCast<const RoomMessageEvent>(event)) {
134             if (!roomEvent->replacedEvent().isEmpty() && roomEvent->replacedEvent() != roomEvent->id()) {
135                 continue;
136             }
137         }
138 
139         if (connection()->isIgnored(user(event->senderId()))) {
140             continue;
141         }
142 
143         if (auto lastEvent = eventCast<const RoomMessageEvent>(event)) {
144             return lastEvent;
145         }
146     }
147     return nullptr;
148 }
149 
lastEventToString() const150 QString NeoChatRoom::lastEventToString() const
151 {
152     if (auto event = lastEvent()) {
153         return user(event->senderId())->displayname(this) + (event->isStateEvent() ? " " : ": ") + eventToString(*event);
154     }
155     return QLatin1String("");
156 }
157 
isEventHighlighted(const RoomEvent * e) const158 bool NeoChatRoom::isEventHighlighted(const RoomEvent *e) const
159 {
160     return highlights.contains(e);
161 }
162 
checkForHighlights(const Quotient::TimelineItem & ti)163 void NeoChatRoom::checkForHighlights(const Quotient::TimelineItem &ti)
164 {
165     auto localUserId = localUser()->id();
166     if (ti->senderId() == localUserId) {
167         return;
168     }
169     if (auto *e = ti.viewAs<RoomMessageEvent>()) {
170         const auto &text = e->plainBody();
171         if (text.contains(localUserId) || text.contains(roomMembername(localUserId))) {
172             highlights.insert(e);
173         }
174     }
175 }
176 
onAddNewTimelineEvents(timeline_iter_t from)177 void NeoChatRoom::onAddNewTimelineEvents(timeline_iter_t from)
178 {
179     std::for_each(from, messageEvents().cend(), [this](const TimelineItem &ti) {
180         checkForHighlights(ti);
181     });
182 }
183 
onAddHistoricalTimelineEvents(rev_iter_t from)184 void NeoChatRoom::onAddHistoricalTimelineEvents(rev_iter_t from)
185 {
186     std::for_each(from, messageEvents().crend(), [this](const TimelineItem &ti) {
187         checkForHighlights(ti);
188     });
189 }
190 
onRedaction(const RoomEvent & prevEvent,const RoomEvent &)191 void NeoChatRoom::onRedaction(const RoomEvent &prevEvent, const RoomEvent & /*after*/)
192 {
193     if (const auto &e = eventCast<const ReactionEvent>(&prevEvent)) {
194         if (auto relatedEventId = e->relation().eventId; !relatedEventId.isEmpty()) {
195             Q_EMIT updatedEvent(relatedEventId);
196         }
197     }
198 }
199 
countChanged()200 void NeoChatRoom::countChanged()
201 {
202     if (displayed() && !hasUnreadMessages()) {
203         resetNotificationCount();
204         resetHighlightCount();
205     }
206 }
207 
lastActiveTime()208 QDateTime NeoChatRoom::lastActiveTime()
209 {
210     if (timelineSize() == 0) {
211         return QDateTime();
212     }
213 
214     if (auto event = lastEvent(true)) {
215         return event->originTimestamp();
216     }
217 
218     // no message found, take last event
219     return messageEvents().rbegin()->get()->originTimestamp();
220 }
221 
savedTopVisibleIndex() const222 int NeoChatRoom::savedTopVisibleIndex() const
223 {
224     return firstDisplayedMarker() == timelineEdge() ? 0 : int(firstDisplayedMarker() - messageEvents().rbegin());
225 }
226 
savedBottomVisibleIndex() const227 int NeoChatRoom::savedBottomVisibleIndex() const
228 {
229     return lastDisplayedMarker() == timelineEdge() ? 0 : int(lastDisplayedMarker() - messageEvents().rbegin());
230 }
231 
saveViewport(int topIndex,int bottomIndex)232 void NeoChatRoom::saveViewport(int topIndex, int bottomIndex)
233 {
234     if (topIndex == -1 || bottomIndex == -1 || (bottomIndex == savedBottomVisibleIndex() && (bottomIndex == 0 || topIndex == savedTopVisibleIndex()))) {
235         return;
236     }
237     if (bottomIndex == 0) {
238         setFirstDisplayedEventId({});
239         setLastDisplayedEventId({});
240         return;
241     }
242     setFirstDisplayedEvent(maxTimelineIndex() - topIndex);
243     setLastDisplayedEvent(maxTimelineIndex() - bottomIndex);
244 }
245 
getUsers(const QString & keyword) const246 QVariantList NeoChatRoom::getUsers(const QString &keyword) const
247 {
248     const auto userList = users();
249     QVariantList matchedList;
250     for (const auto u : userList) {
251         if (u->displayname(this).contains(keyword, Qt::CaseInsensitive)) {
252             NeoChatUser user(u->id(), u->connection());
253             QVariantMap userVariant{{QStringLiteral("id"), user.id()},
254                                     {QStringLiteral("displayName"), user.displayname(this)},
255                                     {QStringLiteral("avatarMediaId"), user.avatarMediaId(this)},
256                                     {QStringLiteral("color"), user.color()}};
257 
258             matchedList.append(QVariant::fromValue(userVariant));
259         }
260     }
261 
262     return matchedList;
263 }
264 
getUser(const QString & userID) const265 QVariantMap NeoChatRoom::getUser(const QString& userID) const
266 {
267     NeoChatUser user(userID, connection());
268     return QVariantMap {
269         { QStringLiteral("id"), user.id() },
270         { QStringLiteral("displayName"), user.displayname(this) },
271         { QStringLiteral("avatarMediaId"), user.avatarMediaId(this) },
272         { QStringLiteral("color"), user.color() }
273     };
274 }
275 
urlToMxcUrl(const QUrl & mxcUrl)276 QUrl NeoChatRoom::urlToMxcUrl(const QUrl &mxcUrl)
277 {
278     return DownloadFileJob::makeRequestUrl(connection()->homeserver(), mxcUrl);
279 }
280 
avatarMediaId() const281 QString NeoChatRoom::avatarMediaId() const
282 {
283     if (const auto avatar = Room::avatarMediaId(); !avatar.isEmpty()) {
284         return avatar;
285     }
286 
287     // Use the first (excluding self) user's avatar for direct chats
288     const auto dcUsers = directChatUsers();
289     for (const auto u : dcUsers) {
290         if (u != localUser()) {
291             return u->avatarMediaId(this);
292         }
293     }
294 
295     return {};
296 }
297 
eventToString(const RoomEvent & evt,Qt::TextFormat format,bool removeReply) const298 QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, bool removeReply) const
299 {
300     const bool prettyPrint = (format == Qt::RichText);
301 
302     using namespace Quotient;
303     return visit(
304         evt,
305         [prettyPrint, removeReply](const RoomMessageEvent &e) {
306             using namespace MessageEventContent;
307 
308             // 1. prettyPrint/HTML
309             if (prettyPrint && e.hasTextContent() && e.mimeType().name() != "text/plain") {
310                 auto htmlBody = static_cast<const TextContent *>(e.content())->body;
311                 if (removeReply) {
312                     htmlBody.remove(utils::removeRichReplyRegex);
313                 }
314                 htmlBody.replace(utils::userPillRegExp, R"(<b class="user-pill">\1</b>)");
315                 htmlBody.replace(utils::strikethroughRegExp, "<s>\\1</s>");
316 
317                 return htmlBody;
318             }
319 
320             if (e.hasFileContent()) {
321                 auto fileCaption = e.content()->fileInfo()->originalName.toHtmlEscaped();
322                 if (fileCaption.isEmpty()) {
323                     fileCaption = prettyPrint ? Quotient::prettyPrint(e.plainBody()) : e.plainBody();
324                 } else if (e.content()->fileInfo()->originalName != e.plainBody()) {
325                     fileCaption = e.plainBody() + " | " + fileCaption;
326                 }
327                 return !fileCaption.isEmpty() ? fileCaption : i18n("a file");
328             }
329 
330             // 2. prettyPrint/text 3. plainText/HTML 4. plainText/text
331             QString plainBody;
332             if (e.hasTextContent() && e.content() && e.mimeType().name() == "text/plain") { // 2/4
333                 plainBody = static_cast<const TextContent *>(e.content())->body;
334             } else { // 3
335                 plainBody = e.plainBody();
336             }
337 
338             if (prettyPrint) {
339                 if (removeReply) {
340                     plainBody.remove(utils::removeReplyRegex);
341                 }
342                 return Quotient::prettyPrint(plainBody);
343             }
344             if (removeReply) {
345                 return plainBody.remove(utils::removeReplyRegex);
346             }
347             return plainBody;
348         },
349         [](const StickerEvent &e) {
350             return e.body();
351         },
352         [this](const RoomMemberEvent &e) {
353             // FIXME: Rewind to the name that was at the time of this event
354             auto subjectName = this->user(e.userId())->displayname();
355             // The below code assumes senderName output in AuthorRole
356             switch (e.membership()) {
357             case MembershipType::Invite:
358                 if (e.repeatsState()) {
359                     auto text = i18n("reinvited %1 to the room", subjectName);
360                     if (!e.reason().isEmpty()) {
361                         text += i18nc("Optional reason for an invitation", ": %1") + e.reason().toHtmlEscaped();
362                     }
363                     return text;
364                 }
365                 Q_FALLTHROUGH();
366             case MembershipType::Join: {
367                 QString text{};
368                 // Part 1: invites and joins
369                 if (e.repeatsState()) {
370                     text = i18n("joined the room (repeated)");
371                 } else if (e.changesMembership()) {
372                     text = e.membership() == MembershipType::Invite ? i18n("invited %1 to the room", subjectName) : i18n("joined the room");
373                 }
374                 if (!text.isEmpty()) {
375                     if (!e.reason().isEmpty()) {
376                         text += i18n(": %1", e.reason().toHtmlEscaped());
377                     }
378                     return text;
379                 }
380                 // Part 2: profile changes of joined members
381                 if (e.isRename() && NeoChatConfig::self()->showRename()) {
382                     if (!e.displayName().isEmpty()) {
383                         text = i18nc("their refers to a singular user", "cleared their display name");
384                     } else {
385                         text = i18nc("their refers to a singular user", "changed their display name to %1", e.displayName().toHtmlEscaped());
386                     }
387                 }
388                 if (e.isAvatarUpdate() && NeoChatConfig::self()->showAvatarUpdate()) {
389                     if (!text.isEmpty()) {
390                         text += i18n(" and ");
391                     }
392                     if (e.avatarUrl().isEmpty()) {
393                         text += i18nc("their refers to a singular user", "cleared their avatar");
394 #ifdef QUOTIENT_07
395                     } else if (!e.prevContent()->avatarUrl) {
396 #else
397                     } else if (e.prevContent()->avatarUrl.isEmpty()) {
398 #endif
399                         text += i18n("set an avatar");
400                     } else {
401                         text += i18nc("their refers to a singular user", "updated their avatar");
402                     }
403                 }
404                 return text;
405             }
406             case MembershipType::Leave:
407                 if (e.prevContent() && e.prevContent()->membership == MembershipType::Invite) {
408                     return (e.senderId() != e.userId()) ? i18n("withdrew %1's invitation", subjectName) : i18n("rejected the invitation");
409                 }
410 
411                 if (e.prevContent() && e.prevContent()->membership == MembershipType::Ban) {
412                     return (e.senderId() != e.userId()) ? i18n("unbanned %1", subjectName) : i18n("self-unbanned");
413                 }
414                 return (e.senderId() != e.userId())
415                     ? i18n("has put %1 out of the room: %2", subjectName, e.contentJson()["reason"_ls].toString().toHtmlEscaped())
416                     : i18n("left the room");
417             case MembershipType::Ban:
418                 return (e.senderId() != e.userId()) ? i18n("banned %1 from the room: %2", subjectName, e.contentJson()["reason"_ls].toString().toHtmlEscaped())
419                                                     : i18n("self-banned from the room");
420             case MembershipType::Knock:
421                 return i18n("knocked");
422             default:;
423             }
424             return i18n("made something unknown");
425         },
426         [](const RoomCanonicalAliasEvent &e) {
427             return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias to: %1", e.alias());
428         },
429         [](const RoomNameEvent &e) {
430             return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name to: %1", e.name().toHtmlEscaped());
431         },
432         [prettyPrint](const RoomTopicEvent &e) {
433             return (e.topic().isEmpty()) ? i18n("cleared the topic") : i18n("set the topic to: %1", prettyPrint ? Quotient::prettyPrint(e.topic()) : e.topic());
434         },
435         [](const RoomAvatarEvent &) {
436             return i18n("changed the room avatar");
437         },
438         [](const EncryptionEvent &) {
439             return i18n("activated End-to-End Encryption");
440         },
441         [](const RoomCreateEvent &e) {
442             return e.isUpgrade() ? i18n("upgraded the room to version %1", e.version().isEmpty() ? "1" : e.version().toHtmlEscaped())
443                                  : i18n("created the room, version %1", e.version().isEmpty() ? "1" : e.version().toHtmlEscaped());
444         },
445         [](const StateEventBase &e) {
446             // A small hack for state events from TWIM bot
447             return e.stateKey() == "twim" ? i18n("updated the database")
448                 : e.stateKey().isEmpty()  ? i18n("updated %1 state", e.matrixType())
449                                           : i18n("updated %1 state for %2", e.matrixType(), e.stateKey().toHtmlEscaped());
450         },
451         i18n("Unknown event"));
452 }
453 
changeAvatar(const QUrl & localFile)454 void NeoChatRoom::changeAvatar(const QUrl &localFile)
455 {
456     const auto job = connection()->uploadFile(localFile.toLocalFile());
457 #ifdef QUOTIENT_07
458     if(isJobPending(job)) {
459 #else
460     if (isJobRunning(job)) {
461 #endif
462         connect(job, &BaseJob::success, this, [this, job] {
463             connection()->callApi<SetRoomStateWithKeyJob>(id(), "m.room.avatar", localUser()->id(), QJsonObject{{"url", job->contentUri()}});
464         });
465     }
466 }
467 
468 void NeoChatRoom::addLocalAlias(const QString &alias)
469 {
470     auto a = aliases();
471     if (a.contains(alias)) {
472         return;
473     }
474 
475     a += alias;
476 
477     setLocalAliases(a);
478 }
479 
480 void NeoChatRoom::removeLocalAlias(const QString &alias)
481 {
482     auto a = aliases();
483     if (!a.contains(alias)) {
484         return;
485     }
486 
487     a.removeAll(alias);
488 
489     setLocalAliases(a);
490 }
491 
492 QString NeoChatRoom::markdownToHTML(const QString &markdown)
493 {
494     const auto str = markdown.toUtf8();
495     char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_DEFAULT);
496 
497     const std::string html(tmp_buf);
498 
499     free(tmp_buf);
500 
501     auto result = QString::fromStdString(html).trimmed();
502 
503     result.replace("<!-- raw HTML omitted -->", "<br />");
504     result.replace(QRegularExpression("(<br />)*$"), "");
505     result.replace("<p>", "");
506     result.replace("</p>", "");
507 
508     return result;
509 }
510 
511 QString msgTypeToString(MessageEventType msgType)
512 {
513     switch (msgType) {
514     case MessageEventType::Text:
515         return "m.text";
516     case MessageEventType::File:
517         return "m.file";
518     case MessageEventType::Audio:
519         return "m.audio";
520     case MessageEventType::Emote:
521         return "m.emote";
522     case MessageEventType::Image:
523         return "m.image";
524     case MessageEventType::Video:
525         return "m.video";
526     case MessageEventType::Notice:
527         return "m.notice";
528     case MessageEventType::Location:
529         return "m.location";
530     default:
531         return "m.text";
532     }
533 }
534 
535 void NeoChatRoom::postMessage(const QString &rawText, const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)
536 {
537     const auto html = markdownToHTML(text);
538     QString cleanText(text);
539     cleanText.replace(QRegularExpression("\\[(.+)\\]\\(.+\\)"), "\\1");
540     postHtmlMessage(rawText, html, type, replyEventId, relateToEventId);
541 }
542 
543 void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)
544 {
545     bool isRichText = Qt::mightBeRichText(html);
546     bool isReply = !replyEventId.isEmpty();
547     bool isEdit = !relateToEventId.isEmpty();
548     const auto replyIt = findInTimeline(replyEventId);
549     if (replyIt == timelineEdge()) {
550         isReply = false;
551     }
552 
553     if (isEdit) {
554         QJsonObject json{
555             {"type", "m.room.message"},
556             {"msgtype", msgTypeToString(type)},
557             {"body", "* " + text},
558             {"format", "org.matrix.custom.html"},
559             {"formatted_body", html},
560             {"m.new_content", QJsonObject{{"body", text}, {"msgtype", msgTypeToString(type)}, {"format", "org.matrix.custom.html"}, {"formatted_body", html}}},
561             {"m.relates_to", QJsonObject{{"rel_type", "m.replace"}, {"event_id", relateToEventId}}}};
562 
563         postJson("m.room.message", json);
564         return;
565     }
566 
567     if (isReply) {
568         const auto &replyEvt = **replyIt;
569 
570         // clang-format off
571         QJsonObject json{
572           {"msgtype", msgTypeToString(type)},
573           {"body", "> <" + replyEvt.senderId() + "> " + eventToString(replyEvt) + "\n\n" + text},
574           {"format", "org.matrix.custom.html"},
575           {"m.relates_to",
576             QJsonObject {
577               {"m.in_reply_to",
578                 QJsonObject {
579                   {"event_id", replyEventId}
580                 }
581               }
582             }
583           },
584           {"formatted_body",
585             "<mx-reply><blockquote><a href=\"https://matrix.to/#/" +
586             id() + "/" +
587             replyEventId +
588             "\">In reply to</a> <a href=\"https://matrix.to/#/" +
589             replyEvt.senderId() + "\">" + replyEvt.senderId() +
590             "</a><br>" + eventToString(replyEvt, Qt::RichText) +
591             "</blockquote></mx-reply>" + (isRichText ? html : text)
592           }
593         };
594         // clang-format on
595 
596         postJson("m.room.message", json);
597 
598         return;
599     }
600 
601     if (isRichText) {
602         Room::postHtmlMessage(text, html, type);
603     } else {
604         Room::postMessage(text, type);
605     }
606 }
607 
608 void NeoChatRoom::toggleReaction(const QString &eventId, const QString &reaction)
609 {
610     if (eventId.isEmpty() || reaction.isEmpty()) {
611         return;
612     }
613 
614     const auto eventIt = findInTimeline(eventId);
615     if (eventIt == timelineEdge()) {
616         return;
617     }
618 
619     const auto &evt = **eventIt;
620 
621     QStringList redactEventIds; // What if there are multiple reaction events?
622 
623     const auto &annotations = relatedEvents(evt, EventRelation::Annotation());
624     if (!annotations.isEmpty()) {
625         for (const auto &a : annotations) {
626             if (auto e = eventCast<const ReactionEvent>(a)) {
627                 if (e->relation().key != reaction) {
628                     continue;
629                 }
630 
631                 if (e->senderId() == localUser()->id()) {
632                     redactEventIds.push_back(e->id());
633                     break;
634                 }
635             }
636         }
637     }
638 
639     if (!redactEventIds.isEmpty()) {
640         for (const auto &redactEventId : redactEventIds) {
641             redactEvent(redactEventId);
642         }
643     } else {
644         postReaction(eventId, reaction);
645     }
646 }
647 
648 bool NeoChatRoom::containsUser(const QString &userID) const
649 {
650     auto u = Room::user(userID);
651 
652     if (!u) {
653         return false;
654     }
655 
656     return Room::memberJoinState(u) != JoinState::Leave;
657 }
658 
659 bool NeoChatRoom::canSendEvent(const QString &eventType) const
660 {
661     auto plEvent = getCurrentState<RoomPowerLevelsEvent>();
662     auto pl = plEvent->powerLevelForEvent(eventType);
663     auto currentPl = plEvent->powerLevelForUser(localUser()->id());
664 
665     return currentPl >= pl;
666 }
667 
668 bool NeoChatRoom::canSendState(const QString &eventType) const
669 {
670     auto plEvent = getCurrentState<RoomPowerLevelsEvent>();
671     auto pl = plEvent->powerLevelForState(eventType);
672     auto currentPl = plEvent->powerLevelForUser(localUser()->id());
673 
674     return currentPl >= pl;
675 }
676 
677 bool NeoChatRoom::readMarkerLoaded() const
678 {
679     const auto it = findInTimeline(readMarkerEventId());
680     return it != timelineEdge();
681 }
682 
683 bool NeoChatRoom::isInvite() const
684 {
685     return joinState() == JoinState::Invite;
686 }
687 
688 bool NeoChatRoom::isUserBanned(const QString &user) const
689 {
690     return getCurrentState<RoomMemberEvent>(user)->membership() == MembershipType::Ban;
691 }
692