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