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