status_view.ex 16.8 KB
Newer Older
1
# Pleroma: A lightweight social networking server
2
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3
4
# SPDX-License-Identifier: AGPL-3.0-only

lain's avatar
lain committed
5
6
defmodule Pleroma.Web.MastodonAPI.StatusView do
  use Pleroma.Web, :view
Maksim's avatar
Maksim committed
7

8
9
  require Pleroma.Constants

Maksim's avatar
Maksim committed
10
  alias Pleroma.Activity
11
  alias Pleroma.ActivityExpiration
Maksim's avatar
Maksim committed
12
  alias Pleroma.HTML
13
  alias Pleroma.Object
rinpatch's avatar
rinpatch committed
14
  alias Pleroma.Repo
Maksim's avatar
Maksim committed
15
  alias Pleroma.User
16
  alias Pleroma.UserRelationship
17
  alias Pleroma.Web.CommonAPI
18
  alias Pleroma.Web.CommonAPI.Utils
Maksim's avatar
Maksim committed
19
  alias Pleroma.Web.MastodonAPI.AccountView
20
  alias Pleroma.Web.MastodonAPI.PollView
Maksim's avatar
Maksim committed
21
  alias Pleroma.Web.MastodonAPI.StatusView
href's avatar
href committed
22
  alias Pleroma.Web.MediaProxy
lain's avatar
lain committed
23

24
25
  import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1]

lain's avatar
lain committed
26
  # TODO: Add cached version.
27
28
  defp get_replied_to_activities([]), do: %{}

lain's avatar
lain committed
29
30
31
  defp get_replied_to_activities(activities) do
    activities
    |> Enum.map(fn
32
33
34
      %{data: %{"type" => "Create"}} = activity ->
        object = Object.normalize(activity)
        object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
lain's avatar
lain committed
35
36
37

      _ ->
        nil
lain's avatar
lain committed
38
    end)
lain's avatar
lain committed
39
    |> Enum.filter(& &1)
40
    |> Activity.create_by_object_ap_id_with_object()
lain's avatar
lain committed
41
42
    |> Repo.all()
    |> Enum.reduce(%{}, fn activity, acc ->
43
      object = Object.normalize(activity)
44
      if object, do: Map.put(acc, object.data["id"], activity), else: acc
lain's avatar
lain committed
45
    end)
lain's avatar
lain committed
46
  end
lain's avatar
lain committed
47

lain's avatar
lain committed
48
49
50
51
52
53
54
55
56
57
58
59
60
  defp get_user(ap_id) do
    cond do
      user = User.get_cached_by_ap_id(ap_id) ->
        user

      user = User.get_by_guessed_nickname(ap_id) ->
        user

      true ->
        User.error_user(ap_id)
    end
  end

61
62
63
64
65
66
67
68
  defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
    do: context_id

  defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
    do: Utils.context_to_conversation_id(context)

  defp get_context_id(_), do: nil

69
  defp reblogged?(activity, user) do
70
71
    object = Object.normalize(activity) || %{}
    present?(user && user.ap_id in (object.data["announcements"] || []))
72
73
  end

lain's avatar
lain committed
74
  def render("index.json", opts) do
75
76
    reading_user = opts[:for]

77
78
    # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
    activities = Enum.filter(opts.activities, & &1)
79
80
    replied_to_activities = get_replied_to_activities(activities)

81
82
83
84
85
86
    parent_activities =
      activities
      |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
      |> Enum.map(&Object.normalize(&1).data["id"])
      |> Activity.create_by_object_ap_id()
      |> Activity.with_preloaded_object(:left)
87
88
      |> Activity.with_preloaded_bookmark(reading_user)
      |> Activity.with_set_thread_muted_field(reading_user)
89
90
      |> Repo.all()

91
    relationships_opt =
92
93
94
95
      cond do
        Map.has_key?(opts, :relationships) ->
          opts[:relationships]

96
        is_nil(reading_user) ->
97
98
99
100
101
          UserRelationship.view_relationships_option(nil, [])

        true ->
          actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"]))

102
          UserRelationship.view_relationships_option(reading_user, actors)
103
      end
104

105
106
107
    opts =
      opts
      |> Map.put(:replied_to_activities, replied_to_activities)
