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