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