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