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