[#2456] Removed support for embedded relationships in account view.
[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 }),
165 in_reply_to_id: nil,
166 in_reply_to_account_id: nil,
167 reblog: reblogged,
168 content: reblogged[:content] || "",
169 created_at: created_at,
170 reblogs_count: 0,
171 replies_count: 0,
172 favourites_count: 0,
173 reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
174 favourited: present?(favorited),
175 bookmarked: present?(bookmarked),
176 muted: false,
177 pinned: pinned?(activity, user),
178 sensitive: false,
179 spoiler_text: "",
180 visibility: get_visibility(activity),
181 media_attachments: reblogged[:media_attachments] || [],
182 mentions: mentions,
183 tags: reblogged[:tags] || [],
184 application: %{
185 name: "Web",
186 website: nil
187 },
188 language: nil,
189 emojis: [],
190 pleroma: %{
191 local: activity.local
192 }
193 }
194 end
195
196 def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
197 object = Object.normalize(activity)
198
199 user = get_user(activity.data["actor"])
200 user_follower_address = user.follower_address
201
202 like_count = object.data["like_count"] || 0
203 announcement_count = object.data["announcement_count"] || 0
204
205 tags = object.data["tag"] || []
206 sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
207
208 tag_mentions =
209 tags
210 |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
211 |> Enum.map(fn tag -> tag["href"] end)
212
213 mentions =
214 (object.data["to"] ++ tag_mentions)
215 |> Enum.uniq()
216 |> Enum.map(fn
217 Pleroma.Constants.as_public() -> nil
218 ^user_follower_address -> nil
219 ap_id -> User.get_cached_by_ap_id(ap_id)
220 end)
221 |> Enum.filter(& &1)
222 |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
223
224 favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
225
226 bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
227
228 client_posted_this_activity = opts[:for] && user.id == opts[:for].id
229
230 expires_at =
231 with true <- client_posted_this_activity,
232 %ActivityExpiration{scheduled_at: scheduled_at} <-
233 ActivityExpiration.get_by_activity_id(activity.id) do
234 scheduled_at
235 else
236 _ -> nil
237 end
238
239 thread_muted? =
240 cond do
241 is_nil(opts[:for]) -> false
242 is_boolean(activity.thread_muted?) -> activity.thread_muted?
243 true -> CommonAPI.thread_muted?(opts[:for], activity)
244 end
245
246 attachment_data = object.data["attachment"] || []
247 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
248
249 created_at = Utils.to_masto_date(object.data["published"])
250
251 reply_to = get_reply_to(activity, opts)
252
253 reply_to_user = reply_to && get_user(reply_to.data["actor"])
254
255 content =
256 object
257 |> render_content()
258
259 content_html =
260 content
261 |> HTML.get_cached_scrubbed_html_for_activity(
262 User.html_filter_policy(opts[:for]),
263 activity,
264 "mastoapi:content"
265 )
266
267 content_plaintext =
268 content
269 |> HTML.get_cached_stripped_html_for_activity(
270 activity,
271 "mastoapi:content"
272 )
273
274 summary = object.data["summary"] || ""
275
276 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
277
278 url =
279 if user.local do
280 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
281 else
282 object.data["url"] || object.data["external_url"] || object.data["id"]
283 end
284
285 direct_conversation_id =
286 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
287 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
288 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
289 Activity.direct_conversation_id(activity, for_user)
290 else
291 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
292 participation_id
293
294 _e ->
295 nil
296 end
297
298 emoji_reactions =
299 with %{data: %{"reactions" => emoji_reactions}} <- object do
300 Enum.map(emoji_reactions, fn [emoji, users] ->
301 %{
302 name: emoji,
303 count: length(users),
304 me: !!(opts[:for] && opts[:for].ap_id in users)
305 }
306 end)
307 else
308 _ -> []
309 end
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 created_at: created_at,
337 reblogs_count: announcement_count,
338 replies_count: object.data["repliesCount"] || 0,
339 favourites_count: like_count,
340 reblogged: reblogged?(activity, opts[:for]),
341 favourited: present?(favorited),
342 bookmarked: present?(bookmarked),
343 muted: muted,
344 pinned: pinned?(activity, user),
345 sensitive: sensitive,
346 spoiler_text: summary,
347 visibility: get_visibility(object),
348 media_attachments: attachments,
349 poll: render(PollView, "show.json", object: object, for: opts[:for]),
350 mentions: mentions,
351 tags: build_tags(tags),
352 application: %{
353 name: "Web",
354 website: nil
355 },
356 language: nil,
357 emojis: build_emojis(object.data["emoji"]),
358 pleroma: %{
359 local: activity.local,
360 conversation_id: get_context_id(activity),
361 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
362 content: %{"text/plain" => content_plaintext},
363 spoiler_text: %{"text/plain" => summary},
364 expires_at: expires_at,
365 direct_conversation_id: direct_conversation_id,
366 thread_muted: thread_muted?,
367 emoji_reactions: emoji_reactions
368 }
369 }
370 end
371
372 def render("show.json", _) do
373 nil
374 end
375
376 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
377 page_url_data = URI.parse(page_url)
378
379 page_url_data =
380 if rich_media[:url] != nil do
381 URI.merge(page_url_data, URI.parse(rich_media[:url]))
382 else
383 page_url_data
384 end
385
386 page_url = page_url_data |> to_string
387
388 image_url =
389 if rich_media[:image] != nil do
390 URI.merge(page_url_data, URI.parse(rich_media[:image]))
391 |> to_string
392 else
393 nil
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
417 type =
418 cond do
419 String.contains?(media_type, "image") -> "image"
420 String.contains?(media_type, "video") -> "video"
421 String.contains?(media_type, "audio") -> "audio"
422 true -> "unknown"
423 end
424
425 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
426
427 %{
428 id: to_string(attachment["id"] || hash_id),
429 url: href,
430 remote_url: href,
431 preview_url: href,
432 text_url: href,
433 type: type,
434 description: attachment["name"],
435 pleroma: %{mime_type: media_type}
436 }
437 end
438
439 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
440 object = Object.normalize(activity)
441
442 user = get_user(activity.data["actor"])
443 created_at = Utils.to_masto_date(activity.data["published"])
444
445 %{
446 id: activity.id,
447 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
448 created_at: created_at,
449 title: object.data["title"] |> HTML.strip_tags(),
450 artist: object.data["artist"] |> HTML.strip_tags(),
451 album: object.data["album"] |> HTML.strip_tags(),
452 length: object.data["length"]
453 }
454 end
455
456 def render("listens.json", opts) do
457 safe_render_many(opts.activities, StatusView, "listen.json", opts)
458 end
459
460 def render("context.json", %{activity: activity, activities: activities, user: user}) do
461 %{ancestors: ancestors, descendants: descendants} =
462 activities
463 |> Enum.reverse()
464 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
465 |> Map.put_new(:ancestors, [])
466 |> Map.put_new(:descendants, [])
467
468 %{
469 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
470 descendants: render("index.json", for: user, activities: descendants, as: :activity)
471 }
472 end
473
474 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
475 object = Object.normalize(activity)
476
477 with nil <- replied_to_activities[object.data["inReplyTo"]] do
478 # If user didn't participate in the thread
479 Activity.get_in_reply_to_activity(activity)
480 end
481 end
482
483 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
484 object = Object.normalize(activity)
485
486 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
487 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
488 else
489 nil
490 end
491 end
492
493 def render_content(%{data: %{"type" => object_type}} = object)
494 when object_type in ["Video", "Event", "Audio"] do
495 with name when not is_nil(name) and name != "" <- object.data["name"] do
496 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
497 else
498 _ -> object.data["content"] || ""
499 end
500 end
501
502 def render_content(%{data: %{"type" => object_type}} = object)
503 when object_type in ["Article", "Page"] do
504 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
505 url when is_bitstring(url) <- object.data["url"] do
506 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
507 else
508 _ -> object.data["content"] || ""
509 end
510 end
511
512 def render_content(object), do: object.data["content"] || ""
513
514 @doc """
515 Builds a dictionary tags.
516
517 ## Examples
518
519 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
520 [{"name": "fediverse", "url": "/tag/fediverse"},
521 {"name": "nextcloud", "url": "/tag/nextcloud"}]
522
523 """
524 @spec build_tags(list(any())) :: list(map())
525 def build_tags(object_tags) when is_list(object_tags) do
526 object_tags
527 |> Enum.filter(&is_binary/1)
528 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
529 end
530
531 def build_tags(_), do: []
532
533 @doc """
534 Builds list emojis.
535
536 Arguments: `nil` or list tuple of name and url.
537
538 Returns list emojis.
539
540 ## Examples
541
542 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
543 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
544
545 """
546 @spec build_emojis(nil | list(tuple())) :: list(map())
547 def build_emojis(nil), do: []
548
549 def build_emojis(emojis) do
550 emojis
551 |> Enum.map(fn {name, url} ->
552 name = HTML.strip_tags(name)
553
554 url =
555 url
556 |> HTML.strip_tags()
557 |> MediaProxy.url()
558
559 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
560 end)
561 end
562
563 defp present?(nil), do: false
564 defp present?(false), do: false
565 defp present?(_), do: true
566
567 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
568 do: id in pinned_activities
569 end