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