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