Merge branch 'develop' into 'cleanup/masto_fe-default_settings'
[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, visible_for_user?: 2]
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, subset: :source_mutes)
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
301 [emoji, users] when is_list(users) ->
302 build_emoji_map(emoji, users, opts[:for])
303
304 {emoji, users} when is_list(users) ->
305 build_emoji_map(emoji, users, opts[:for])
306
307 _ ->
308 nil
309 end)
310 |> Enum.reject(&is_nil/1)
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 }),
335 in_reply_to_id: reply_to && to_string(reply_to.id),
336 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
337 reblog: nil,
338 card: card,
339 content: content_html,
340 text: opts[:with_source] && object.data["source"],
341 created_at: created_at,
342 reblogs_count: announcement_count,
343 replies_count: object.data["repliesCount"] || 0,
344 favourites_count: like_count,
345 reblogged: reblogged?(activity, opts[:for]),
346 favourited: present?(favorited),
347 bookmarked: present?(bookmarked),
348 muted: muted,
349 pinned: pinned?(activity, user),
350 sensitive: sensitive,
351 spoiler_text: summary,
352 visibility: get_visibility(object),
353 media_attachments: attachments,
354 poll: render(PollView, "show.json", object: object, for: opts[:for]),
355 mentions: mentions,
356 tags: build_tags(tags),
357 application: %{
358 name: "Web",
359 website: nil
360 },
361 language: nil,
362 emojis: build_emojis(object.data["emoji"]),
363 pleroma: %{
364 local: activity.local,
365 conversation_id: get_context_id(activity),
366 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
367 content: %{"text/plain" => content_plaintext},
368 spoiler_text: %{"text/plain" => summary},
369 expires_at: expires_at,
370 direct_conversation_id: direct_conversation_id,
371 thread_muted: thread_muted?,
372 emoji_reactions: emoji_reactions,
373 parent_visible: visible_for_user?(reply_to, opts[:for])
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 is_binary(rich_media["url"]) 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 is_binary(rich_media["image"]) do
396 URI.merge(page_url_data, URI.parse(rich_media["image"]))
397 |> to_string
398 end
399
400 %{
401 type: "link",
402 provider_name: page_url_data.host,
403 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
404 url: page_url,
405 image: image_url |> MediaProxy.url(),
406 title: rich_media["title"] || "",
407 description: rich_media["description"] || "",
408 pleroma: %{
409 opengraph: rich_media
410 }
411 }
412 end
413
414 def render("card.json", _), do: nil
415
416 def render("attachment.json", %{attachment: attachment}) do
417 [attachment_url | _] = attachment["url"]
418 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
419 href = attachment_url["href"] |> MediaProxy.url()
420
421 type =
422 cond do
423 String.contains?(media_type, "image") -> "image"
424 String.contains?(media_type, "video") -> "video"
425 String.contains?(media_type, "audio") -> "audio"
426 true -> "unknown"
427 end
428
429 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
430
431 %{
432 id: to_string(attachment["id"] || hash_id),
433 url: href,
434 remote_url: href,
435 preview_url: href,
436 text_url: href,
437 type: type,
438 description: attachment["name"],
439 pleroma: %{mime_type: media_type}
440 }
441 end
442
443 def render("context.json", %{activity: activity, activities: activities, user: user}) do
444 %{ancestors: ancestors, descendants: descendants} =
445 activities
446 |> Enum.reverse()
447 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
448 |> Map.put_new(:ancestors, [])
449 |> Map.put_new(:descendants, [])
450
451 %{
452 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
453 descendants: render("index.json", for: user, activities: descendants, as: :activity)
454 }
455 end
456
457 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
458 object = Object.normalize(activity)
459
460 with nil <- replied_to_activities[object.data["inReplyTo"]] do
461 # If user didn't participate in the thread
462 Activity.get_in_reply_to_activity(activity)
463 end
464 end
465
466 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
467 object = Object.normalize(activity)
468
469 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
470 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
471 else
472 nil
473 end
474 end
475
476 def render_content(%{data: %{"type" => object_type}} = object)
477 when object_type in ["Video", "Event", "Audio"] do
478 with name when not is_nil(name) and name != "" <- object.data["name"] do
479 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
480 else
481 _ -> object.data["content"] || ""
482 end
483 end
484
485 def render_content(%{data: %{"type" => object_type}} = object)
486 when object_type in ["Article", "Page"] do
487 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
488 url when is_bitstring(url) <- object.data["url"] do
489 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
490 else
491 _ -> object.data["content"] || ""
492 end
493 end
494
495 def render_content(object), do: object.data["content"] || ""
496
497 @doc """
498 Builds a dictionary tags.
499
500 ## Examples
501
502 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
503 [{"name": "fediverse", "url": "/tag/fediverse"},
504 {"name": "nextcloud", "url": "/tag/nextcloud"}]
505
506 """
507 @spec build_tags(list(any())) :: list(map())
508 def build_tags(object_tags) when is_list(object_tags) do
509 object_tags
510 |> Enum.filter(&is_binary/1)
511 |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"})
512 end
513
514 def build_tags(_), do: []
515
516 @doc """
517 Builds list emojis.
518
519 Arguments: `nil` or list tuple of name and url.
520
521 Returns list emojis.
522
523 ## Examples
524
525 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
526 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
527
528 """
529 @spec build_emojis(nil | list(tuple())) :: list(map())
530 def build_emojis(nil), do: []
531
532 def build_emojis(emojis) do
533 emojis
534 |> Enum.map(fn {name, url} ->
535 name = HTML.strip_tags(name)
536
537 url =
538 url
539 |> HTML.strip_tags()
540 |> MediaProxy.url()
541
542 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
543 end)
544 end
545
546 defp present?(nil), do: false
547 defp present?(false), do: false
548 defp present?(_), do: true
549
550 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
551 do: id in pinned_activities
552
553 defp build_emoji_map(emoji, users, current_user) do
554 %{
555 name: emoji,
556 count: length(users),
557 me: !!(current_user && current_user.ap_id in users)
558 }
559 end
560 end