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