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