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