Merge branch 'feat/rich-media-improvements' into 'develop'
[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.ActivityExpiration
12 alias Pleroma.HTML
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
24 import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
25
26 # This is a naive way to do this, just spawning a process per activity
27 # to fetch the preview. However it should be fine considering
28 # pagination is restricted to 40 activities at a time
29 defp fetch_rich_media_for_activities(activities) do
30 Enum.each(activities, fn activity ->
31 spawn(fn ->
32 Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
33 end)
34 end)
35 end
36
37 # TODO: Add cached version.
38 defp get_replied_to_activities([]), do: %{}
39
40 defp get_replied_to_activities(activities) do
41 activities
42 |> Enum.map(fn
43 %{data: %{"type" => "Create"}} = activity ->
44 object = Object.normalize(activity)
45 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
46
47 _ ->
48 nil
49 end)
50 |> Enum.filter(& &1)
51 |> Activity.create_by_object_ap_id_with_object()
52 |> Repo.all()
53 |> Enum.reduce(%{}, fn activity, acc ->
54 object = Object.normalize(activity)
55 if object, do: Map.put(acc, object.data["id"], activity), else: acc
56 end)
57 end
58
59 def get_user(ap_id, fake_record_fallback \\ true) do
60 cond do
61 user = User.get_cached_by_ap_id(ap_id) ->
62 user
63
64 user = User.get_by_guessed_nickname(ap_id) ->
65 user
66
67 fake_record_fallback ->
68 # TODO: refactor (fake records is never a good idea)
69 User.error_user(ap_id)
70
71 true ->
72 nil
73 end
74 end
75
76 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
77 do: context_id
78
79 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
80 do: Utils.context_to_conversation_id(context)
81
82 defp get_context_id(_), do: nil
83
84 defp reblogged?(activity, user) do
85 object = Object.normalize(activity) || %{}
86 present?(user && user.ap_id in (object.data["announcements"] || []))
87 end
88
89 def render("index.json", opts) do
90 reading_user = opts[:for]
91
92 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
93 activities = Enum.filter(opts.activities, & &1)
94
95 # Start fetching rich media before doing anything else, so that later calls to get the cards
96 # only block for timeout in the worst case, as opposed to
97 # length(activities_with_links) * timeout
98 fetch_rich_media_for_activities(activities)
99 replied_to_activities = get_replied_to_activities(activities)
100
101 parent_activities =
102 activities
103 |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
104 |> Enum.map(&Object.normalize(&1).data["id"])
105 |> Activity.create_by_object_ap_id()
106 |> Activity.with_preloaded_object(:left)
107 |> Activity.with_preloaded_bookmark(reading_user)
108 |> Activity.with_set_thread_muted_field(reading_user)
109 |> Repo.all()
110
111 relationships_opt =
112 cond do
113 Map.has_key?(opts, :relationships) ->
114 opts[:relationships]
115
116 is_nil(reading_user) ->
117 UserRelationship.view_relationships_option(nil, [])
118
119 true ->
120 # Note: unresolved users are filtered out
121 actors =
122 (activities ++ parent_activities)
123 |> Enum.map(&get_user(&1.data["actor"], false))
124 |> Enum.filter(& &1)
125
126 UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
127 end
128
129 opts =
130 opts
131 |> Map.put(:replied_to_activities, replied_to_activities)
132 |> Map.put(:parent_activities, parent_activities)
133 |> Map.put(:relationships, relationships_opt)
134
135 safe_render_many(activities, StatusView, "show.json", opts)
136 end
137
138 def render(
139 "show.json",
140 %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
141 ) do
142 user = get_user(activity.data["actor"])
143 created_at = Utils.to_masto_date(activity.data["published"])
144 activity_object = Object.normalize(activity)
145
146 reblogged_parent_activity =
147 if opts[:parent_activities] do
148 Activity.Queries.find_by_object_ap_id(
149 opts[:parent_activities],
150 activity_object.data["id"]
151 )
152 else
153 Activity.create_by_object_ap_id(activity_object.data["id"])
154 |> Activity.with_preloaded_bookmark(opts[:for])
155 |> Activity.with_set_thread_muted_field(opts[:for])
156 |> Repo.one()
157 end
158
159 reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
160 reblogged = render("show.json", reblog_rendering_opts)
161
162 favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
163
164 bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
165
166 mentions =
167 activity.recipients
168 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
169 |> Enum.filter(& &1)
170 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
171
172 %{
173 id: to_string(activity.id),
174 uri: activity_object.data["id"],
175 url: activity_object.data["id"],
176 account:
177 AccountView.render("show.json", %{
178 user: user,
179 for: opts[:for]
180 }),
181 in_reply_to_id: nil,
182 in_reply_to_account_id: nil,
183 reblog: reblogged,
184 content: reblogged[:content] || "",
185 created_at: created_at,
186 reblogs_count: 0,
187 replies_count: 0,
188 favourites_count: 0,
189 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
190 favourited: present?(favorited),
191 bookmarked: present?(bookmarked),
192 muted: false,
193 pinned: pinned?(activity, user),
194 sensitive: false,
195 spoiler_text: "",
196 visibility: get_visibility(activity),
197 media_attachments: reblogged[:media_attachments] || [],
198 mentions: mentions,
199 tags: reblogged[:tags] || [],
200 application: %{
201 name: "Web",
202 website: nil
203 },
204 language: nil,
205 emojis: [],
206 pleroma: %{
207 local: activity.local
208 }
209 }
210 end
211
212 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
213 object = Object.normalize(activity)
214
215 user = get_user(activity.data["actor"])
216 user_follower_address = user.follower_address
217
218 like_count = object.data["like_count"] || 0
219 announcement_count = object.data["announcement_count"] || 0
220
221 tags = object.data["tag"] || []
222 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
223
224 tag_mentions =
225 tags
226 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
227 |> Enum.map(fn tag -> tag["href"] end)
228
229 mentions =
230 (object.data["to"] ++ tag_mentions)
231 |> Enum.uniq()
232 |> Enum.map(fn
233 Pleroma.Constants.as_public() -> nil
234 ^user_follower_address -> nil
235 ap_id -> User.get_cached_by_ap_id(ap_id)
236 end)
237 |> Enum.filter(& &1)
238 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
239
240 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
241
242 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
243
244 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
245
246 expires_at =
247 with true <- client_posted_this_activity,
248 %ActivityExpiration{scheduled_at: scheduled_at} <-
249 ActivityExpiration.get_by_activity_id(activity.id) do
250 scheduled_at
251 else
252 _ -> nil
253 end
254
255 thread_muted? =
256 cond do
257 is_nil(opts[:for]) -> false
258 is_boolean(activity.thread_muted?) -> activity.thread_muted?
259 true -> CommonAPI.thread_muted?(opts[:for], activity)
260 end
261
262 attachment_data = object.data["attachment"] || []
263 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
264
265 created_at = Utils.to_masto_date(object.data["published"])
266
267 reply_to = get_reply_to(activity, opts)
268
269 reply_to_user = reply_to && get_user(reply_to.data["actor"])
270
271 content =
272 object
273 |> render_content()
274
275 content_html =
276 content
277 |> HTML.get_cached_scrubbed_html_for_activity(
278 User.html_filter_policy(opts[:for]),
279 activity,
280 "mastoapi:content"
281 )
282
283 content_plaintext =
284 content
285 |> HTML.get_cached_stripped_html_for_activity(
286 activity,
287 "mastoapi:content"
288 )
289
290 summary = object.data["summary"] || ""
291
292 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
293
294 url =
295 if user.local do
296 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
297 else
298 object.data["url"] || object.data["external_url"] || object.data["id"]
299 end
300
301 direct_conversation_id =
302 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
303 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
304 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
305 Activity.direct_conversation_id(activity, for_user)
306 else
307 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
308 participation_id
309
310 _e ->
311 nil
312 end
313
314 emoji_reactions =
315 with %{data: %{"reactions" => emoji_reactions}} <- object do
316 Enum.map(emoji_reactions, fn
317 [emoji, users] when is_list(users) ->
318 build_emoji_map(emoji, users, opts[:for])
319
320 {emoji, users} when is_list(users) ->
321 build_emoji_map(emoji, users, opts[:for])
322
323 _ ->
324 nil
325 end)
326 |> Enum.reject(&is_nil/1)
327 else
328 _ -> []
329 end
330
331 # Status muted state (would do 1 request per status unless user mutes are preloaded)
332 muted =
333 thread_muted? ||
334 UserRelationship.exists?(
335 get_in(opts, [:relationships, :user_relationships]),
336 :mute,
337 opts[:for],
338 user,
339 fn for_user, user -> User.mutes?(for_user, user) end
340 )
341
342 %{
343 id: to_string(activity.id),
344 uri: object.data["id"],
345 url: url,
346 account:
347 AccountView.render("show.json", %{
348 user: user,
349 for: opts[:for]
350 }),
351 in_reply_to_id: reply_to && to_string(reply_to.id),
352 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
353 reblog: nil,
354 card: card,
355 content: content_html,
356 text: opts[:with_source] && object.data["source"],
357 created_at: created_at,
358 reblogs_count: announcement_count,
359 replies_count: object.data["repliesCount"] || 0,
360 favourites_count: like_count,
361 reblogged: reblogged?(activity, opts[:for]),
362 favourited: present?(favorited),
363 bookmarked: present?(bookmarked),
364 muted: muted,
365 pinned: pinned?(activity, user),
366 sensitive: sensitive,
367 spoiler_text: summary,
368 visibility: get_visibility(object),
369 media_attachments: attachments,
370 poll: render(PollView, "show.json", object: object, for: opts[:for]),
371 mentions: mentions,
372 tags: build_tags(tags),
373 application: %{
374 name: "Web",
375 website: nil
376 },
377 language: nil,
378 emojis: build_emojis(object.data["emoji"]),
379 pleroma: %{
380 local: activity.local,
381 conversation_id: get_context_id(activity),
382 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
383 content: %{"text/plain" => content_plaintext},
384 spoiler_text: %{"text/plain" => summary},
385 expires_at: expires_at,
386 direct_conversation_id: direct_conversation_id,
387 thread_muted: thread_muted?,
388 emoji_reactions: emoji_reactions,
389 parent_visible: visible_for_user?(reply_to, opts[:for])
390 }
391 }
392 end
393
394 def render("show.json", _) do
395 nil
396 end
397
398 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
399 page_url_data = URI.parse(page_url)
400
401 page_url_data =
402 if is_binary(rich_media["url"]) do
403 URI.merge(page_url_data, URI.parse(rich_media["url"]))
404 else
405 page_url_data
406 end
407
408 page_url = page_url_data |> to_string
409
410 image_url =
411 if is_binary(rich_media["image"]) do
412 URI.merge(page_url_data, URI.parse(rich_media["image"]))
413 |> to_string
414 end
415
416 %{
417 type: "link",
418 provider_name: page_url_data.host,
419 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
420 url: page_url,
421 image: image_url |> MediaProxy.url(),
422 title: rich_media["title"] || "",
423 description: rich_media["description"] || "",
424 pleroma: %{
425 opengraph: rich_media
426 }
427 }
428 end
429
430 def render("card.json", _), do: nil
431
432 def render("attachment.json", %{attachment: attachment}) do
433 [attachment_url | _] = attachment["url"]
434 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
435 href = attachment_url["href"] |> MediaProxy.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,
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