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