add custom emoji reaction support
[akkoma] / lib / pleroma / web / mastodon_api / views / status_view.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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.HTML
12 alias Pleroma.Maps
13 alias Pleroma.Object
14 alias Pleroma.Repo
15 alias Pleroma.User
16 alias Pleroma.UserRelationship
17 alias Pleroma.Web.CommonAPI
18 alias Pleroma.Web.CommonAPI.Utils
19 alias Pleroma.Web.MastodonAPI.AccountView
20 alias Pleroma.Web.MastodonAPI.PollView
21 alias Pleroma.Web.MastodonAPI.StatusView
22 alias Pleroma.Web.MediaProxy
23 alias Pleroma.Web.PleromaAPI.EmojiReactionController
24
25 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
26
27 # This is a naive way to do this, just spawning a process per activity
28 # to fetch the preview. However it should be fine considering
29 # pagination is restricted to 40 activities at a time
30 defp fetch_rich_media_for_activities(activities) do
31 Enum.each(activities, fn activity ->
32 spawn(fn ->
33 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
34 end)
35 end)
36 end
37
38 # TODO: Add cached version.
39 defp get_replied_to_activities([]), do: %{}
40
41 defp get_replied_to_activities(activities) do
42 activities
43 |> Enum.map(fn
44 %{data: %{"type" => "Create"}} = activity ->
45 object = Object.normalize(activity, fetch: false)
46 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
47
48 _ ->
49 nil
50 end)
51 |> Enum.filter(& &1)
52 |> Activity.create_by_object_ap_id_with_object()
53 |> Repo.all()
54 |> Enum.reduce(%{}, fn activity, acc ->
55 object = Object.normalize(activity, fetch: false)
56 if object, do: Map.put(acc, object.data["id"], activity), else: acc
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 # Check if the user reblogged this status
69 defp reblogged?(activity, %User{ap_id: ap_id}) do
70 with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
71 Object.normalize(activity, fetch: false) do
72 ap_id in announcements
73 else
74 _ -> false
75 end
76 end
77
78 # False if the user is logged out
79 defp reblogged?(_activity, _user), do: false
80
81 def render("index.json", opts) do
82 reading_user = opts[:for]
83
84 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
85 activities = Enum.filter(opts.activities, & &1)
86
87 # Start fetching rich media before doing anything else, so that later calls to get the cards
88 # only block for timeout in the worst case, as opposed to
89 # length(activities_with_links) * timeout
90 fetch_rich_media_for_activities(activities)
91 replied_to_activities = get_replied_to_activities(activities)
92
93 parent_activities =
94 activities
95 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
96 |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
97 |> Activity.create_by_object_ap_id()
98 |> Activity.with_preloaded_object(:left)
99 |> Activity.with_preloaded_bookmark(reading_user)
100 |> Activity.with_set_thread_muted_field(reading_user)
101 |> Repo.all()
102
103 relationships_opt =
104 cond do
105 Map.has_key?(opts, :relationships) ->
106 opts[:relationships]
107
108 is_nil(reading_user) ->
109 UserRelationship.view_relationships_option(nil, [])
110
111 true ->
112 # Note: unresolved users are filtered out
113 actors =
114 (activities ++ parent_activities)
115 |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
116 |> Enum.filter(& &1)
117
118 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
119 end
120
121 opts =
122 opts
123 |> Map.put(:replied_to_activities, replied_to_activities)
124 |> Map.put(:parent_activities, parent_activities)
125 |> Map.put(:relationships, relationships_opt)
126
127 safe_render_many(activities, StatusView, "show.json", opts)
128 end
129
130 def render(
131 "show.json",
132 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
133 ) do
134 user = CommonAPI.get_user(activity.data["actor"])
135 created_at = Utils.to_masto_date(activity.data["published"])
136 object = Object.normalize(activity, fetch: false)
137
138 reblogged_parent_activity =
139 if opts[:parent_activities] do
140 Activity.Queries.find_by_object_ap_id(
141 opts[:parent_activities],
142 object.data["id"]
143 )
144 else
145 Activity.create_by_object_ap_id(object.data["id"])
146 |> Activity.with_preloaded_bookmark(opts[:for])
147 |> Activity.with_set_thread_muted_field(opts[:for])
148 |> Repo.one()
149 end
150
151 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
152 reblogged = render("show.json", reblog_rendering_opts)
153
154 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
155
156 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
157
158 mentions =
159 activity.recipients
160 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
161 |> Enum.filter(& &1)
162 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
163
164 {pinned?, pinned_at} = pin_data(object, user)
165
166 %{
167 id: to_string(activity.id),
168 uri: object.data["id"],
169 url: object.data["id"],
170 account:
171 AccountView.render("show.json", %{
172 user: user,
173 for: opts[:for]
174 }),
175 in_reply_to_id: nil,
176 in_reply_to_account_id: nil,
177 reblog: reblogged,
178 content: reblogged[:content] || "",
179 created_at: created_at,
180 reblogs_count: 0,
181 replies_count: 0,
182 favourites_count: 0,
183 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
184 favourited: present?(favorited),
185 bookmarked: present?(bookmarked),
186 muted: false,
187 pinned: pinned?,
188 sensitive: false,
189 spoiler_text: "",
190 visibility: get_visibility(activity),
191 media_attachments: reblogged[:media_attachments] || [],
192 mentions: mentions,
193 tags: reblogged[:tags] || [],
194 application: build_application(object.data["generator"]),
195 language: nil,
196 emojis: [],
197 pleroma: %{
198 local: activity.local,
199 pinned_at: pinned_at
200 }
201 }
202 end
203
204 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
205 object = Object.normalize(activity, fetch: false)
206
207 user = CommonAPI.get_user(activity.data["actor"])
208 user_follower_address = user.follower_address
209
210 like_count = object.data["like_count"] || 0
211 announcement_count = object.data["announcement_count"] || 0
212
213 hashtags = Object.hashtags(object)
214 sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
215
216 tags = Object.tags(object)
217
218 tag_mentions =
219 tags
220 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
221 |> Enum.map(fn tag -> tag["href"] end)
222
223 mentions =
224 (object.data["to"] ++ tag_mentions)
225 |> Enum.uniq()
226 |> Enum.map(fn
227 Pleroma.Constants.as_public() -> nil
228 ^user_follower_address -> nil
229 ap_id -> User.get_cached_by_ap_id(ap_id)
230 end)
231 |> Enum.filter(& &1)
232 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
233
234 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
235
236 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
237
238 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
239
240 expires_at =
241 with true <- client_posted_this_activity,
242 %Oban.Job{scheduled_at: scheduled_at} <-
243 Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
244 scheduled_at
245 else
246 _ -> nil
247 end
248
249 thread_muted? =
250 cond do
251 is_nil(opts[:for]) -> false
252 is_boolean(activity.thread_muted?) -> activity.thread_muted?
253 true -> CommonAPI.thread_muted?(opts[:for], activity)
254 end
255
256 attachment_data = object.data["attachment"] || []
257 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
258
259 created_at = Utils.to_masto_date(object.data["published"])
260
261 reply_to = get_reply_to(activity, opts)
262
263 reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
264
265 content =
266 object
267 |> render_content()
268
269 content_html =
270 content
271 |> Activity.HTML.get_cached_scrubbed_html_for_activity(
272 User.html_filter_policy(opts[:for]),
273 activity,
274 "mastoapi:content"
275 )
276
277 content_plaintext =
278 content
279 |> Activity.HTML.get_cached_stripped_html_for_activity(
280 activity,
281 "mastoapi:content"
282 )
283
284 summary = object.data["summary"] || ""
285
286 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
287
288 url =
289 if user.local do
290 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
291 else
292 object.data["url"] || object.data["external_url"] || object.data["id"]
293 end
294
295 direct_conversation_id =
296 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
297 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
298 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
299 Activity.direct_conversation_id(activity, for_user)
300 else
301 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
302 participation_id
303
304 _e ->
305 nil
306 end
307 emoji_reactions =
308 object.data
309 |> Map.get("reactions", [])
310 |> EmojiReactionController.filter_allowed_users(
311 opts[:for],
312 Map.get(opts, :with_muted, false)
313 )
314 |> Stream.map(fn {emoji, users, url} ->
315 build_emoji_map(emoji, users, url, opts[:for])
316 end)
317 |> Enum.to_list()
318
319 # Status muted state (would do 1 request per status unless user mutes are preloaded)
320 muted =
321 thread_muted? ||
322 UserRelationship.exists?(
323 get_in(opts, [:relationships, :user_relationships]),
324 :mute,
325 opts[:for],
326 user,
327 fn for_user, user -> User.mutes?(for_user, user) end
328 )
329
330 {pinned?, pinned_at} = pin_data(object, user)
331
332 %{
333 id: to_string(activity.id),
334 uri: object.data["id"],
335 url: url,
336 account:
337 AccountView.render("show.json", %{
338 user: user,
339 for: opts[:for]
340 }),
341 in_reply_to_id: reply_to && to_string(reply_to.id),
342 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
343 reblog: nil,
344 card: card,
345 content: content_html,
346 text: opts[:with_source] && object.data["source"],
347 created_at: created_at,
348 reblogs_count: announcement_count,
349 replies_count: object.data["repliesCount"] || 0,
350 favourites_count: like_count,
351 reblogged: reblogged?(activity, opts[:for]),
352 favourited: present?(favorited),
353 bookmarked: present?(bookmarked),
354 muted: muted,
355 pinned: pinned?,
356 sensitive: sensitive,
357 spoiler_text: summary,
358 visibility: get_visibility(object),
359 media_attachments: attachments,
360 poll: render(PollView, "show.json", object: object, for: opts[:for]),
361 mentions: mentions,
362 tags: build_tags(tags),
363 application: build_application(object.data["generator"]),
364 language: nil,
365 emojis: build_emojis(object.data["emoji"]),
366 pleroma: %{
367 local: activity.local,
368 conversation_id: get_context_id(activity),
369 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
370 content: %{"text/plain" => content_plaintext},
371 spoiler_text: %{"text/plain" => summary},
372 expires_at: expires_at,
373 direct_conversation_id: direct_conversation_id,
374 thread_muted: thread_muted?,
375 emoji_reactions: emoji_reactions,
376 parent_visible: visible_for_user?(reply_to, opts[:for]),
377 pinned_at: pinned_at
378 }
379 }
380 end
381
382 def render("show.json", _) do
383 nil
384 end
385
386 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
387 page_url_data = URI.parse(page_url)
388
389 page_url_data =
390 if is_binary(rich_media["url"]) do
391 URI.merge(page_url_data, URI.parse(rich_media["url"]))
392 else
393 page_url_data
394 end
395
396 page_url = page_url_data |> to_string
397
398 image_url_data =
399 if is_binary(rich_media["image"]) do
400 URI.parse(rich_media["image"])
401 else
402 nil
403 end
404
405 image_url = build_image_url(image_url_data, page_url_data)
406
407 %{
408 type: "link",
409 provider_name: page_url_data.host,
410 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
411 url: page_url,
412 image: image_url |> MediaProxy.url(),
413 title: rich_media["title"] || "",
414 description: rich_media["description"] || "",
415 pleroma: %{
416 opengraph: rich_media
417 }
418 }
419 end
420
421 def render("card.json", _), do: nil
422
423 def render("attachment.json", %{attachment: attachment}) do
424 [attachment_url | _] = attachment["url"]
425 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
426 href = attachment_url["href"] |> MediaProxy.url()
427 href_preview = attachment_url["href"] |> MediaProxy.preview_url()
428 meta = render("attachment_meta.json", %{attachment: attachment})
429
430 type =
431 cond do
432 String.contains?(media_type, "image") -> "image"
433 String.contains?(media_type, "video") -> "video"
434 String.contains?(media_type, "audio") -> "audio"
435 true -> "unknown"
436 end
437
438 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
439
440 %{
441 id: to_string(attachment["id"] || hash_id),
442 url: href,
443 remote_url: href,
444 preview_url: href_preview,
445 text_url: href,
446 type: type,
447 description: attachment["name"],
448 pleroma: %{mime_type: media_type},
449 blurhash: attachment["blurhash"]
450 }
451 |> Maps.put_if_present(:meta, meta)
452 end
453
454 def render("attachment_meta.json", %{
455 attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
456 })
457 when is_integer(width) and is_integer(height) do
458 %{
459 original: %{
460 width: width,
461 height: height,
462 aspect: width / height
463 }
464 }
465 end
466
467 def render("attachment_meta.json", _), do: nil
468
469 def render("context.json", %{activity: activity, activities: activities, user: user}) do
470 %{ancestors: ancestors, descendants: descendants} =
471 activities
472 |> Enum.reverse()
473 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
474 |> Map.put_new(:ancestors, [])
475 |> Map.put_new(:descendants, [])
476
477 %{
478 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
479 descendants: render("index.json", for: user, activities: descendants, as: :activity)
480 }
481 end
482
483 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
484 object = Object.normalize(activity, fetch: false)
485
486 with nil <- replied_to_activities[object.data["inReplyTo"]] do
487 # If user didn't participate in the thread
488 Activity.get_in_reply_to_activity(activity)
489 end
490 end
491
492 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
493 object = Object.normalize(activity, fetch: false)
494
495 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
496 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
497 else
498 nil
499 end
500 end
501
502 def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
503 url = object.data["url"] || object.data["id"]
504
505 "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
506 end
507
508 def render_content(object), do: object.data["content"] || ""
509
510 @doc """
511 Builds a dictionary tags.
512
513 ## Examples
514
515 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
516 [{"name": "fediverse", "url": "/tag/fediverse"},
517 {"name": "nextcloud", "url": "/tag/nextcloud"}]
518
519 """
520 @spec build_tags(list(any())) :: list(map())
521 def build_tags(object_tags) when is_list(object_tags) do
522 object_tags
523 |> Enum.filter(&is_binary/1)
524 |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
525 end
526
527 def build_tags(_), do: []
528
529 @doc """
530 Builds list emojis.
531
532 Arguments: `nil` or list tuple of name and url.
533
534 Returns list emojis.
535
536 ## Examples
537
538 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
539 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
540
541 """
542 @spec build_emojis(nil | list(tuple())) :: list(map())
543 def build_emojis(nil), do: []
544
545 def build_emojis(emojis) do
546 emojis
547 |> Enum.map(fn {name, url} ->
548 name = HTML.strip_tags(name)
549
550 url =
551 url
552 |> HTML.strip_tags()
553 |> MediaProxy.url()
554
555 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
556 end)
557 end
558
559 defp present?(nil), do: false
560 defp present?(false), do: false
561 defp present?(_), do: true
562
563 defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
564 if pinned_at = pinned_objects[object_id] do
565 {true, Utils.to_masto_date(pinned_at)}
566 else
567 {false, nil}
568 end
569 end
570
571 defp build_emoji_map(emoji, users, url, current_user) do
572 %{
573 name: emoji,
574 count: length(users),
575 url: url,
576 me: !!(current_user && current_user.ap_id in users)
577 }
578 end
579
580 @spec build_application(map() | nil) :: map() | nil
581 defp build_application(%{"type" => _type, "name" => name, "url" => url}),
582 do: %{name: name, website: url}
583
584 defp build_application(_), do: nil
585
586 # Workaround for Elixir issue #10771
587 # Avoid applying URI.merge unless necessary
588 # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
589 # when Elixir 1.12 is the minimum supported version
590 @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
591 defp build_image_url(
592 %URI{scheme: image_scheme, host: image_host} = image_url_data,
593 %URI{} = _page_url_data
594 )
595 when not is_nil(image_scheme) and not is_nil(image_host) do
596 image_url_data |> to_string
597 end
598
599 defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
600 URI.merge(page_url_data, image_url_data) |> to_string
601 end
602
603 defp build_image_url(_, _), do: nil
604 end