Emoji reactions: Change cache and reply format
[akkoma] / lib / pleroma / web / mastodon_api / views / status_view.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.MastodonAPI.StatusView do
6 use Pleroma.Web, :view
7
8 require Pleroma.Constants
9
10 alias Pleroma.Activity
11 alias Pleroma.ActivityExpiration
12 alias Pleroma.HTML
13 alias Pleroma.Object
14 alias Pleroma.Repo
15 alias Pleroma.User
16 alias Pleroma.Web.CommonAPI
17 alias Pleroma.Web.CommonAPI.Utils
18 alias Pleroma.Web.MastodonAPI.AccountView
19 alias Pleroma.Web.MastodonAPI.PollView
20 alias Pleroma.Web.MastodonAPI.StatusView
21 alias Pleroma.Web.MediaProxy
22
23 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1]
24
25 # TODO: Add cached version.
26 defp get_replied_to_activities([]), do: %{}
27
28 defp get_replied_to_activities(activities) do
29 activities
30 |> Enum.map(fn
31 %{data: %{"type" => "Create"}} = activity ->
32 object = Object.normalize(activity)
33 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
34
35 _ ->
36 nil
37 end)
38 |> Enum.filter(& &1)
39 |> Activity.create_by_object_ap_id_with_object()
40 |> Repo.all()
41 |> Enum.reduce(%{}, fn activity, acc ->
42 object = Object.normalize(activity)
43 if object, do: Map.put(acc, object.data["id"], activity), else: acc
44 end)
45 end
46
47 defp get_user(ap_id) do
48 cond do
49 user = User.get_cached_by_ap_id(ap_id) ->
50 user
51
52 user = User.get_by_guessed_nickname(ap_id) ->
53 user
54
55 true ->
56 User.error_user(ap_id)
57 end
58 end
59
60 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
61 do: context_id
62
63 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
64 do: Utils.context_to_conversation_id(context)
65
66 defp get_context_id(_), do: nil
67
68 defp reblogged?(activity, user) do
69 object = Object.normalize(activity) || %{}
70 present?(user && user.ap_id in (object.data["announcements"] || []))
71 end
72
73 def render("index.json", opts) do
74 replied_to_activities = get_replied_to_activities(opts.activities)
75 opts = Map.put(opts, :replied_to_activities, replied_to_activities)
76
77 safe_render_many(opts.activities, StatusView, "show.json", opts)
78 end
79
80 def render(
81 "show.json",
82 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
83 ) do
84 user = get_user(activity.data["actor"])
85 created_at = Utils.to_masto_date(activity.data["published"])
86 activity_object = Object.normalize(activity)
87
88 reblogged_activity =
89 Activity.create_by_object_ap_id(activity_object.data["id"])
90 |> Activity.with_preloaded_bookmark(opts[:for])
91 |> Activity.with_set_thread_muted_field(opts[:for])
92 |> Repo.one()
93
94 reblogged = render("show.json", Map.put(opts, :activity, reblogged_activity))
95
96 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
97
98 bookmarked = Activity.get_bookmark(reblogged_activity, opts[:for]) != nil
99
100 mentions =
101 activity.recipients
102 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
103 |> Enum.filter(& &1)
104 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
105
106 %{
107 id: to_string(activity.id),
108 uri: activity_object.data["id"],
109 url: activity_object.data["id"],
110 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
111 in_reply_to_id: nil,
112 in_reply_to_account_id: nil,
113 reblog: reblogged,
114 content: reblogged[:content] || "",
115 created_at: created_at,
116 reblogs_count: 0,
117 replies_count: 0,
118 favourites_count: 0,
119 reblogged: reblogged?(reblogged_activity, opts[:for]),
120 favourited: present?(favorited),
121 bookmarked: present?(bookmarked),
122 muted: false,
123 pinned: pinned?(activity, user),
124 sensitive: false,
125 spoiler_text: "",
126 visibility: get_visibility(activity),
127 media_attachments: reblogged[:media_attachments] || [],
128 mentions: mentions,
129 tags: reblogged[:tags] || [],
130 application: %{
131 name: "Web",
132 website: nil
133 },
134 language: nil,
135 emojis: [],
136 pleroma: %{
137 local: activity.local
138 }
139 }
140 end
141
142 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
143 object = Object.normalize(activity)
144
145 user = get_user(activity.data["actor"])
146 user_follower_address = user.follower_address
147
148 like_count = object.data["like_count"] || 0
149 announcement_count = object.data["announcement_count"] || 0
150
151 tags = object.data["tag"] || []
152 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
153
154 tag_mentions =
155 tags
156 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
157 |> Enum.map(fn tag -> tag["href"] end)
158
159 mentions =
160 (object.data["to"] ++ tag_mentions)
161 |> Enum.uniq()
162 |> Enum.map(fn
163 Pleroma.Constants.as_public() -> nil
164 ^user_follower_address -> nil
165 ap_id -> User.get_cached_by_ap_id(ap_id)
166 end)
167 |> Enum.filter(& &1)
168 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
169
170 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
171
172 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
173
174 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
175
176 expires_at =
177 with true <- client_posted_this_activity,
178 expiration when not is_nil(expiration) <-
179 ActivityExpiration.get_by_activity_id(activity.id) do
180 expiration.scheduled_at
181 end
182
183 thread_muted? =
184 case activity.thread_muted? do
185 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
186 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
187 end
188
189 attachment_data = object.data["attachment"] || []
190 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
191
192 created_at = Utils.to_masto_date(object.data["published"])
193
194 reply_to = get_reply_to(activity, opts)
195
196 reply_to_user = reply_to && get_user(reply_to.data["actor"])
197
198 content =
199 object
200 |> render_content()
201
202 content_html =
203 content
204 |> HTML.get_cached_scrubbed_html_for_activity(
205 User.html_filter_policy(opts[:for]),
206 activity,
207 "mastoapi:content"
208 )
209
210 content_plaintext =
211 content
212 |> HTML.get_cached_stripped_html_for_activity(
213 activity,
214 "mastoapi:content"
215 )
216
217 summary = object.data["summary"] || ""
218
219 summary_html =
220 summary
221 |> HTML.get_cached_scrubbed_html_for_activity(
222 User.html_filter_policy(opts[:for]),
223 activity,
224 "mastoapi:summary"
225 )
226
227 summary_plaintext =
228 summary
229 |> HTML.get_cached_stripped_html_for_activity(
230 activity,
231 "mastoapi:summary"
232 )
233
234 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
235
236 url =
237 if user.local do
238 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
239 else
240 object.data["url"] || object.data["external_url"] || object.data["id"]
241 end
242
243 direct_conversation_id =
244 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
245 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
246 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
247 Activity.direct_conversation_id(activity, for_user)
248 else
249 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
250 participation_id
251
252 _e ->
253 nil
254 end
255
256 emoji_reactions =
257 with %{data: %{"reactions" => emoji_reactions}} <- object do
258 Enum.map(emoji_reactions, fn [emoji, users] ->
259 [emoji, length(users)]
260 end)
261 else
262 _ -> []
263 end
264
265 %{
266 id: to_string(activity.id),
267 uri: object.data["id"],
268 url: url,
269 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
270 in_reply_to_id: reply_to && to_string(reply_to.id),
271 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
272 reblog: nil,
273 card: card,
274 content: content_html,
275 created_at: created_at,
276 reblogs_count: announcement_count,
277 replies_count: object.data["repliesCount"] || 0,
278 favourites_count: like_count,
279 reblogged: reblogged?(activity, opts[:for]),
280 favourited: present?(favorited),
281 bookmarked: present?(bookmarked),
282 muted: thread_muted? || User.mutes?(opts[:for], user),
283 pinned: pinned?(activity, user),
284 sensitive: sensitive,
285 spoiler_text: summary_html,
286 visibility: get_visibility(object),
287 media_attachments: attachments,
288 poll: render(PollView, "show.json", object: object, for: opts[:for]),
289 mentions: mentions,
290 tags: build_tags(tags),
291 application: %{
292 name: "Web",
293 website: nil
294 },
295 language: nil,
296 emojis: build_emojis(object.data["emoji"]),
297 pleroma: %{
298 local: activity.local,
299 conversation_id: get_context_id(activity),
300 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
301 content: %{"text/plain" => content_plaintext},
302 spoiler_text: %{"text/plain" => summary_plaintext},
303 expires_at: expires_at,
304 direct_conversation_id: direct_conversation_id,
305 thread_muted: thread_muted?,
306 emoji_reactions: emoji_reactions
307 }
308 }
309 end
310
311 def render("show.json", _) do
312 nil
313 end
314
315 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
316 page_url_data = URI.parse(page_url)
317
318 page_url_data =
319 if rich_media[:url] != nil do
320 URI.merge(page_url_data, URI.parse(rich_media[:url]))
321 else
322 page_url_data
323 end
324
325 page_url = page_url_data |> to_string
326
327 image_url =
328 if rich_media[:image] != nil do
329 URI.merge(page_url_data, URI.parse(rich_media[:image]))
330 |> to_string
331 else
332 nil
333 end
334
335 site_name = rich_media[:site_name] || page_url_data.host
336
337 %{
338 type: "link",
339 provider_name: site_name,
340 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
341 url: page_url,
342 image: image_url |> MediaProxy.url(),
343 title: rich_media[:title] || "",
344 description: rich_media[:description] || "",
345 pleroma: %{
346 opengraph: rich_media
347 }
348 }
349 end
350
351 def render("card.json", _), do: nil
352
353 def render("attachment.json", %{attachment: attachment}) do
354 [attachment_url | _] = attachment["url"]
355 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
356 href = attachment_url["href"] |> MediaProxy.url()
357
358 type =
359 cond do
360 String.contains?(media_type, "image") -> "image"
361 String.contains?(media_type, "video") -> "video"
362 String.contains?(media_type, "audio") -> "audio"
363 true -> "unknown"
364 end
365
366 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
367
368 %{
369 id: to_string(attachment["id"] || hash_id),
370 url: href,
371 remote_url: href,
372 preview_url: href,
373 text_url: href,
374 type: type,
375 description: attachment["name"],
376 pleroma: %{mime_type: media_type}
377 }
378 end
379
380 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
381 object = Object.normalize(activity)
382
383 user = get_user(activity.data["actor"])
384 created_at = Utils.to_masto_date(activity.data["published"])
385
386 %{
387 id: activity.id,
388 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
389 created_at: created_at,
390 title: object.data["title"] |> HTML.strip_tags(),
391 artist: object.data["artist"] |> HTML.strip_tags(),
392 album: object.data["album"] |> HTML.strip_tags(),
393 length: object.data["length"]
394 }
395 end
396
397 def render("listens.json", opts) do
398 safe_render_many(opts.activities, StatusView, "listen.json", opts)
399 end
400
401 def render("context.json", %{activity: activity, activities: activities, user: user}) do
402 %{ancestors: ancestors, descendants: descendants} =
403 activities
404 |> Enum.reverse()
405 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
406 |> Map.put_new(:ancestors, [])
407 |> Map.put_new(:descendants, [])
408
409 %{
410 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
411 descendants: render("index.json", for: user, activities: descendants, as: :activity)
412 }
413 end
414
415 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
416 object = Object.normalize(activity)
417
418 with nil <- replied_to_activities[object.data["inReplyTo"]] do
419 # If user didn't participate in the thread
420 Activity.get_in_reply_to_activity(activity)
421 end
422 end
423
424 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
425 object = Object.normalize(activity)
426
427 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
428 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
429 else
430 nil
431 end
432 end
433
434 def render_content(%{data: %{"type" => object_type}} = object)
435 when object_type in ["Video", "Event"] do
436 with name when not is_nil(name) and name != "" <- object.data["name"] do
437 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
438 else
439 _ -> object.data["content"] || ""
440 end
441 end
442
443 def render_content(%{data: %{"type" => object_type}} = object)
444 when object_type in ["Article", "Page"] do
445 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
446 url when is_bitstring(url) <- object.data["url"] do
447 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
448 else
449 _ -> object.data["content"] || ""
450 end
451 end
452
453 def render_content(object), do: object.data["content"] || ""
454
455 @doc """
456 Builds a dictionary tags.
457
458 ## Examples
459
460 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
461 [{"name": "fediverse", "url": "/tag/fediverse"},
462 {"name": "nextcloud", "url": "/tag/nextcloud"}]
463
464 """
465 @spec build_tags(list(any())) :: list(map())
466 def build_tags(object_tags) when is_list(object_tags) do
467 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
468
469 Enum.reduce(object_tags, [], fn tag, tags ->
470 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
471 end)
472 end
473
474 def build_tags(_), do: []
475
476 @doc """
477 Builds list emojis.
478
479 Arguments: `nil` or list tuple of name and url.
480
481 Returns list emojis.
482
483 ## Examples
484
485 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
486 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
487
488 """
489 @spec build_emojis(nil | list(tuple())) :: list(map())
490 def build_emojis(nil), do: []
491
492 def build_emojis(emojis) do
493 emojis
494 |> Enum.map(fn {name, url} ->
495 name = HTML.strip_tags(name)
496
497 url =
498 url
499 |> HTML.strip_tags()
500 |> MediaProxy.url()
501
502 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
503 end)
504 end
505
506 defp present?(nil), do: false
507 defp present?(false), do: false
508 defp present?(_), do: true
509
510 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
511 do: id in pinned_activities
512 end