1 #include "providers/ffz/FfzEmotes.hpp"
2 
3 #include <QJsonArray>
4 
5 #include "common/NetworkRequest.hpp"
6 #include "common/Outcome.hpp"
7 #include "common/QLogging.hpp"
8 #include "messages/Emote.hpp"
9 #include "messages/Image.hpp"
10 #include "messages/MessageBuilder.hpp"
11 #include "providers/twitch/TwitchChannel.hpp"
12 
13 namespace chatterino {
14 namespace {
15 
16     const QString CHANNEL_HAS_NO_EMOTES(
17         "This channel has no FrankerFaceZ channel emotes.");
18 
getEmoteLink(const QJsonObject & urls,const QString & emoteScale)19     Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale)
20     {
21         auto emote = urls.value(emoteScale);
22         if (emote.isUndefined() || emote.isNull())
23         {
24             return {""};
25         }
26 
27         assert(emote.isString());
28 
29         return {"https:" + emote.toString()};
30     }
fillInEmoteData(const QJsonObject & urls,const EmoteName & name,const QString & tooltip,Emote & emoteData)31     void fillInEmoteData(const QJsonObject &urls, const EmoteName &name,
32                          const QString &tooltip, Emote &emoteData)
33     {
34         auto url1x = getEmoteLink(urls, "1");
35         auto url2x = getEmoteLink(urls, "2");
36         auto url3x = getEmoteLink(urls, "4");
37 
38         //, code, tooltip
39         emoteData.name = name;
40         emoteData.images =
41             ImageSet{Image::fromUrl(url1x, 1),
42                      url2x.string.isEmpty() ? Image::getEmpty()
43                                             : Image::fromUrl(url2x, 0.5),
44                      url3x.string.isEmpty() ? Image::getEmpty()
45                                             : Image::fromUrl(url3x, 0.25)};
46         emoteData.tooltip = {tooltip};
47     }
cachedOrMake(Emote && emote,const EmoteId & id)48     EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id)
49     {
50         static std::unordered_map<EmoteId, std::weak_ptr<const Emote>> cache;
51         static std::mutex mutex;
52 
53         return cachedOrMakeEmotePtr(std::move(emote), cache, mutex, id);
54     }
parseGlobalEmotes(const QJsonObject & jsonRoot,const EmoteMap & currentEmotes)55     std::pair<Outcome, EmoteMap> parseGlobalEmotes(
56         const QJsonObject &jsonRoot, const EmoteMap &currentEmotes)
57     {
58         auto jsonSets = jsonRoot.value("sets").toObject();
59         auto emotes = EmoteMap();
60 
61         for (auto jsonSet : jsonSets)
62         {
63             auto jsonEmotes = jsonSet.toObject().value("emoticons").toArray();
64 
65             for (auto jsonEmoteValue : jsonEmotes)
66             {
67                 auto jsonEmote = jsonEmoteValue.toObject();
68 
69                 auto name = EmoteName{jsonEmote.value("name").toString()};
70                 auto id =
71                     EmoteId{QString::number(jsonEmote.value("id").toInt())};
72                 auto urls = jsonEmote.value("urls").toObject();
73 
74                 auto emote = Emote();
75                 fillInEmoteData(urls, name,
76                                 name.string + "<br>Global FFZ Emote", emote);
77                 emote.homePage =
78                     Url{QString("https://www.frankerfacez.com/emoticon/%1-%2")
79                             .arg(id.string)
80                             .arg(name.string)};
81 
82                 emotes[name] =
83                     cachedOrMakeEmotePtr(std::move(emote), currentEmotes);
84             }
85         }
86 
87         return {Success, std::move(emotes)};
88     }
89 
parseAuthorityBadge(const QJsonObject & badgeUrls,const QString tooltip)90     boost::optional<EmotePtr> parseAuthorityBadge(const QJsonObject &badgeUrls,
91                                                   const QString tooltip)
92     {
93         boost::optional<EmotePtr> authorityBadge;
94 
95         if (!badgeUrls.isEmpty())
96         {
97             auto authorityBadge1x = getEmoteLink(badgeUrls, "1");
98             auto authorityBadge2x = getEmoteLink(badgeUrls, "2");
99             auto authorityBadge3x = getEmoteLink(badgeUrls, "4");
100 
101             auto authorityBadgeImageSet = ImageSet{
102                 Image::fromUrl(authorityBadge1x, 1),
103                 authorityBadge2x.string.isEmpty()
104                     ? Image::getEmpty()
105                     : Image::fromUrl(authorityBadge2x, 0.5),
106                 authorityBadge3x.string.isEmpty()
107                     ? Image::getEmpty()
108                     : Image::fromUrl(authorityBadge3x, 0.25),
109             };
110 
111             authorityBadge = std::make_shared<Emote>(Emote{
112                 {""},
113                 authorityBadgeImageSet,
114                 Tooltip{tooltip},
115                 authorityBadge1x,
116             });
117         }
118         return authorityBadge;
119     }
120 
parseChannelEmotes(const QJsonObject & jsonRoot)121     EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot)
122     {
123         auto jsonSets = jsonRoot.value("sets").toObject();
124         auto emotes = EmoteMap();
125 
126         for (auto jsonSet : jsonSets)
127         {
128             auto jsonEmotes = jsonSet.toObject().value("emoticons").toArray();
129 
130             for (auto _jsonEmote : jsonEmotes)
131             {
132                 auto jsonEmote = _jsonEmote.toObject();
133 
134                 // margins
135                 auto id =
136                     EmoteId{QString::number(jsonEmote.value("id").toInt())};
137                 auto name = EmoteName{jsonEmote.value("name").toString()};
138                 auto author = EmoteAuthor{jsonEmote.value("owner")
139                                               .toObject()
140                                               .value("display_name")
141                                               .toString()};
142                 auto urls = jsonEmote.value("urls").toObject();
143 
144                 Emote emote;
145                 fillInEmoteData(urls, name,
146                                 QString("%1<br>Channel FFZ Emote<br>By: %2")
147                                     .arg(name.string)
148                                     .arg(author.string),
149                                 emote);
150                 emote.homePage =
151                     Url{QString("https://www.frankerfacez.com/emoticon/%1-%2")
152                             .arg(id.string)
153                             .arg(name.string)};
154 
155                 emotes[name] = cachedOrMake(std::move(emote), id);
156             }
157         }
158 
159         return emotes;
160     }
161 }  // namespace
162 
FfzEmotes()163 FfzEmotes::FfzEmotes()
164     : global_(std::make_shared<EmoteMap>())
165 {
166 }
167 
emotes() const168 std::shared_ptr<const EmoteMap> FfzEmotes::emotes() const
169 {
170     return this->global_.get();
171 }
172 
emote(const EmoteName & name) const173 boost::optional<EmotePtr> FfzEmotes::emote(const EmoteName &name) const
174 {
175     auto emotes = this->global_.get();
176     auto it = emotes->find(name);
177     if (it != emotes->end())
178         return it->second;
179     return boost::none;
180 }
181 
loadEmotes()182 void FfzEmotes::loadEmotes()
183 {
184     QString url("https://api.frankerfacez.com/v1/set/global");
185 
186     NetworkRequest(url)
187 
188         .timeout(30000)
189         .onSuccess([this](auto result) -> Outcome {
190             auto emotes = this->emotes();
191             auto pair = parseGlobalEmotes(result.parseJson(), *emotes);
192             if (pair.first)
193                 this->global_.set(
194                     std::make_shared<EmoteMap>(std::move(pair.second)));
195             return pair.first;
196         })
197         .execute();
198 }
199 
loadChannel(std::weak_ptr<Channel> channel,const QString & channelId,std::function<void (EmoteMap &&)> emoteCallback,std::function<void (boost::optional<EmotePtr>)> modBadgeCallback,std::function<void (boost::optional<EmotePtr>)> vipBadgeCallback,bool manualRefresh)200 void FfzEmotes::loadChannel(
201     std::weak_ptr<Channel> channel, const QString &channelId,
202     std::function<void(EmoteMap &&)> emoteCallback,
203     std::function<void(boost::optional<EmotePtr>)> modBadgeCallback,
204     std::function<void(boost::optional<EmotePtr>)> vipBadgeCallback,
205     bool manualRefresh)
206 {
207     qCDebug(chatterinoFfzemotes)
208         << "[FFZEmotes] Reload FFZ Channel Emotes for channel" << channelId;
209 
210     NetworkRequest("https://api.frankerfacez.com/v1/room/id/" + channelId)
211 
212         .timeout(20000)
213         .onSuccess([emoteCallback = std::move(emoteCallback),
214                     modBadgeCallback = std::move(modBadgeCallback),
215                     vipBadgeCallback = std::move(vipBadgeCallback), channel,
216                     manualRefresh](auto result) -> Outcome {
217             auto json = result.parseJson();
218             auto emoteMap = parseChannelEmotes(json);
219             auto modBadge = parseAuthorityBadge(
220                 json.value("room").toObject().value("mod_urls").toObject(),
221                 "Moderator");
222             auto vipBadge = parseAuthorityBadge(
223                 json.value("room").toObject().value("vip_badge").toObject(),
224                 "VIP");
225 
226             bool hasEmotes = !emoteMap.empty();
227 
228             emoteCallback(std::move(emoteMap));
229             modBadgeCallback(std::move(modBadge));
230             vipBadgeCallback(std::move(vipBadge));
231             if (auto shared = channel.lock(); manualRefresh)
232             {
233                 if (hasEmotes)
234                 {
235                     shared->addMessage(makeSystemMessage(
236                         "FrankerFaceZ channel emotes reloaded."));
237                 }
238                 else
239                 {
240                     shared->addMessage(
241                         makeSystemMessage(CHANNEL_HAS_NO_EMOTES));
242                 }
243             }
244 
245             return Success;
246         })
247         .onError([channelId, channel, manualRefresh](NetworkResult result) {
248             auto shared = channel.lock();
249             if (!shared)
250                 return;
251             if (result.status() == 404)
252             {
253                 // User does not have any FFZ emotes
254                 if (manualRefresh)
255                     shared->addMessage(
256                         makeSystemMessage(CHANNEL_HAS_NO_EMOTES));
257             }
258             else if (result.status() == NetworkResult::timedoutStatus)
259             {
260                 // TODO: Auto retry in case of a timeout, with a delay
261                 qCWarning(chatterinoFfzemotes)
262                     << "Fetching FFZ emotes for channel" << channelId
263                     << "failed due to timeout";
264                 shared->addMessage(
265                     makeSystemMessage("Failed to fetch FrankerFaceZ channel "
266                                       "emotes. (timed out)"));
267             }
268             else
269             {
270                 qCWarning(chatterinoFfzemotes)
271                     << "Error fetching FFZ emotes for channel" << channelId
272                     << ", error" << result.status();
273                 shared->addMessage(
274                     makeSystemMessage("Failed to fetch FrankerFaceZ channel "
275                                       "emotes. (unknown error)"));
276             }
277         })
278         .execute();
279 }
280 
281 }  // namespace chatterino
282