Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma 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
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
347 nil
348 end
349
350 def render("attachment.json", %{attachment: attachment}) do
351 [attachment_url | _] = attachment["url"]
352 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
353 href = attachment_url["href"] |> MediaProxy.url()
354
355 type =
356 cond do
357 String.contains?(media_type, "image") -> "image"
358 String.contains?(media_type, "video") -> "video"
359 String.contains?(media_type, "audio") -> "audio"
360 true -> "unknown"
361 end
362
363 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
364
365 %{
366 id: to_string(attachment["id"] || hash_id),
367 url: href,
368 remote_url: href,
369 preview_url: href,
370 text_url: href,
371 type: type,
372 description: attachment["name"],
373 pleroma: %{mime_type: media_type}
374 }
375 end
376
377 def render("poll.json", %{object: object} = opts) do
378 {multiple, options} =
379 case object.data do
380 %{"anyOf" => options} when is_list(options) -> {true, options}
381 %{"oneOf" => options} when is_list(options) -> {false, options}
382 _ -> {nil, nil}
383 end
384
385 if options do
386 {end_time, expired} =
387 case object.data["closed"] || object.data["endTime"] do
388 end_time when is_binary(end_time) ->
389 end_time =
390 (object.data["closed"] || object.data["endTime"])
391 |> NaiveDateTime.from_iso8601!()
392
393 expired =
394 end_time
395 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
396 |> case do
397 :lt -> true
398 _ -> false
399 end
400
401 end_time = Utils.to_masto_date(end_time)
402
403 {end_time, expired}
404
405 _ ->
406 {nil, false}
407 end
408
409 voted =
410 if opts[:for] do
411 existing_votes =
412 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
413
414 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
415 else
416 false
417 end
418
419 {options, votes_count} =
420 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
421 current_count = option["replies"]["totalItems"] || 0
422
423 {%{
424 title: HTML.strip_tags(name),
425 votes_count: current_count
426 }, current_count + count}
427 end)
428
429 %{
430 # Mastodon uses separate ids for polls, but an object can't have
431 # more than one poll embedded so object id is fine
432 id: to_string(object.id),
433 expires_at: end_time,
434 expired: expired,
435 multiple: multiple,
436 votes_count: votes_count,
437 options: options,
438 voted: voted,
439 emojis: build_emojis(object.data["emoji"])
440 }
441 else
442 nil
443 end
444 end
445
446 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
447 object = Object.normalize(activity)
448
449 with nil <- replied_to_activities[object.data["inReplyTo"]] do
450 # If user didn't participate in the thread
451 Activity.get_in_reply_to_activity(activity)
452 end
453 end
454
455 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
456 object = Object.normalize(activity)
457
458 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
459 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
460 else
461 nil
462 end
463 end
464
465 def render_content(%{data: %{"type" => "Video"}} = object) do
466 with name when not is_nil(name) and name != "" <- object.data["name"] do
467 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
468 else
469 _ -> object.data["content"] || ""
470 end
471 end
472
473 def render_content(%{data: %{"type" => object_type}} = object)
474 when object_type in ["Article", "Page"] do
475 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
476 url when is_bitstring(url) <- object.data["url"] do
477 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
478 else
479 _ -> object.data["content"] || ""
480 end
481 end
482
483 def render_content(object), do: object.data["content"] || ""
484
485 @doc """
486 Builds a dictionary tags.
487
488 ## Examples
489
490 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
491 [{"name": "fediverse", "url": "/tag/fediverse"},
492 {"name": "nextcloud", "url": "/tag/nextcloud"}]
493
494 """
495 @spec build_tags(list(any())) :: list(map())
496 def build_tags(object_tags) when is_list(object_tags) do
497 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
498
499 Enum.reduce(object_tags, [], fn tag, tags ->
500 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
501 end)
502 end
503
504 def build_tags(_), do: []
505
506 @doc """
507 Builds list emojis.
508
509 Arguments: `nil` or list tuple of name and url.
510
511 Returns list emojis.
512
513 ## Examples
514
515 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
516 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
517
518 """
519 @spec build_emojis(nil | list(tuple())) :: list(map())
520 def build_emojis(nil), do: []
521
522 def build_emojis(emojis) do
523 emojis
524 |> Enum.map(fn {name, url} ->
525 name = HTML.strip_tags(name)
526
527 url =
528 url
529 |> HTML.strip_tags()
530 |> MediaProxy.url()
531
532 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
533 end)
534 end
535
536 defp present?(nil), do: false
537 defp present?(false), do: false
538 defp present?(_), do: true
539
540 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
541 do: id in pinned_activities
542 end