Merge branch 'length-limit-bio' 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.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"}} = activity ->
30 object = Object.normalize(activity)
31 object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
32
33 _ ->
34 nil
35 end)
36 |> Enum.filter(& &1)
37 |> Activity.create_by_object_ap_id_with_object()
38 |> Repo.all()
39 |> Enum.reduce(%{}, fn activity, acc ->
40 object = Object.normalize(activity)
41 if object, do: Map.put(acc, object.data["id"], activity), else: acc
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 |> Activity.with_set_thread_muted_field(opts[:for])
94 |> Repo.one()
95
96 reblogged = render("status.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("account.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: "public",
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("status.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 thread_muted? =
177 case activity.thread_muted? do
178 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
179 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
180 end
181
182 attachment_data = object.data["attachment"] || []
183 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
184
185 created_at = Utils.to_masto_date(object.data["published"])
186
187 reply_to = get_reply_to(activity, opts)
188
189 reply_to_user = reply_to && get_user(reply_to.data["actor"])
190
191 content =
192 object
193 |> render_content()
194
195 content_html =
196 content
197 |> HTML.get_cached_scrubbed_html_for_activity(
198 User.html_filter_policy(opts[:for]),
199 activity,
200 "mastoapi:content"
201 )
202
203 content_plaintext =
204 content
205 |> HTML.get_cached_stripped_html_for_activity(
206 activity,
207 "mastoapi:content"
208 )
209
210 summary = object.data["summary"] || ""
211
212 summary_html =
213 summary
214 |> HTML.get_cached_scrubbed_html_for_activity(
215 User.html_filter_policy(opts[:for]),
216 activity,
217 "mastoapi:summary"
218 )
219
220 summary_plaintext =
221 summary
222 |> HTML.get_cached_stripped_html_for_activity(
223 activity,
224 "mastoapi:summary"
225 )
226
227 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
228
229 url =
230 if user.local do
231 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
232 else
233 object.data["url"] || object.data["external_url"] || object.data["id"]
234 end
235
236 %{
237 id: to_string(activity.id),
238 uri: object.data["id"],
239 url: url,
240 account: AccountView.render("account.json", %{user: user, for: opts[:for]}),
241 in_reply_to_id: reply_to && to_string(reply_to.id),
242 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
243 reblog: nil,
244 card: card,
245 content: content_html,
246 created_at: created_at,
247 reblogs_count: announcement_count,
248 replies_count: object.data["repliesCount"] || 0,
249 favourites_count: like_count,
250 reblogged: reblogged?(activity, opts[:for]),
251 favourited: present?(favorited),
252 bookmarked: present?(bookmarked),
253 muted: thread_muted? || User.mutes?(opts[:for], user),
254 pinned: pinned?(activity, user),
255 sensitive: sensitive,
256 spoiler_text: summary_html,
257 visibility: get_visibility(object),
258 media_attachments: attachments,
259 poll: render("poll.json", %{object: object, for: opts[:for]}),
260 mentions: mentions,
261 tags: build_tags(tags),
262 application: %{
263 name: "Web",
264 website: nil
265 },
266 language: nil,
267 emojis: build_emojis(object.data["emoji"]),
268 pleroma: %{
269 local: activity.local,
270 conversation_id: get_context_id(activity),
271 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
272 content: %{"text/plain" => content_plaintext},
273 spoiler_text: %{"text/plain" => summary_plaintext}
274 }
275 }
276 end
277
278 def render("status.json", _) do
279 nil
280 end
281
282 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
283 page_url_data = URI.parse(page_url)
284
285 page_url_data =
286 if rich_media[:url] != nil do
287 URI.merge(page_url_data, URI.parse(rich_media[:url]))
288 else
289 page_url_data
290 end
291
292 page_url = page_url_data |> to_string
293
294 image_url =
295 if rich_media[:image] != nil do
296 URI.merge(page_url_data, URI.parse(rich_media[:image]))
297 |> to_string
298 else
299 nil
300 end
301
302 site_name = rich_media[:site_name] || page_url_data.host
303
304 %{
305 type: "link",
306 provider_name: site_name,
307 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
308 url: page_url,
309 image: image_url |> MediaProxy.url(),
310 title: rich_media[:title] || "",
311 description: rich_media[:description] || "",
312 pleroma: %{
313 opengraph: rich_media
314 }
315 }
316 end
317
318 def render("card.json", _) do
319 nil
320 end
321
322 def render("attachment.json", %{attachment: attachment}) do
323 [attachment_url | _] = attachment["url"]
324 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
325 href = attachment_url["href"] |> MediaProxy.url()
326
327 type =
328 cond do
329 String.contains?(media_type, "image") -> "image"
330 String.contains?(media_type, "video") -> "video"
331 String.contains?(media_type, "audio") -> "audio"
332 true -> "unknown"
333 end
334
335 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
336
337 %{
338 id: to_string(attachment["id"] || hash_id),
339 url: href,
340 remote_url: href,
341 preview_url: href,
342 text_url: href,
343 type: type,
344 description: attachment["name"],
345 pleroma: %{mime_type: media_type}
346 }
347 end
348
349 def render("poll.json", %{object: object} = opts) do
350 {multiple, options} =
351 case object.data do
352 %{"anyOf" => options} when is_list(options) -> {true, options}
353 %{"oneOf" => options} when is_list(options) -> {false, options}
354 _ -> {nil, nil}
355 end
356
357 if options do
358 end_time =
359 (object.data["closed"] || object.data["endTime"])
360 |> NaiveDateTime.from_iso8601!()
361
362 expired =
363 end_time
364 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
365 |> case do
366 :lt -> true
367 _ -> false
368 end
369
370 voted =
371 if opts[:for] do
372 existing_votes =
373 Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object)
374
375 existing_votes != [] or opts[:for].ap_id == object.data["actor"]
376 else
377 false
378 end
379
380 {options, votes_count} =
381 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
382 current_count = option["replies"]["totalItems"] || 0
383
384 {%{
385 title: HTML.strip_tags(name),
386 votes_count: current_count
387 }, current_count + count}
388 end)
389
390 %{
391 # Mastodon uses separate ids for polls, but an object can't have
392 # more than one poll embedded so object id is fine
393 id: to_string(object.id),
394 expires_at: Utils.to_masto_date(end_time),
395 expired: expired,
396 multiple: multiple,
397 votes_count: votes_count,
398 options: options,
399 voted: voted,
400 emojis: build_emojis(object.data["emoji"])
401 }
402 else
403 nil
404 end
405 end
406
407 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
408 object = Object.normalize(activity)
409
410 with nil <- replied_to_activities[object.data["inReplyTo"]] do
411 # If user didn't participate in the thread
412 Activity.get_in_reply_to_activity(activity)
413 end
414 end
415
416 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
417 object = Object.normalize(activity)
418
419 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
420 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
421 else
422 nil
423 end
424 end
425
426 def render_content(%{data: %{"type" => "Video"}} = object) do
427 with name when not is_nil(name) and name != "" <- object.data["name"] do
428 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
429 else
430 _ -> object.data["content"] || ""
431 end
432 end
433
434 def render_content(%{data: %{"type" => object_type}} = object)
435 when object_type in ["Article", "Page"] do
436 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
437 url when is_bitstring(url) <- object.data["url"] do
438 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
439 else
440 _ -> object.data["content"] || ""
441 end
442 end
443
444 def render_content(object), do: object.data["content"] || ""
445
446 @doc """
447 Builds a dictionary tags.
448
449 ## Examples
450
451 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
452 [{"name": "fediverse", "url": "/tag/fediverse"},
453 {"name": "nextcloud", "url": "/tag/nextcloud"}]
454
455 """
456 @spec build_tags(list(any())) :: list(map())
457 def build_tags(object_tags) when is_list(object_tags) do
458 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
459
460 Enum.reduce(object_tags, [], fn tag, tags ->
461 tags ++ [%{name: tag, url: "/tag/#{tag}"}]
462 end)
463 end
464
465 def build_tags(_), do: []
466
467 @doc """
468 Builds list emojis.
469
470 Arguments: `nil` or list tuple of name and url.
471
472 Returns list emojis.
473
474 ## Examples
475
476 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
477 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
478
479 """
480 @spec build_emojis(nil | list(tuple())) :: list(map())
481 def build_emojis(nil), do: []
482
483 def build_emojis(emojis) do
484 emojis
485 |> Enum.map(fn {name, url} ->
486 name = HTML.strip_tags(name)
487
488 url =
489 url
490 |> HTML.strip_tags()
491 |> MediaProxy.url()
492
493 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
494 end)
495 end
496
497 defp present?(nil), do: false
498 defp present?(false), do: false
499 defp present?(_), do: true
500
501 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
502 do: id in pinned_activities
503 end