1 | // SPDX-FileCopyrightText: 2015 Felix Rohrbach <kde@fxrh.de> |
2 | // SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net> |
3 | // SPDX-License-Identifier: LGPL-2.1-or-later |
4 | |
5 | #include "user.h" |
6 | |
7 | #include "avatar.h" |
8 | #include "connection.h" |
9 | #include "logging_categories_p.h" |
10 | #include "room.h" |
11 | |
12 | #include "csapi/content-repo.h" |
13 | #include "csapi/profile.h" |
14 | #include "csapi/room_state.h" |
15 | |
16 | #include "events/event.h" |
17 | #include "events/roommemberevent.h" |
18 | |
19 | #include <QtCore/QElapsedTimer> |
20 | #include <QtCore/QPointer> |
21 | #include <QtCore/QRegularExpression> |
22 | #include <QtCore/QStringBuilder> |
23 | #include <QtCore/QTimer> |
24 | |
25 | #include <functional> |
26 | |
27 | using namespace Quotient; |
28 | |
29 | class Q_DECL_HIDDEN User::Private { |
30 | public: |
31 | Private(QString userId) : id(std::move(userId)), hueF(stringToHueF(s: id)) { } |
32 | |
33 | QString id; |
34 | qreal hueF; |
35 | |
36 | QString defaultName; |
37 | Avatar defaultAvatar; |
38 | // NB: This container is ever-growing. Even if the user no more scrolls |
39 | // the timeline that far back, historical avatars are still kept around. |
40 | // This is consistent with the rest of Quotient, as room timelines |
41 | // are never vacuumed either. This will probably change in the future. |
42 | /// Map of mediaId to Avatar objects |
43 | static UnorderedMap<QString, Avatar> otherAvatars; |
44 | }; |
45 | |
46 | decltype(User::Private::otherAvatars) User::Private::otherAvatars {}; |
47 | |
48 | User::User(QString userId, Connection* connection) |
49 | : QObject(connection), d(makeImpl<Private>(args: std::move(userId))) |
50 | { |
51 | setObjectName(id()); |
52 | } |
53 | |
54 | Connection* User::connection() const |
55 | { |
56 | Q_ASSERT(parent()); |
57 | return static_cast<Connection*>(parent()); |
58 | } |
59 | |
60 | void User::load() |
61 | { |
62 | auto* profileJob = |
63 | connection()->callApi<GetUserProfileJob>(jobArgs: id()); |
64 | connect(sender: profileJob, signal: &BaseJob::result, context: this, slot: [this, profileJob] { |
65 | d->defaultName = profileJob->displayname(); |
66 | d->defaultAvatar = Avatar(QUrl(profileJob->avatarUrl())); |
67 | emit defaultNameChanged(); |
68 | emit defaultAvatarChanged(); |
69 | }); |
70 | } |
71 | |
72 | QString User::id() const { return d->id; } |
73 | |
74 | bool User::isGuest() const |
75 | { |
76 | Q_ASSERT(!d->id.isEmpty() && d->id.startsWith(u'@')); |
77 | auto it = std::find_if_not(first: d->id.cbegin() + 1, last: d->id.cend(), |
78 | pred: [](QChar c) { return c.isDigit(); }); |
79 | Q_ASSERT(it != d->id.cend()); |
80 | return *it == u':'; |
81 | } |
82 | |
83 | int User::hue() const { return int(hueF() * 359); } |
84 | |
85 | QString User::name(const Room* room) const |
86 | { |
87 | return room ? room->memberName(mxId: id()) : d->defaultName; |
88 | } |
89 | |
90 | void User::rename(const QString& newName) |
91 | { |
92 | const auto actualNewName = sanitized(plainText: newName); |
93 | if (actualNewName == d->defaultName) |
94 | return; // Nothing to do |
95 | |
96 | connect(sender: connection()->callApi<SetDisplayNameJob>(jobArgs: id(), jobArgs: actualNewName), |
97 | signal: &BaseJob::success, context: this, slot: [this, actualNewName] { |
98 | // Check again, it could have changed meanwhile |
99 | if (actualNewName != d->defaultName) { |
100 | d->defaultName = actualNewName; |
101 | emit defaultNameChanged(); |
102 | } else |
103 | qCWarning(MAIN) |
104 | << "User" << id() << "already has profile name set to" |
105 | << actualNewName; |
106 | }); |
107 | } |
108 | |
109 | void User::rename(const QString& newName, Room* r) |
110 | { |
111 | if (!r) { |
112 | qCWarning(MAIN) << "Passing a null room to two-argument User::rename()" |
113 | "is incorrect; client developer, please fix it" ; |
114 | rename(newName); |
115 | return; |
116 | } |
117 | // #481: take the current state and update it with the new name |
118 | if (const auto& maybeEvt = r->currentState().get<RoomMemberEvent>(stateKey: id())) { |
119 | auto content = maybeEvt->content(); |
120 | if (content.membership == Membership::Join) { |
121 | content.displayName = sanitized(plainText: newName); |
122 | r->setState<RoomMemberEvent>(args: id(), args: std::move(content)); |
123 | // The state will be updated locally after it arrives with sync |
124 | return; |
125 | } |
126 | } |
127 | qCCritical(MEMBERS) |
128 | << "Attempt to rename a non-member in a room context - ignored" ; |
129 | } |
130 | |
131 | template <typename SourceT> |
132 | inline bool User::doSetAvatar(SourceT&& source) |
133 | { |
134 | return d->defaultAvatar.upload( |
135 | connection(), source, [this](const QUrl& contentUri) { |
136 | auto* j = connection()->callApi<SetAvatarUrlJob>(jobArgs: id(), jobArgs: contentUri); |
137 | connect(j, &BaseJob::success, this, |
138 | [this, contentUri] { |
139 | if (contentUri == d->defaultAvatar.url()) { |
140 | d->defaultAvatar.updateUrl(newUrl: contentUri); |
141 | emit defaultAvatarChanged(); |
142 | } else |
143 | qCWarning(MAIN) << "User" << id() |
144 | << "already has avatar URL set to" |
145 | << contentUri.toDisplayString(); |
146 | }); |
147 | }); |
148 | } |
149 | |
150 | bool User::setAvatar(const QString& fileName) |
151 | { |
152 | return doSetAvatar(source: fileName); |
153 | } |
154 | |
155 | bool User::setAvatar(QIODevice* source) |
156 | { |
157 | return doSetAvatar(source); |
158 | } |
159 | |
160 | void User::removeAvatar() |
161 | { |
162 | connection()->callApi<SetAvatarUrlJob>(jobArgs: id(), jobArgs: QUrl()); |
163 | } |
164 | |
165 | void User::requestDirectChat() { connection()->requestDirectChat(u: this); } |
166 | |
167 | void User::ignore() { connection()->addToIgnoredUsers(user: this); } |
168 | |
169 | void User::unmarkIgnore() { connection()->removeFromIgnoredUsers(user: this); } |
170 | |
171 | bool User::isIgnored() const { return connection()->isIgnored(user: this); } |
172 | |
173 | QString User::displayname(const Room* room) const |
174 | { |
175 | return room ? room->safeMemberName(userId: id()) |
176 | : d->defaultName.isEmpty() ? d->id : d->defaultName; |
177 | } |
178 | |
179 | QString User::fullName(const Room* room) const |
180 | { |
181 | const auto displayName = name(room); |
182 | return displayName.isEmpty() ? id() : (displayName % " ("_ls % id() % u')'); |
183 | } |
184 | |
185 | const Avatar& User::avatarObject(const Room* room) const |
186 | { |
187 | if (!room) |
188 | return d->defaultAvatar; |
189 | |
190 | const auto& url = room->memberAvatarUrl(mxId: id()); |
191 | const auto& mediaId = url.authority() + url.path(); |
192 | return d->otherAvatars.try_emplace(k: mediaId, args: url).first->second; |
193 | } |
194 | |
195 | QImage User::avatar(int dimension, const Room* room) const |
196 | { |
197 | return avatar(requestedWidth: dimension, requestedHeight: dimension, room); |
198 | } |
199 | |
200 | QImage User::avatar(int width, int height, const Room* room) const |
201 | { |
202 | return avatar(width, height, room, callback: [] {}); |
203 | } |
204 | |
205 | QImage User::avatar(int width, int height, const Room* room, |
206 | const Avatar::get_callback_t& callback) const |
207 | { |
208 | return avatarObject(room).get(connection: connection(), w: width, h: height, callback); |
209 | } |
210 | |
211 | QString User::avatarMediaId(const Room* room) const |
212 | { |
213 | return avatarObject(room).mediaId(); |
214 | } |
215 | |
216 | QUrl User::avatarUrl(const Room* room) const |
217 | { |
218 | return avatarObject(room).url(); |
219 | } |
220 | |
221 | qreal User::hueF() const { return d->hueF; } |
222 | |