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