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