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