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