108
      |> Map.put(:parent_activities, parent_activities)
109
      |> Map.put(:relationships, relationships_opt)
lain's avatar
lain committed
110

111
    safe_render_many(activities, StatusView, "show.json", opts)
lain's avatar
lain committed
112
113
  end

lain's avatar
lain committed
114
  def render(
115
        "show.json",
116
        %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
lain's avatar
lain committed
117
      ) do
lain's avatar
lain committed
118
    user = get_user(activity.data["actor"])
lain's avatar
lain committed
119
    created_at = Utils.to_masto_date(activity.data["published"])
120
    activity_object = Object.normalize(activity)
lain's avatar
lain committed
121

122
123
124
125
126
127
128
129
130
131
132
133
    reblogged_parent_activity =
      if opts[:parent_activities] do
        Activity.Queries.find_by_object_ap_id(
          opts[:parent_activities],
          activity_object.data["id"]
        )
      else
        Activity.create_by_object_ap_id(activity_object.data["id"])
        |> Activity.with_preloaded_bookmark(opts[:for])
        |> Activity.with_set_thread_muted_field(opts[:for])
        |> Repo.one()
      end
134

135
136
    reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
    reblogged = render("show.json", reblog_rendering_opts)
lain's avatar
lain committed
137

138
    favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || [])
Alexander Strizhakov's avatar
Alexander Strizhakov committed
139

140
    bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
141

lain's avatar
lain committed
142
143
144
145
146
    mentions =
      activity.recipients
      |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
      |> Enum.filter(& &1)
      |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
lain's avatar
lain committed
147
148

    %{
149
      id: to_string(activity.id),
150
151
      uri: activity_object.data["id"],
      url: activity_object.data["id"],
152
153
154
155
      account:
        AccountView.render("show.json", %{
          user: user,
          for: opts[:for],
156
          relationships: opts[:relationships]
157
        }),
lain's avatar
lain committed
158
159
160
      in_reply_to_id: nil,
      in_reply_to_account_id: nil,
      reblog: reblogged,
161
      content: reblogged[:content] || "",
lain's avatar
lain committed
162
163
      created_at: created_at,
      reblogs_count: 0,
164
      replies_count: 0,
lain's avatar
lain committed
165
      favourites_count: 0,
166
      reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
167
168
      favourited: present?(favorited),
      bookmarked: present?(bookmarked),
lain's avatar
lain committed
169
      muted: false,
170
      pinned: pinned?(activity, user),
lain's avatar
lain committed
171
172
      sensitive: false,
      spoiler_text: "",
173
      visibility: get_visibility(activity),
Maksim's avatar
Maksim committed
174
      media_attachments: reblogged[:media_attachments] || [],
lain's avatar
lain committed
175
      mentions: mentions,
Maksim's avatar
Maksim committed
176
      tags: reblogged[:tags] || [],
lain's avatar
lain committed
177
178
179
180
      application: %{
        name: "Web",
        website: nil
      },
lain's avatar
lain committed
181
      language: nil,
182
183
184
185
      emojis: [],
      pleroma: %{
        local: activity.local
      }
lain's avatar
lain committed
186
187
188
    }
  end

189
  def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
190
    object = Object.normalize(activity)
191

lain's avatar
lain committed
192
    user = get_user(activity.data["actor"])
193
    user_follower_address = user.follower_address
lain's avatar
lain committed
194

195
196
    like_count = object.data["like_count"] || 0
    announcement_count = object.data["announcement_count"] || 0
lain's avatar
lain committed
197

198
199
    tags = object.data["tag"] || []
    sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw")
lain's avatar
lain committed
200

201
202
203
204
205
    tag_mentions =
      tags
      |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
      |> Enum.map(fn tag -> tag["href"] end)

lain's avatar
lain committed
206
    mentions =
207
208
      (object.data["to"] ++ tag_mentions)
      |> Enum.uniq()
209
210
211
212
213
      |> Enum.map(fn
        Pleroma.Constants.as_public() -> nil
        ^user_follower_address -> nil
        ap_id -> User.get_cached_by_ap_id(ap_id)
      end)
lain's avatar
lain committed
214
215
      |> Enum.filter(& &1)
      |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
