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