5bf4a6ba2bc6b40cf38a6d491938afbaa0d5a2b0
[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 {options, votes_count} =
356 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
357 current_count = option["replies"]["totalItems"] || 0
358
359 {%{
360 title: HTML.strip_tags(name),
361 votes_count: current_count
362 }, current_count + count}
363 end)
364
365 %{
366 # Mastodon uses separate ids for polls, but an object can't have
367 # more than one poll embedded so object id is fine
368 id: object.id,
369 expires_at: Utils.to_masto_date(end_time),
370 expired: expired,
371 multiple: multiple,
372 votes_count: votes_count,
373 options: options,
374 # TODO: Actually check for a vote
375 voted: false,
376 emojis: build_emojis(object.data["emoji"])
377 }
378 else
379 nil
380 end
381 end
382
383 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
384 object = Object.normalize(activity)
385
386 with nil <- replied_to_activities[object.data["inReplyTo"]] do
387 # If user didn't participate in the thread
388 Activity.get_in_reply_to_activity(activity)
389 end
390 end
391
392 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
393 object = Object.normalize(activity)
394
395 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
396 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
397 else
398 nil
399 end
400 end
401
402 def render_content(%{data: %{"type" => "Video"}} = object) do
403 with name when not is_nil(name) and name != "" <- object.data["name"] do
404 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
405 else
406 _ -> object.data["content"] || ""
407 end
408 end
409
410 def render_content(%{data: %{"type" => object_type}} = object)
411 when object_type in ["Article", "Page"] do
412 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
413 url when is_bitstring(url) <- object.data["url"] do
414 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
415 else
416 _ -> object.data["content"] || ""
417 end
418 end
419
420 def render_content(object), do: object.data["content"] || ""
421
422 @doc """
423 Builds a dictionary tags.
424
425 ## Examples
426
427 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
428 [{"name": "fediverse", "url": "/tag/fediverse"},
429 {"name": "nextcloud", "url": "/tag/nextcloud"}]
430
431 """
432 @spec build_tags(list(any())) :: list(map())
433 def build_tags(object_tags) when is_list(object_tags) do
434 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
435
436 Enum.reduce(object_tags, [], fn tag, tags ->
437 tags ++ [%{name: tag, url: "/tag/#{tag}"}]
438 end)
439 end
440
441 def build_tags(_), do: []
442
443 @doc """
444 Builds list emojis.
445
446 Arguments: `nil` or list tuple of name and url.
447
448 Returns list emojis.
449
450 ## Examples
451
452 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
453 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
454
455 """
456 @spec build_emojis(nil | list(tuple())) :: list(map())
457 def build_emojis(nil), do: []
458
459 def build_emojis(emojis) do
460 emojis
461 |> Enum.map(fn {name, url} ->
462 name = HTML.strip_tags(name)
463
464 url =
465 url
466 |> HTML.strip_tags()
467 |> MediaProxy.url()
468
469 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
470 end)
471 end
472
473 defp present?(nil), do: false
474 defp present?(false), do: false
475 defp present?(_), do: true
476
477 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
478 do: id in pinned_activities
479 end