7264dcafb59c3a1f51277021a0e900561cfe33de
[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_at =
172 with true <- client_posted_this_activity,
173 expiration when not is_nil(expiration) <-
174 ActivityExpiration.get_by_activity_id(activity.id) do
175 expiration.scheduled_at
176 end
177
178 thread_muted? =
179 case activity.thread_muted? do
180 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
181 nil -> CommonAPI.thread_muted?(user, activity)
182 end
183
184 attachment_data = object.data["attachment"] || []
185 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
186
187 created_at = Utils.to_masto_date(object.data["published"])
188
189 reply_to = get_reply_to(activity, opts)
190
191 reply_to_user = reply_to && get_user(reply_to.data["actor"])
192
193 content =
194 object
195 |> render_content()
196
197 content_html =
198 content
199 |> HTML.get_cached_scrubbed_html_for_activity(
200 User.html_filter_policy(opts[:for]),
201 activity,
202 "mastoapi:content"
203 )
204
205 content_plaintext =
206 content
207 |> HTML.get_cached_stripped_html_for_activity(
208 activity,
209 "mastoapi:content"
210 )
211
212 summary = object.data["summary"] || ""
213
214 summary_html =
215 summary
216 |> HTML.get_cached_scrubbed_html_for_activity(
217 User.html_filter_policy(opts[:for]),
218 activity,
219 "mastoapi:summary"
220 )
221
222 summary_plaintext =
223 summary
224 |> HTML.get_cached_stripped_html_for_activity(
225 activity,
226 "mastoapi:summary"
227 )
228
229 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
230
231 url =
232 if user.local do
233 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
234 else
235 object.data["external_url"] || object.data["id"]
236 end
237
238 %{
239 id: to_string(activity.id),
240 uri: object.data["id"],
241 url: url,
242 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
243 in_reply_to_id: reply_to && to_string(reply_to.id),
244 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
245 reblog: nil,
246 card: card,
247 content: content_html,
248 created_at: created_at,
249 reblogs_count: announcement_count,
250 replies_count: object.data["repliesCount"] || 0,
251 favourites_count: like_count,
252 reblogged: reblogged?(activity, opts[:for]),
253 favourited: present?(favorited),
254 bookmarked: present?(bookmarked),
255 muted: thread_muted? || User.mutes?(opts[:for], user),
256 pinned: pinned?(activity, user),
257 sensitive: sensitive,
258 spoiler_text: summary_html,
259 visibility: get_visibility(object),
260 media_attachments: attachments,
261 poll: render("poll.json", %{object: object, for: opts[:for]}),
262 mentions: mentions,
263 tags: build_tags(tags),
264 application: %{
265 name: "Web",
266 website: nil
267 },
268 language: nil,
269 emojis: build_emojis(object.data["emoji"]),
270 pleroma: %{
271 local: activity.local,
272 conversation_id: get_context_id(activity),
273 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
274 content: %{"text/plain" => content_plaintext},
275 spoiler_text: %{"text/plain" => summary_plaintext},
276 expires_at: expires_at
277 }
278 }
279 end
280
281 def render("status.json", _) do
282 nil
283 end
284
285 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
286 page_url_data = URI.parse(page_url)
287
288 page_url_data =
289 if rich_media[:url] != nil do
290 URI.merge(page_url_data, URI.parse(rich_media[:url]))
291 else
292 page_url_data
293 end
294
295 page_url = page_url_data |> to_string
296
297 image_url =
298 if rich_media[:image] != nil do
299 URI.merge(page_url_data, URI.parse(rich_media[:image]))
300 |> to_string
301 else
302 nil
303 end
304
305 site_name = rich_media[:site_name] || page_url_data.host
306
307 %{
308 type: "link",
309 provider_name: site_name,
310 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
311 url: page_url,
312 image: image_url |> MediaProxy.url(),
313 title: rich_media[:title] || "",
314 description: rich_media[:description] || "",
315 pleroma: %{
316 opengraph: rich_media
317 }
318 }
319 end
320
321 def render("card.json", _) do
322 nil
323 end
324
325 def render("attachment.json", %{attachment: attachment}) do
326 [attachment_url | _] = attachment["url"]
327 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
328 href = attachment_url["href"] |> MediaProxy.url()
329
330 type =
331 cond do
332 String.contains?(media_type, "image") -> "image"
333 String.contains?(media_type, "video") -> "video"
334 String.contains?(media_type, "audio") -> "audio"
335 true -> "unknown"
336 end
337
338 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
339
340 %{
341 id: to_string(attachment["id"] || hash_id),
342 url: href,
343 remote_url: href,
344 preview_url: href,
345 text_url: href,
346 type: type,
347 description: attachment["name"],
348 pleroma: %{mime_type: media_type}
349 }
350 end
351
352 def render("poll.json", %{object: object} = opts) do
353 {multiple, options} =
354 case object.data do
355 %{"anyOf" => options} when is_list(options) -> {true, options}
356 %{"oneOf" => options} when is_list(options) -> {false, options}
357 _ -> {nil, nil}
358 end
359
360 if options do
361 end_time =
362 (object.data["closed"] || object.data["endTime"])
363 |> NaiveDateTime.from_iso8601!()
364
365 expired =
366 end_time
367 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
368 |> case do
369 :lt -> true
370 _ -> false
371 end
372
373 voted =
374 if opts[:for] do
375 existing_votes =
376 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
377
378 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
379 else
380 false
381 end
382
383 {options, votes_count} =
384 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
385 current_count = option["replies"]["totalItems"] || 0
386
387 {%{
388 title: HTML.strip_tags(name),
389 votes_count: current_count
390 }, current_count + count}
391 end)
392
393 %{
394 # Mastodon uses separate ids for polls, but an object can't have
395 # more than one poll embedded so object id is fine
396 id: to_string(object.id),
397 expires_at: Utils.to_masto_date(end_time),
398 expired: expired,
399 multiple: multiple,
400 votes_count: votes_count,
401 options: options,
402 voted: voted,
403 emojis: build_emojis(object.data["emoji"])
404 }
405 else
406 nil
407 end
408 end
409
410 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
411 object = Object.normalize(activity)
412
413 with nil <- replied_to_activities[object.data["inReplyTo"]] do
414 # If user didn't participate in the thread
415 Activity.get_in_reply_to_activity(activity)
416 end
417 end
418
419 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
420 object = Object.normalize(activity)
421
422 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
423 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
424 else
425 nil
426 end
427 end
428
429 def render_content(%{data: %{"type" => "Video"}} = object) do
430 with name when not is_nil(name) and name != "" <- object.data["name"] do
431 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
432 else
433 _ -> object.data["content"] || ""
434 end
435 end
436
437 def render_content(%{data: %{"type" => object_type}} = object)
438 when object_type in ["Article", "Page"] do
439 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
440 url when is_bitstring(url) <- object.data["url"] do
441 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
442 else
443 _ -> object.data["content"] || ""
444 end
445 end
446
447 def render_content(object), do: object.data["content"] || ""
448
449 @doc """
450 Builds a dictionary tags.
451
452 ## Examples
453
454 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
455 [{"name": "fediverse", "url": "/tag/fediverse"},
456 {"name": "nextcloud", "url": "/tag/nextcloud"}]
457
458 """
459 @spec build_tags(list(any())) :: list(map())
460 def build_tags(object_tags) when is_list(object_tags) do
461 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
462
463 Enum.reduce(object_tags, [], fn tag, tags ->
464 tags ++ [%{name: tag, url: "/tag/#{tag}"}]
465 end)
466 end
467
468 def build_tags(_), do: []
469
470 @doc """
471 Builds list emojis.
472
473 Arguments: `nil` or list tuple of name and url.
474
475 Returns list emojis.
476
477 ## Examples
478
479 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
480 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
481
482 """
483 @spec build_emojis(nil | list(tuple())) :: list(map())
484 def build_emojis(nil), do: []
485
486 def build_emojis(emojis) do
487 emojis
488 |> Enum.map(fn {name, url} ->
489 name = HTML.strip_tags(name)
490
491 url =
492 url
493 |> HTML.strip_tags()
494 |> MediaProxy.url()
495
496 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
497 end)
498 end
499
500 defp present?(nil), do: false
501 defp present?(false), do: false
502 defp present?(_), do: true
503
504 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
505 do: id in pinned_activities
506 end