Merge branch 'feature/move-activity' 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.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 summary_html =
220 summary
221 |> HTML.get_cached_scrubbed_html_for_activity(
222 User.html_filter_policy(opts[:for]),
223 activity,
224 "mastoapi:summary"
225 )
226
227 summary_plaintext =
228 summary
229 |> HTML.get_cached_stripped_html_for_activity(
230 activity,
231 "mastoapi:summary"
232 )
233
234 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
235
236 url =
237 if user.local do
238 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
239 else
240 object.data["url"] || object.data["external_url"] || object.data["id"]
241 end
242
243 direct_conversation_id =
244 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
245 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
246 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
247 Activity.direct_conversation_id(activity, for_user)
248 else
249 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
250 participation_id
251
252 _e ->
253 nil
254 end
255
256 %{
257 id: to_string(activity.id),
258 uri: object.data["id"],
259 url: url,
260 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
261 in_reply_to_id: reply_to && to_string(reply_to.id),
262 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
263 reblog: nil,
264 card: card,
265 content: content_html,
266 created_at: created_at,
267 reblogs_count: announcement_count,
268 replies_count: object.data["repliesCount"] || 0,
269 favourites_count: like_count,
270 reblogged: reblogged?(activity, opts[:for]),
271 favourited: present?(favorited),
272 bookmarked: present?(bookmarked),
273 muted: thread_muted? || User.mutes?(opts[:for], user),
274 pinned: pinned?(activity, user),
275 sensitive: sensitive,
276 spoiler_text: summary_html,
277 visibility: get_visibility(object),
278 media_attachments: attachments,
279 poll: render(PollView, "show.json", object: object, for: opts[:for]),
280 mentions: mentions,
281 tags: build_tags(tags),
282 application: %{
283 name: "Web",
284 website: nil
285 },
286 language: nil,
287 emojis: build_emojis(object.data["emoji"]),
288 pleroma: %{
289 local: activity.local,
290 conversation_id: get_context_id(activity),
291 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
292 content: %{"text/plain" => content_plaintext},
293 spoiler_text: %{"text/plain" => summary_plaintext},
294 expires_at: expires_at,
295 direct_conversation_id: direct_conversation_id,
296 thread_muted: thread_muted?
297 }
298 }
299 end
300
301 def render("show.json", _) do
302 nil
303 end
304
305 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
306 page_url_data = URI.parse(page_url)
307
308 page_url_data =
309 if rich_media[:url] != nil do
310 URI.merge(page_url_data, URI.parse(rich_media[:url]))
311 else
312 page_url_data
313 end
314
315 page_url = page_url_data |> to_string
316
317 image_url =
318 if rich_media[:image] != nil do
319 URI.merge(page_url_data, URI.parse(rich_media[:image]))
320 |> to_string
321 else
322 nil
323 end
324
325 site_name = rich_media[:site_name] || page_url_data.host
326
327 %{
328 type: "link",
329 provider_name: site_name,
330 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
331 url: page_url,
332 image: image_url |> MediaProxy.url(),
333 title: rich_media[:title] || "",
334 description: rich_media[:description] || "",
335 pleroma: %{
336 opengraph: rich_media
337 }
338 }
339 end
340
341 def render("card.json", _), do: nil
342
343 def render("attachment.json", %{attachment: attachment}) do
344 [attachment_url | _] = attachment["url"]
345 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
346 href = attachment_url["href"] |> MediaProxy.url()
347
348 type =
349 cond do
350 String.contains?(media_type, "image") -> "image"
351 String.contains?(media_type, "video") -> "video"
352 String.contains?(media_type, "audio") -> "audio"
353 true -> "unknown"
354 end
355
356 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
357
358 %{
359 id: to_string(attachment["id"] || hash_id),
360 url: href,
361 remote_url: href,
362 preview_url: href,
363 text_url: href,
364 type: type,
365 description: attachment["name"],
366 pleroma: %{mime_type: media_type}
367 }
368 end
369
370 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
371 object = Object.normalize(activity)
372
373 user = get_user(activity.data["actor"])
374 created_at = Utils.to_masto_date(activity.data["published"])
375
376 %{
377 id: activity.id,
378 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
379 created_at: created_at,
380 title: object.data["title"] |> HTML.strip_tags(),
381 artist: object.data["artist"] |> HTML.strip_tags(),
382 album: object.data["album"] |> HTML.strip_tags(),
383 length: object.data["length"]
384 }
385 end
386
387 def render("listens.json", opts) do
388 safe_render_many(opts.activities, StatusView, "listen.json", opts)
389 end
390
391 def render("context.json", %{activity: activity, activities: activities, user: user}) do
392 %{ancestors: ancestors, descendants: descendants} =
393 activities
394 |> Enum.reverse()
395 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
396 |> Map.put_new(:ancestors, [])
397 |> Map.put_new(:descendants, [])
398
399 %{
400 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
401 descendants: render("index.json", for: user, activities: descendants, as: :activity)
402 }
403 end
404
405 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
406 object = Object.normalize(activity)
407
408 with nil <- replied_to_activities[object.data["inReplyTo"]] do
409 # If user didn't participate in the thread
410 Activity.get_in_reply_to_activity(activity)
411 end
412 end
413
414 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
415 object = Object.normalize(activity)
416
417 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
418 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
419 else
420 nil
421 end
422 end
423
424 def render_content(%{data: %{"type" => "Video"}} = object) do
425 with name when not is_nil(name) and name != "" <- object.data["name"] do
426 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
427 else
428 _ -> object.data["content"] || ""
429 end
430 end
431
432 def render_content(%{data: %{"type" => object_type}} = object)
433 when object_type in ["Article", "Page"] do
434 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
435 url when is_bitstring(url) <- object.data["url"] do
436 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
437 else
438 _ -> object.data["content"] || ""
439 end
440 end
441
442 def render_content(object), do: object.data["content"] || ""
443
444 @doc """
445 Builds a dictionary tags.
446
447 ## Examples
448
449 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
450 [{"name": "fediverse", "url": "/tag/fediverse"},
451 {"name": "nextcloud", "url": "/tag/nextcloud"}]
452
453 """
454 @spec build_tags(list(any())) :: list(map())
455 def build_tags(object_tags) when is_list(object_tags) do
456 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
457
458 Enum.reduce(object_tags, [], fn tag, tags ->
459 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
460 end)
461 end
462
463 def build_tags(_), do: []
464
465 @doc """
466 Builds list emojis.
467
468 Arguments: `nil` or list tuple of name and url.
469
470 Returns list emojis.
471
472 ## Examples
473
474 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
475 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
476
477 """
478 @spec build_emojis(nil | list(tuple())) :: list(map())
479 def build_emojis(nil), do: []
480
481 def build_emojis(emojis) do
482 emojis
483 |> Enum.map(fn {name, url} ->
484 name = HTML.strip_tags(name)
485
486 url =
487 url
488 |> HTML.strip_tags()
489 |> MediaProxy.url()
490
491 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
492 end)
493 end
494
495 defp present?(nil), do: false
496 defp present?(false), do: false
497 defp present?(_), do: true
498
499 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
500 do: id in pinned_activities
501 end