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