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