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