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