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