lain's avatar
lain committed
216

217
    favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
lain's avatar
lain committed
218

219
    bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
lain's avatar
lain committed
220

221
222
    client_posted_this_activity = opts[:for] && user.id == opts[:for].id

223
    expires_at =
224
      with true <- client_posted_this_activity,
minibikini's avatar
minibikini committed
225
           %ActivityExpiration{scheduled_at: scheduled_at} <-
226
             ActivityExpiration.get_by_activity_id(activity.id) do
minibikini's avatar
minibikini committed
227
228
229
        scheduled_at
      else
        _ -> nil
230
231
      end

Aaron Tinio's avatar
Aaron Tinio committed
232
    thread_muted? =
233
234
235
236
      cond do
        is_nil(opts[:for]) -> false
        is_boolean(activity.thread_muted?) -> activity.thread_muted?
        true -> CommonAPI.thread_muted?(opts[:for], activity)
Aaron Tinio's avatar
Aaron Tinio committed
237
238
      end

239
    attachment_data = object.data["attachment"] || []
lain's avatar
lain committed
240
    attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
lain's avatar
lain committed
241

242
    created_at = Utils.to_masto_date(object.data["published"])
243

lain's avatar
lain committed
244
    reply_to = get_reply_to(activity, opts)
lain's avatar
lain committed
245

lain's avatar
lain committed
246
    reply_to_user = reply_to && get_user(reply_to.data["actor"])
Roger Braun's avatar
Roger Braun committed
247

248
    content =
Maksim's avatar
Maksim committed
249
250
      object
      |> render_content()
251
252
253

    content_html =
      content
254
      |> HTML.get_cached_scrubbed_html_for_activity(
255
256
        User.html_filter_policy(opts[:for]),
        activity,
257
258
259
        "mastoapi:content"
      )

260
261
262
263
264
265
266
    content_plaintext =
      content
      |> HTML.get_cached_stripped_html_for_activity(
        activity,
        "mastoapi:content"
      )

267
    summary = object.data["summary"] || ""
268

269
    card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
270

lain's avatar
lain committed
271
272
273
274
    url =
      if user.local do
        Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
      else
275
        object.data["url"] || object.data["external_url"] || object.data["id"]
lain's avatar
lain committed
276
      end
277

278
    direct_conversation_id =
