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