Merge branch 'release/2.0.7' into 'stable'
[akkoma] / lib / pleroma / web / mastodon_api / views / status_view.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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 %ActivityExpiration{scheduled_at: scheduled_at} <-
179 ActivityExpiration.get_by_activity_id(activity.id) do
180 scheduled_at
181 else
182 _ -> nil
183 end
184
185 thread_muted? =
186 case activity.thread_muted? do
187 thread_muted? when is_boolean(thread_muted?) -> thread_muted?
188 nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
189 end
190
191 attachment_data = object.data["attachment"] || []
192 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
193
194 created_at = Utils.to_masto_date(object.data["published"])
195
196 reply_to = get_reply_to(activity, opts)
197
198 reply_to_user = reply_to && get_user(reply_to.data["actor"])
199
200 content =
201 object
202 |> render_content()
203
204 content_html =
205 content
206 |> HTML.get_cached_scrubbed_html_for_activity(
207 User.html_filter_policy(opts[:for]),
208 activity,
209 "mastoapi:content"
210 )
211
212 content_plaintext =
213 content
214 |> HTML.get_cached_stripped_html_for_activity(
215 activity,
216 "mastoapi:content"
217 )
218
219 summary = object.data["summary"] || ""
220
221 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
222
223 url =
224 if user.local do
225 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
226 else
227 object.data["url"] || object.data["external_url"] || object.data["id"]
228 end
229
230 direct_conversation_id =
231 with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
232 {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
233 {_, %User{} = for_user} <- {:for_user, opts[:for]} do
234 Activity.direct_conversation_id(activity, for_user)
235 else
236 {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
237 participation_id
238
239 _e ->
240 nil
241 end
242
243 emoji_reactions =
244 with %{data: %{"reactions" => emoji_reactions}} <- object do
245 Enum.map(emoji_reactions, fn [emoji, users] ->
246 %{
247 name: emoji,
248 count: length(users),
249 me: !!(opts[:for] && opts[:for].ap_id in users)
250 }
251 end)
252 else
253 _ -> []
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,
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},
294 expires_at: expires_at,
295 direct_conversation_id: direct_conversation_id,
296 thread_muted: thread_muted?,
297 emoji_reactions: emoji_reactions
298 }
299 }
300 end
301
302 def render("show.json", _) do
303 nil
304 end
305
306 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
307 page_url_data = URI.parse(page_url)
308
309 page_url_data =
310 if is_binary(rich_media["url"]) do
311 URI.merge(page_url_data, URI.parse(rich_media["url"]))
312 else
313 page_url_data
314 end
315
316 page_url = page_url_data |> to_string
317
318 image_url =
319 if is_binary(rich_media["image"]) do
320 URI.merge(page_url_data, URI.parse(rich_media["image"]))
321 |> to_string
322 end
323
324 %{
325 type: "link",
326 provider_name: page_url_data.host,
327 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
328 url: page_url,
329 image: image_url |> MediaProxy.url(),
330 title: rich_media["title"] || "",
331 description: rich_media["description"] || "",
332 pleroma: %{
333 opengraph: rich_media
334 }
335 }
336 end
337
338 def render("card.json", _), do: nil
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("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
368 object = Object.normalize(activity)
369
370 user = get_user(activity.data["actor"])
371 created_at = Utils.to_masto_date(activity.data["published"])
372
373 %{
374 id: activity.id,
375 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
376 created_at: created_at,
377 title: object.data["title"] |> HTML.strip_tags(),
378 artist: object.data["artist"] |> HTML.strip_tags(),
379 album: object.data["album"] |> HTML.strip_tags(),
380 length: object.data["length"]
381 }
382 end
383
384 def render("listens.json", opts) do
385 safe_render_many(opts.activities, StatusView, "listen.json", opts)
386 end
387
388 def render("context.json", %{activity: activity, activities: activities, user: user}) do
389 %{ancestors: ancestors, descendants: descendants} =
390 activities
391 |> Enum.reverse()
392 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
393 |> Map.put_new(:ancestors, [])
394 |> Map.put_new(:descendants, [])
395
396 %{
397 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
398 descendants: render("index.json", for: user, activities: descendants, as: :activity)
399 }
400 end
401
402 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
403 object = Object.normalize(activity)
404
405 with nil <- replied_to_activities[object.data["inReplyTo"]] do
406 # If user didn't participate in the thread
407 Activity.get_in_reply_to_activity(activity)
408 end
409 end
410
411 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
412 object = Object.normalize(activity)
413
414 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
415 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
416 else
417 nil
418 end
419 end
420
421 def render_content(%{data: %{"type" => object_type}} = object)
422 when object_type in ["Video", "Event", "Audio"] do
423 with name when not is_nil(name) and name != "" <- object.data["name"] do
424 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
425 else
426 _ -> object.data["content"] || ""
427 end
428 end
429
430 def render_content(%{data: %{"type" => object_type}} = object)
431 when object_type in ["Article", "Page"] do
432 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
433 url when is_bitstring(url) <- object.data["url"] do
434 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
435 else
436 _ -> object.data["content"] || ""
437 end
438 end
439
440 def render_content(object), do: object.data["content"] || ""
441
442 @doc """
443 Builds a dictionary tags.
444
445 ## Examples
446
447 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
448 [{"name": "fediverse", "url": "/tag/fediverse"},
449 {"name": "nextcloud", "url": "/tag/nextcloud"}]
450
451 """
452 @spec build_tags(list(any())) :: list(map())
453 def build_tags(object_tags) when is_list(object_tags) do
454 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
455
456 Enum.reduce(object_tags, [], fn tag, tags ->
457 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
458 end)
459 end
460
461 def build_tags(_), do: []
462
463 @doc """
464 Builds list emojis.
465
466 Arguments: `nil` or list tuple of name and url.
467
468 Returns list emojis.
469
470 ## Examples
471
472 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
473 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
474
475 """
476 @spec build_emojis(nil | list(tuple())) :: list(map())
477 def build_emojis(nil), do: []
478
479 def build_emojis(emojis) do
480 emojis
481 |> Enum.map(fn {name, url} ->
482 name = HTML.strip_tags(name)
483
484 url =
485 url
486 |> HTML.strip_tags()
487 |> MediaProxy.url()
488
489 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
490 end)
491 end
492
493 defp present?(nil), do: false
494 defp present?(false), do: false
495 defp present?(_), do: true
496
497 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
498 do: id in pinned_activities
499 end