279
280
      with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
           {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
281
282
           {_, %User{} = for_user} <- {:for_user, opts[:for]} do
        Activity.direct_conversation_id(activity, for_user)
283
      else
284
285
286
        {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
          participation_id

287
288
        _e ->
          nil
lain's avatar
lain committed
289
      end
290

lain's avatar
lain committed
291
292
    emoji_reactions =
      with %{data: %{"reactions" => emoji_reactions}} <- object do
293
        Enum.map(emoji_reactions, fn [emoji, users] ->
294
          %{
295
            name: emoji,
296
            count: length(users),
297
            me: !!(opts[:for] && opts[:for].ap_id in users)
298
          }
lain's avatar
lain committed
299
300
        end)
      else
301
        _ -> []
lain's avatar
lain committed
302
303
      end

304
305
    muted =
      thread_muted? ||
306
        UserRelationship.exists?(
307
          get_in(opts, [:relationships, :user_relationships]),
308
309
310
311
312
313
          :mute,
          opts[:for],
          user,
          fn for_user, user -> User.mutes?(for_user, user) end
        )

lain's avatar
lain committed
314
    %{
315
      id: to_string(activity.id),
316
      uri: object.data["id"],
lain's avatar
lain committed
317
      url: url,
318
319
320
321
      account:
        AccountView.render("show.json", %{
          user: user,
          for: opts[:for],
322
          relationships: opts[:relationships]
323
        }),
324
325
      in_reply_to_id: reply_to && to_string(reply_to.id),
      in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
lain's avatar
lain committed
326
      reblog: nil,
327
      card: card,
328
      content: content_html,
329
      created_at: created_at,
lain's avatar
lain committed
330
      reblogs_count: announcement_count,
331
      replies_count: object.data["repliesCount"] || 0,
lain's avatar
lain committed
332
      favourites_count: like_count,
333
      reblogged: reblogged?(activity, opts[:for]),
Maksim's avatar
Maksim committed
334
      favourited: present?(favorited),
Haelwenn's avatar
Haelwenn committed
335
      bookmarked: present?(bookmarked),
336
      muted: muted,
337
      pinned: pinned?(activity, user),
lain's avatar
lain committed
338
      sensitive: sensitive,
339
      spoiler_text: summary,
340
      visibility: get_visibility(object),
341
      media_attachments: attachments,
342
      poll: render(PollView, "show.json", object: object, for: opts[:for]),
lain's avatar
lain committed
343
      mentions: mentions,
Maksim's avatar
Maksim committed
344
      tags: build_tags(tags),
345
346
347
348
      application: %{
        name: "Web",
        website: nil
      },
Roger Braun's avatar
Roger Braun committed
349
      language: nil,
350
      emojis: build_emojis(object.data["emoji"]),
351
      pleroma: %{
352
        local: activity.local,
353
        conversation_id: get_context_id(activity),
354
        in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
355
        content: %{"text/plain" => content_plaintext},
356
        spoiler_text: %{"text/plain" => summary},
357
        expires_at: expires_at,
358
        direct_conversation_id: direct_conversation_id,
lain's avatar
lain committed
359
360
        thread_muted: thread_muted?,
        emoji_reactions: emoji_reactions
361
      }
lain's avatar
lain committed
362
363
    }
  end
lain's avatar
lain committed
364

365
  def render("show.json", _) do
366
367
368
    nil
  end

369
  def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
370
371
    page_url_data = URI.parse(page_url)

rinpatch's avatar
rinpatch committed
372
373
    page_url_data =
      if rich_media[:url] != nil do
374
        URI.merge(page_url_data, URI.parse(rich_media[:url]))
375
      else
376
        page_url_data
377
      end
rinpatch's avatar
rinpatch committed
378

rinpatch's avatar
rinpatch committed
379
380
381
    page_url = page_url_data |> to_string

    image_url =
382
383
384
385
386
387
      if rich_media[:image] != nil do
        URI.merge(page_url_data, URI.parse(rich_media[:image]))
        |> to_string
      else
        nil
      end
rinpatch's avatar
rinpatch committed
388

389
390
    %{
      type: "link",
391
      provider_name: page_url_data.host,
392
393
      provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
      url: page_url,
rinpatch's avatar
rinpatch committed
394
      image: image_url |> MediaProxy.url(),
395
396
      title: rich_media[:title] || "",
      description: rich_media[:description] || "",
397
398
399
400
401
402
      pleroma: %{
        opengraph: rich_media
      }
    }
  end

Maksim's avatar
Maksim committed
403
  def render("card.json", _), do: nil
404

lain's avatar
lain committed
405
  def render("attachment.json", %{attachment: attachment}) do
Haelwenn's avatar
Haelwenn committed
406
    [attachment_url | _] = attachment["url"]
407
    media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
408
    href = attachment_url["href"] |> MediaProxy.url()
lain's avatar
lain committed
409

lain's avatar
lain committed
410
411
412
413
414
415
416
    type =
      cond do
        String.contains?(media_type, "image") -> "image"
        String.contains?(media_type, "video") -> "video"
        String.contains?(media_type, "audio") -> "audio"
        true -> "unknown"
      end
lain's avatar
lain committed
417

lain's avatar
lain committed
418
    <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
lain's avatar
lain committed
419

lain's avatar
lain committed
420
    %{
lain's avatar
lain committed
421
      id: to_string(attachment["id"] || hash_id),
422
      url: href,
lain's avatar
lain committed
423
      remote_url: href,
424
      preview_url: href,
425
      text_url: href,
426
      type: type,
427
428
      description: attachment["name"],
      pleroma: %{mime_type: media_type}
lain's avatar
lain committed
429
430
    }
  end
431

432
433
434
435
436
437
438
439
  def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
    object = Object.normalize(activity)

    user = get_user(activity.data["actor"])
    created_at = Utils.to_masto_date(activity.data["published"])

    %{
      id: activity.id,
minibikini's avatar
minibikini committed
440
      account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
441
442
443
444
445
446
447
448
      created_at: created_at,
      title: object.data["title"] |> HTML.strip_tags(),
      artist: object.data["artist"] |> HTML.strip_tags(),
      album: object.data["album"] |> HTML.strip_tags(),
      length: object.data["length"]
    }
  end

449
450
451
452
  def render("listens.json", opts) do
    safe_render_many(opts.activities, StatusView, "listen.json", opts)
  end

453
454
455
456
457
458
459
460
461
462
463
464
465
466
  def render("context.json", %{activity: activity, activities: activities, user: user}) do
    %{ancestors: ancestors, descendants: descendants} =
      activities
      |> Enum.reverse()
      |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
      |> Map.put_new(:ancestors, [])
      |> Map.put_new(:descendants, [])

    %{
      ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
      descendants: render("index.json", for: user, activities: descendants, as: :activity)
    }
  end

467
  def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
468
    object = Object.normalize(activity)
469
470

    with nil <- replied_to_activities[object.data["inReplyTo"]] do
Alexander Strizhakov's avatar
Alexander Strizhakov committed
471
472
473
      # If user didn't participate in the thread
      Activity.get_in_reply_to_activity(activity)
    end
474
475
  end

476
477
  def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
    object = Object.normalize(activity)
478
479

    if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
480
      Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
481
482
483
484
485
    else
      nil
    end
  end

486
  def render_content(%{data: %{"type" => object_type}} = object)
487
      when object_type in ["Video", "Event", "Audio"] do
488
489
    with name when not is_nil(name) and name != "" <- object.data["name"] do
      "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
Maksim's avatar
Maksim committed
490
    else
491
      _ -> object.data["content"] || ""
Maksim's avatar
Maksim committed
492
493
    end
  end
494

495
  def render_content(%{data: %{"type" => object_type}} = object)
Maksim's avatar
Maksim committed
496
      when object_type in ["Article", "Page"] do
497
498
499
    with summary when not is_nil(summary) and summary != "" <- object.data["name"],
         url when is_bitstring(url) <- object.data["url"] do
      "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
Maksim's avatar
Maksim committed
500
    else
501
      _ -> object.data["content"] || ""
Maksim's avatar
Maksim committed
502
    end
503
504
  end

505
  def render_content(object), do: object.data["content"] || ""
kaniini's avatar
kaniini committed
506

Maksim's avatar
Maksim committed
507
508
509
510
511
512
513
514
515
516
  @doc """
  Builds a dictionary tags.

  ## Examples

  iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
  [{"name": "fediverse", "url": "/tag/fediverse"},
   {"name": "nextcloud", "url": "/tag/nextcloud"}]

  """
Maksim's avatar
Maksim committed
517
  @spec build_tags(list(any())) :: list(map())
Maksim's avatar
Maksim committed
518
  def build_tags(object_tags) when is_list(object_tags) do
Maksim's avatar
Maksim committed
519
520
    object_tags = for tag when is_binary(tag) <- object_tags, do: tag

Maksim's avatar
Maksim committed
521
    Enum.reduce(object_tags, [], fn tag, tags ->
522
      tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
Maksim's avatar
Maksim committed
523
    end)
524
525
  end

Maksim's avatar
Maksim committed
526
  def build_tags(_), do: []
kaniini's avatar
kaniini committed
527

Maksim's avatar
Maksim committed
528
529
  @doc """
  Builds list emojis.
530

Maksim's avatar
Maksim committed
531
532
533
534
535
  Arguments: `nil` or list tuple of name and url.

  Returns list emojis.

  ## Examples
536

Maksim's avatar
Maksim committed
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
  iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
  [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]

  """
  @spec build_emojis(nil | list(tuple())) :: list(map())
  def build_emojis(nil), do: []

  def build_emojis(emojis) do
    emojis
    |> Enum.map(fn {name, url} ->
      name = HTML.strip_tags(name)

      url =
        url
        |> HTML.strip_tags()
        |> MediaProxy.url()

      %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
    end)
556
557
  end

Maksim's avatar
Maksim committed
558
559
560
  defp present?(nil), do: false
  defp present?(false), do: false
  defp present?(_), do: true
561

562
  defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
563
    do: id in pinned_activities
lain's avatar
lain committed
564
end