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