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