[#2456] Dropped support for embedded `pleroma/account/relationship` in statuses and...
[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]
25
26 # TODO: Add cached version.
27 defp get_replied_to_activities([]), do: %{}
28
29 defp get_replied_to_activities(activities) do
30 activities
31 |> Enum.map(fn
32 %{data: %{"type" => "Create"}} = activity ->
33 object = Object.normalize(activity)
34 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
35
36 _ ->
37 nil
38 end)
39 |> Enum.filter(& &1)
40 |> Activity.create_by_object_ap_id_with_object()
41 |> Repo.all()
42 |> Enum.reduce(%{}, fn activity, acc ->
43 object = Object.normalize(activity)
44 if object, do: Map.put(acc, object.data["id"], activity), else: acc
45 end)
46 end
47
48 def get_user(ap_id, fake_record_fallback \\ true) do
49 cond do
50 user = User.get_cached_by_ap_id(ap_id) ->
51 user
52
53 user = User.get_by_guessed_nickname(ap_id) ->
54 user
55
56 fake_record_fallback ->
57 # TODO: refactor (fake records is never a good idea)
58 User.error_user(ap_id)
59
60 true ->
61 nil
62 end
63 end
64
65 defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
66 do: context_id
67
68 defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
69 do: Utils.context_to_conversation_id(context)
70
71 defp get_context_id(_), do: nil
72
73 defp reblogged?(activity, user) do
74 object = Object.normalize(activity) || %{}
75 present?(user && user.ap_id in (object.data["announcements"] || []))
76 end
77
78 def render("index.json", opts) do
79 reading_user = opts[:for]
80
81 # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
82 activities = Enum.filter(opts.activities, & &1)
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).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(&get_user(&1.data["actor"], false))
108 |> Enum.filter(& &1)
109
110 UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: true)
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 = get_user(activity.data["actor"])
127 created_at = Utils.to_masto_date(activity.data["published"])
128 activity_object = Object.normalize(activity)
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 skip_relationships: true
165 }),
166 in_reply_to_id: nil,
167 in_reply_to_account_id: nil,
168 reblog: reblogged,
169 content: reblogged[:content] || "",
170 created_at: created_at,
171 reblogs_count: 0,
172 replies_count: 0,
173 favourites_count: 0,
174 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
175 favourited: present?(favorited),
176 bookmarked: present?(bookmarked),
177 muted: false,
178 pinned: pinned?(activity, user),
179 sensitive: false,
180 spoiler_text: "",
181 visibility: get_visibility(activity),
182 media_attachments: reblogged[:media_attachments] || [],
183 mentions: mentions,
184 tags: reblogged[:tags] || [],
185 application: %{
186 name: "Web",
187 website: nil
188 },
189 language: nil,
190 emojis: [],
191 pleroma: %{
192 local: activity.local
193 }
194 }
195 end
196
197 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
198 object = Object.normalize(activity)
199
200 user = get_user(activity.data["actor"])
201 user_follower_address = user.follower_address
202
203 like_count = object.data["like_count"] || 0
204 announcement_count = object.data["announcement_count"] || 0
205
206 tags = object.data["tag"] || []
207 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
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 %ActivityExpiration{scheduled_at: scheduled_at} <-
234 ActivityExpiration.get_by_activity_id(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 && 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 with %{data: %{"reactions" => emoji_reactions}} <- object do
301 Enum.map(emoji_reactions, fn [emoji, users] ->
302 %{
303 name: emoji,
304 count: length(users),
305 me: !!(opts[:for] && opts[:for].ap_id in users)
306 }
307 end)
308 else
309 _ -> []
310 end
311
312 # Status muted state (would do 1 request per status unless user mutes are preloaded)
313 muted =
314 thread_muted? ||
315 UserRelationship.exists?(
316 get_in(opts, [:relationships, :user_relationships]),
317 :mute,
318 opts[:for],
319 user,
320 fn for_user, user -> User.mutes?(for_user, user) end
321 )
322
323 %{
324 id: to_string(activity.id),
325 uri: object.data["id"],
326 url: url,
327 account:
328 AccountView.render("show.json", %{
329 user: user,
330 for: opts[:for],
331 skip_relationships: true
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 created_at: created_at,
339 reblogs_count: announcement_count,
340 replies_count: object.data["repliesCount"] || 0,
341 favourites_count: like_count,
342 reblogged: reblogged?(activity, opts[:for]),
343 favourited: present?(favorited),
344 bookmarked: present?(bookmarked),
345 muted: muted,
346 pinned: pinned?(activity, user),
347 sensitive: sensitive,
348 spoiler_text: summary,
349 visibility: get_visibility(object),
350 media_attachments: attachments,
351 poll: render(PollView, "show.json", object: object, for: opts[:for]),
352 mentions: mentions,
353 tags: build_tags(tags),
354 application: %{
355 name: "Web",
356 website: nil
357 },
358 language: nil,
359 emojis: build_emojis(object.data["emoji"]),
360 pleroma: %{
361 local: activity.local,
362 conversation_id: get_context_id(activity),
363 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
364 content: %{"text/plain" => content_plaintext},
365 spoiler_text: %{"text/plain" => summary},
366 expires_at: expires_at,
367 direct_conversation_id: direct_conversation_id,
368 thread_muted: thread_muted?,
369 emoji_reactions: emoji_reactions
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 rich_media[:url] != nil 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 rich_media[:image] != nil do
392 URI.merge(page_url_data, URI.parse(rich_media[:image]))
393 |> to_string
394 else
395 nil
396 end
397
398 %{
399 type: "link",
400 provider_name: page_url_data.host,
401 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
402 url: page_url,
403 image: image_url |> MediaProxy.url(),
404 title: rich_media[:title] || "",
405 description: rich_media[:description] || "",
406 pleroma: %{
407 opengraph: rich_media
408 }
409 }
410 end
411
412 def render("card.json", _), do: nil
413
414 def render("attachment.json", %{attachment: attachment}) do
415 [attachment_url | _] = attachment["url"]
416 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
417 href = attachment_url["href"] |> MediaProxy.url()
418
419 type =
420 cond do
421 String.contains?(media_type, "image") -> "image"
422 String.contains?(media_type, "video") -> "video"
423 String.contains?(media_type, "audio") -> "audio"
424 true -> "unknown"
425 end
426
427 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
428
429 %{
430 id: to_string(attachment["id"] || hash_id),
431 url: href,
432 remote_url: href,
433 preview_url: href,
434 text_url: href,
435 type: type,
436 description: attachment["name"],
437 pleroma: %{mime_type: media_type}
438 }
439 end
440
441 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
442 object = Object.normalize(activity)
443
444 user = get_user(activity.data["actor"])
445 created_at = Utils.to_masto_date(activity.data["published"])
446
447 %{
448 id: activity.id,
449 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
450 created_at: created_at,
451 title: object.data["title"] |> HTML.strip_tags(),
452 artist: object.data["artist"] |> HTML.strip_tags(),
453 album: object.data["album"] |> HTML.strip_tags(),
454 length: object.data["length"]
455 }
456 end
457
458 def render("listens.json", opts) do
459 safe_render_many(opts.activities, StatusView, "listen.json", opts)
460 end
461
462 def render("context.json", %{activity: activity, activities: activities, user: user}) do
463 %{ancestors: ancestors, descendants: descendants} =
464 activities
465 |> Enum.reverse()
466 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
467 |> Map.put_new(:ancestors, [])
468 |> Map.put_new(:descendants, [])
469
470 %{
471 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
472 descendants: render("index.json", for: user, activities: descendants, as: :activity)
473 }
474 end
475
476 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
477 object = Object.normalize(activity)
478
479 with nil <- replied_to_activities[object.data["inReplyTo"]] do
480 # If user didn't participate in the thread
481 Activity.get_in_reply_to_activity(activity)
482 end
483 end
484
485 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
486 object = Object.normalize(activity)
487
488 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
489 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
490 else
491 nil
492 end
493 end
494
495 def render_content(%{data: %{"type" => object_type}} = object)
496 when object_type in ["Video", "Event", "Audio"] do
497 with name when not is_nil(name) and name != "" <- object.data["name"] do
498 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
499 else
500 _ -> object.data["content"] || ""
501 end
502 end
503
504 def render_content(%{data: %{"type" => object_type}} = object)
505 when object_type in ["Article", "Page"] do
506 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
507 url when is_bitstring(url) <- object.data["url"] do
508 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
509 else
510 _ -> object.data["content"] || ""
511 end
512 end
513
514 def render_content(object), do: object.data["content"] || ""
515
516 @doc """
517 Builds a dictionary tags.
518
519 ## Examples
520
521 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
522 [{"name": "fediverse", "url": "/tag/fediverse"},
523 {"name": "nextcloud", "url": "/tag/nextcloud"}]
524
525 """
526 @spec build_tags(list(any())) :: list(map())
527 def build_tags(object_tags) when is_list(object_tags) do
528 object_tags
529 |> Enum.filter(&is_binary/1)
530 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
531 end
532
533 def build_tags(_), do: []
534
535 @doc """
536 Builds list emojis.
537
538 Arguments: `nil` or list tuple of name and url.
539
540 Returns list emojis.
541
542 ## Examples
543
544 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
545 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
546
547 """
548 @spec build_emojis(nil | list(tuple())) :: list(map())
549 def build_emojis(nil), do: []
550
551 def build_emojis(emojis) do
552 emojis
553 |> Enum.map(fn {name, url} ->
554 name = HTML.strip_tags(name)
555
556 url =
557 url
558 |> HTML.strip_tags()
559 |> MediaProxy.url()
560
561 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
562 end)
563 end
564
565 defp present?(nil), do: false
566 defp present?(false), do: false
567 defp present?(_), do: true
568
569 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
570 do: id in pinned_activities
571 end