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