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