Quote posting (#113)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Mon, 25 Jul 2022 16:30:06 +0000 (16:30 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Mon, 25 Jul 2022 16:30:06 +0000 (16:30 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/113

27 files changed:
CHANGELOG.md
config/config.exs
docs/docs/administration/CLI_tasks/user.md
lib/pleroma/activity.ex
lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
lib/pleroma/web/activity_pub/object_validators/common_fields.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/api_spec/operations/status_operation.ex
lib/pleroma/web/api_spec/schemas/status.ex
lib/pleroma/web/common_api.ex
lib/pleroma/web/common_api/activity_draft.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
priv/static/schemas/litepub-0.1.jsonld
test/fixtures/fedibird/quote.json [new file with mode: 0644]
test/fixtures/misskey/quote.json [new file with mode: 0644]
test/fixtures/quote_post/fedibird_quote_post.json [new file with mode: 0644]
test/fixtures/quote_post/fedibird_quote_uri.json [new file with mode: 0644]
test/fixtures/quote_post/misskey_quote_post.json [new file with mode: 0644]
test/fixtures/quoted_status.json [new file with mode: 0644]
test/pleroma/web/activity_pub/builder_test.exs
test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs [new file with mode: 0644]
test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs
test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/views/status_view_test.exs

index d6dd4dd4ccb4a022e6385643570544d871030cb7..4982a319130abccdf7a8a73d8c0f286ba05acacc 100644 (file)
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ### Added
 - extended runtime module support, see config cheatsheet
+- quote posting; quotes are limited to public posts
 
 ### Fixed
 - Updated mastoFE path, for the newer version
index cfe207dcc8f7b0feea00f6cfb37400ec31caca64..bac167c29af4dbd02c3bb008ff58318e95a8965a 100644 (file)
@@ -407,6 +407,8 @@ config :pleroma, :mrf_vocabulary,
   accept: [],
   reject: []
 
+config :pleroma, :mrf_inline_quote, prefix: "RE"
+
 # threshold of 7 days
 config :pleroma, :mrf_object_age,
   threshold: 604_800,
index 0d19b5622e229722679468cae2e41d8e5b1bac91..b7a60751df14d90f106c10aaa26d022313147c5a 100644 (file)
     ```sh
     mix pleroma.user unconfirm_all
     ```
+
+## Fix following state
+
+Sometimes the system can get into a situation where
+it think you're already following someone and won't send a request
+to the remote instance, or won't let you unfollow someone. This
+bug was fixed, but in case you encounter these weird states:
+
+=== "OTP"
+
+    ```sh
+     ./bin/pleroma_ctl user fix_follow_state localuser remoteuser@example.com
+    ```
+
+=== "From Source"
+
+    ```sh
+    mix pleroma.user fix_follow_state localuser remoteuser@example.com
+    ```
+
+The first argument is the local user's nickname - if you are `myuser@myinstance`, this should be `myuser`.
+
+The second is the remote user, consisting of both nickname AND domain.
+
+If you are a weird follow state situation and cannot resolve it with the above, you may need to co-operate with the remote admin to clear the state their side too - they should provide the arguments *backwards*, i.e `fix_follow_state remote local`.
index abfe778d24c0498358e02288570fcccd112131aa..01c9df53b6fde56252154999ae7ebf5667219a89 100644 (file)
@@ -292,6 +292,12 @@ defmodule Pleroma.Activity do
     get_in_reply_to_activity_from_object(Object.normalize(activity, fetch: false))
   end
 
+  def get_quoted_activity_from_object(%Object{data: %{"quoteUri" => ap_id}}) do
+    get_create_by_object_ap_id_with_object(ap_id)
+  end
+
+  def get_quoted_activity_from_object(_), do: nil
+
   def normalize(%Activity{data: %{"id" => ap_id}}), do: get_by_ap_id_with_object(ap_id)
   def normalize(%{"id" => ap_id}), do: get_by_ap_id_with_object(ap_id)
   def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id)
index 8c51b8d6330e0fd5ea678bf5bbba4041096c0656..97ceaf08ecfa8ec008d753948bd0a502903162e4 100644 (file)
@@ -168,6 +168,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do
         "tag" => Keyword.values(draft.tags) |> Enum.uniq()
       }
       |> add_in_reply_to(draft.in_reply_to)
+      |> add_quote(draft.quote)
       |> Map.merge(draft.extra)
 
     {:ok, data, []}
@@ -183,6 +184,16 @@ defmodule Pleroma.Web.ActivityPub.Builder do
     end
   end
 
+  defp add_quote(object, nil), do: object
+
+  defp add_quote(object, quote) do
+    with %Object{} = quote_object <- Object.normalize(quote, fetch: false) do
+      Map.put(object, "quoteUri", quote_object.data["id"])
+    else
+      _ -> object
+    end
+  end
+
   def answer(user, object, name) do
     {:ok,
      %{
diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex
new file mode 100644 (file)
index 0000000..2043241
--- /dev/null
@@ -0,0 +1,71 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do
+  @moduledoc "Force a quote line into the message content."
+  @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+  defp build_inline_quote(prefix, url) do
+    "<span class=\"quote-inline\"><br/><br/>#{prefix}: <a href=\"#{url}\">#{url}</a></span>"
+  end
+
+  defp has_inline_quote?(content, quote_url) do
+    cond do
+      # Does the quote URL exist in the content?
+      content =~ quote_url -> true
+      # Does the content already have a .quote-inline span?
+      content =~ "<span class=\"quote-inline\">" -> true
+      # No inline quote found
+      true -> false
+    end
+  end
+
+  defp filter_object(%{"quoteUri" => quote_url} = object) do
+    content = object["content"] || ""
+
+    if has_inline_quote?(content, quote_url) do
+      object
+    else
+      prefix = Pleroma.Config.get([:mrf_inline_quote, :prefix])
+
+      content =
+        if String.ends_with?(content, "</p>") do
+          String.trim_trailing(content, "</p>") <> build_inline_quote(prefix, quote_url) <> "</p>"
+        else
+          content <> build_inline_quote(prefix, quote_url)
+        end
+
+      Map.put(object, "content", content)
+    end
+  end
+
+  @impl true
+  def filter(%{"object" => %{"quoteUri" => _} = object} = activity) do
+    {:ok, Map.put(activity, "object", filter_object(object))}
+  end
+
+  @impl true
+  def filter(object), do: {:ok, object}
+
+  @impl true
+  def describe, do: {:ok, %{}}
+
+  @impl true
+  def config_description do
+    %{
+      key: :mrf_inline_quote,
+      related_policy: "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy",
+      label: "MRF Inline Quote",
+      description: "Force quote post URLs inline",
+      children: [
+        %{
+          key: :prefix,
+          type: :string,
+          description: "Prefix before the link",
+          suggestions: ["RE", "QT", "RT", "RN"]
+        }
+      ]
+    }
+  end
+end
index 5e377c2946d7faa10eefb875c288fe071bdc5028..a0724ca55427d9f6ca0ef7dd0c4a4dab22b1ce6c 100644 (file)
@@ -156,6 +156,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
     |> fix_replies()
     |> fix_source()
     |> fix_misskey_content()
+    |> Transmogrifier.fix_quote_url()
     |> Transmogrifier.fix_attachments()
     |> Transmogrifier.fix_emoji()
     |> Transmogrifier.fix_content_map()
index 37ec860dcfbae67d298e9559765e9b17fbd67161..1eaf572b98e2506b61825fc0e1a81267152b6889 100644 (file)
@@ -59,6 +59,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do
       field(:like_count, :integer, default: 0)
       field(:announcement_count, :integer, default: 0)
       field(:inReplyTo, ObjectValidators.ObjectID)
+      field(:quoteUri, ObjectValidators.ObjectID)
       field(:url, ObjectValidators.Uri)
 
       field(:likes, {:array, ObjectValidators.ObjectID}, default: [])
index 115dfc470c37f5447cd87cc353d11fbbea65435a..b6ee24ee613bf479a53f73c1d9096e90f05cfe3b 100644 (file)
@@ -598,6 +598,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
 
   def set_reply_to_uri(obj), do: obj
 
+  def set_quote_url(%{"quoteUri" => quote} = object) when is_binary(quote) do
+    Map.put(object, "quoteUrl", quote)
+  end
+
+  def set_quote_url(obj), do: obj
+
   @doc """
   Serialized Mastodon-compatible `replies` collection containing _self-replies_.
   Based on Mastodon's ActivityPub::NoteSerializer#replies.
@@ -652,6 +658,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> prepare_attachments
     |> set_conversation
     |> set_reply_to_uri
+    |> set_quote_url()
     |> set_replies
     |> strip_internal_fields
     |> strip_internal_tags
@@ -879,6 +886,43 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
 
   defp strip_internal_tags(object), do: object
 
+  def fix_quote_url(object, options \\ [])
+
+  def fix_quote_url(%{"quoteUri" => quote_url} = object, options)
+      when not is_nil(quote_url) do
+    with {:ok, quoted_object} <- get_obj_helper(quote_url, options),
+         %Activity{} <- Activity.get_create_by_object_ap_id(quoted_object.data["id"]) do
+      Map.put(object, "quoteUri", quoted_object.data["id"])
+    else
+      e ->
+        Logger.warn("Couldn't fetch #{inspect(quote_url)}, error: #{inspect(e)}")
+        object
+    end
+  end
+
+  # Soapbox
+  def fix_quote_url(%{"quoteUrl" => quote_url} = object, options) do
+    object
+    |> Map.put("quoteUri", quote_url)
+    |> fix_quote_url(options)
+  end
+
+  # Old Fedibird (bug)
+  # https://github.com/fedibird/mastodon/issues/9
+  def fix_quote_url(%{"quoteURL" => quote_url} = object, options) do
+    object
+    |> Map.put("quoteUri", quote_url)
+    |> fix_quote_url(options)
+  end
+
+  def fix_quote_url(%{"_misskey_quote" => quote_url} = object, options) do
+    object
+    |> Map.put("quoteUri", quote_url)
+    |> fix_quote_url(options)
+  end
+
+  def fix_quote_url(object, _), do: object
+
   def perform(:user_upgrade, user) do
     # we pass a fake user so that the followers collection is stripped away
     old_follower_address = User.ap_followers(%User{nickname: user.nickname})
index c7df676c34f846a4f7b35e952b864c295cf4168d..a5da8b58e6a78c47e3ff18249d63c7a00c8f94e3 100644 (file)
@@ -496,6 +496,11 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
           type: :string,
           description:
             "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`."
+        },
+        quote_id: %Schema{
+          nullable: true,
+          type: :string,
+          description: "Will quote a given status."
         }
       },
       example: %{
index 3caab0f00dd7f723144e73d89d092569b30a115b..60db8ad6fa60324a050213e7edb756be01973ee7 100644 (file)
@@ -133,6 +133,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
         type: :boolean,
         description: "Have you pinned this status? Only appears if the status is pinnable."
       },
+      quote_id: %Schema{
+        type: :string,
+        description: "ID of the status being quoted",
+        nullable: true
+      },
+      quote: %Schema{
+        allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}],
+        nullable: true,
+        description: "Quoted status (if any)"
+      },
       pleroma: %Schema{
         type: :object,
         properties: %{
@@ -204,6 +214,33 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
           }
         }
       },
+      akkoma: %Schema{
+        type: :object,
+        properties: %{
+          source: %Schema{
+            nullable: true,
+            oneOf: [
+              %Schema{type: :string, example: 'plaintext content'},
+              %Schema{
+                type: :object,
+                properties: %{
+                  content: %Schema{
+                    type: :string,
+                    description: "The source content of the status",
+                    nullable: true
+                  },
+                  mediaType: %Schema{
+                    type: :string,
+                    description: "The source MIME type of the status",
+                    example: "text/plain",
+                    nullable: true
+                  }
+                }
+              }
+            ]
+          }
+        }
+      },
       poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"},
       reblog: %Schema{
         allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}],
index 8ab50cf2bd3fdf2b22b53a58cc3a0960c6aed1ec..bc5e26cf7c68f568734b7b79a48297695c5c58a8 100644 (file)
@@ -319,6 +319,10 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
+  def get_quoted_visibility(nil), do: nil
+
+  def get_quoted_visibility(activity), do: get_replied_to_visibility(activity)
+
   def check_expiry_date({:ok, nil} = res), do: res
 
   def check_expiry_date({:ok, in_seconds}) do
index ea88213fbdae41c28149857b92c2846bb4d088ef..767b2bf0feaeb5d75b0bb972122315ce898052cd 100644 (file)
@@ -22,6 +22,8 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
             attachments: [],
             in_reply_to: nil,
             in_reply_to_conversation: nil,
+            quote_id: nil,
+            quote: nil,
             visibility: nil,
             expires_at: nil,
             extra: nil,
@@ -54,6 +56,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
     |> with_valid(&in_reply_to/1)
     |> with_valid(&in_reply_to_conversation/1)
     |> with_valid(&visibility/1)
+    |> with_valid(&quote_id/1)
     |> content()
     |> with_valid(&to_and_cc/1)
     |> with_valid(&context/1)
@@ -108,6 +111,28 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
     %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation}
   end
 
+  defp quote_id(%{params: %{quote_id: ""}} = draft), do: draft
+
+  defp quote_id(%{params: %{quote_id: id}} = draft) when is_binary(id) do
+    with {:activity, %Activity{} = quote} <- {:activity, Activity.get_by_id(id)},
+         visibility <- CommonAPI.get_quoted_visibility(quote),
+         {:visibility, true} <- {:visibility, visibility in ["public", "unlisted"]} do
+      %__MODULE__{draft | quote: Activity.get_by_id(id)}
+    else
+      {:activity, _} ->
+        add_error(draft, dgettext("errors", "You can't quote a status that doesn't exist"))
+
+      {:visibility, false} ->
+        add_error(draft, dgettext("errors", "You can only quote public or unlisted statuses"))
+    end
+  end
+
+  defp quote_id(%{params: %{quote_id: %Activity{} = quote}} = draft) do
+    %__MODULE__{draft | quote: quote}
+  end
+
+  defp quote_id(draft), do: draft
+
   defp visibility(%{params: params} = draft) do
     case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do
       {visibility, "direct"} when visibility != "direct" ->
index c0f467592d4cb02aaf552bb699924c7906055c9a..cf4ea51e0dd3ec9f382796ab644eaffb4d459d91 100644 (file)
@@ -329,6 +329,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
     {pinned?, pinned_at} = pin_data(object, user)
 
+    quote = Activity.get_quoted_activity_from_object(object)
+
     %{
       id: to_string(activity.id),
       uri: object.data["id"],
@@ -363,6 +365,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       application: build_application(object.data["generator"]),
       language: nil,
       emojis: build_emojis(object.data["emoji"]),
+      quote_id: if(quote, do: quote.id, else: nil),
+      quote: maybe_render_quote(quote, opts),
       pleroma: %{
         local: activity.local,
         conversation_id: get_context_id(activity),
@@ -604,4 +608,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
   end
 
   defp build_image_url(_, _), do: nil
+
+  defp maybe_render_quote(nil, _), do: nil
+
+  defp maybe_render_quote(quote, opts) do
+    if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
+      nil
+    else
+      opts =
+        opts
+        |> Map.put(:activity, quote)
+        |> Map.put(:do_not_recurse, true)
+
+      render("show.json", opts)
+    end
+  end
 end
index e7722cf726fe2a3b0b3fd3236c04e80808252c38..d2b62ba775967532a781a749e8fbfb385a7ebb8a 100644 (file)
@@ -17,6 +17,8 @@
             "ostatus": "http://ostatus.org#",
             "schema": "http://schema.org#",
             "toot": "http://joinmastodon.org/ns#",
+            "misskey": "https://misskey-hub.net/ns#",
+            "fedibird": "http://fedibird.com/ns#",
             "value": "schema:value",
             "sensitive": "as:sensitive",
             "litepub": "http://litepub.social/ns#",
@@ -26,6 +28,8 @@
                 "@id": "litepub:listMessage",
                 "@type": "@id"
             },
+            "quoteUrl": "as:quoteUrl",
+            "quoteUri": "fedibird:quoteUri",
             "oauthRegistrationEndpoint": {
                 "@id": "litepub:oauthRegistrationEndpoint",
                 "@type": "@id"
diff --git a/test/fixtures/fedibird/quote.json b/test/fixtures/fedibird/quote.json
new file mode 100644 (file)
index 0000000..a43cc31
--- /dev/null
@@ -0,0 +1,73 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    {
+      "ostatus": "http://ostatus.org#",
+      "atomUri": "ostatus:atomUri",
+      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+      "conversation": "ostatus:conversation",
+      "sensitive": "as:sensitive",
+      "toot": "http://joinmastodon.org/ns#",
+      "votersCount": "toot:votersCount",
+      "fedibird": "http://fedibird.com/ns#",
+      "quoteUri": "fedibird:quoteUri",
+      "expiry": "fedibird:expiry",
+      "references": {
+        "@id": "fedibird:references",
+        "@type": "@id"
+      },
+      "emojiReactions": {
+        "@id": "fedibird:emojiReactions",
+        "@type": "@id"
+      }
+    }
+  ],
+  "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674",
+  "type": "Note",
+  "summary": null,
+  "inReplyTo": null,
+  "published": "2022-07-25T11:12:26Z",
+  "url": "https://fedibird.com/@akkoma_ap_integration_tester/108707679228362674",
+  "attributedTo": "https://fedibird.com/users/akkoma_ap_integration_tester",
+  "to": [
+    "https://fedibird.com/users/akkoma_ap_integration_tester/followers"
+  ],
+  "cc": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "sensitive": false,
+  "atomUri": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674",
+  "inReplyToAtomUri": null,
+  "conversation": "tag:fedibird.com,2022-07-25:objectId=108707679228389900:objectType=Conversation",
+  "context": "https://fedibird.com/contexts/108707679228389900",
+  "quoteUri": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924",
+  "_misskey_quote": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924",
+  "_misskey_content": "public quote",
+  "content": "<p>public quote<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"ArtMirror@example.com\" data-status-id=\"108703793483919195\" href=\"https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.com/objects/24d9f</span><span class=\"invisible\">2e1-32d2-4bd5-bdf2-8ea61d3fb5e8</span></a></span><span class=\"reference-link-inline\"> <a href=\"https://fedibird.com/@akkoma_ap_integration_tester/108707679228362674/references\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"status-link unhandled-link\" data-status-id=\"108707679228362674\">[参照]</a></span></p>",
+  "contentMap": {
+    "ja": "<p>public quote<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"ArtMirror@example.com\" data-status-id=\"108703793483919195\" href=\"https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.com/objects/24d9f</span><span class=\"invisible\">2e1-32d2-4bd5-bdf2-8ea61d3fb5e8</span></a></span><span class=\"reference-link-inline\"> <a href=\"https://fedibird.com/@akkoma_ap_integration_tester/108707679228362674/references\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"status-link unhandled-link\" data-status-id=\"108707679228362674\">[参照]</a></span></p>"
+  },
+  "attachment": [],
+  "tag": [],
+  "replies": {
+    "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies",
+    "type": "Collection",
+    "first": {
+      "type": "CollectionPage",
+      "next": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies?only_other_accounts=true&page=true",
+      "partOf": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies",
+      "items": []
+    }
+  },
+  "references": {
+    "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/references",
+    "type": "Collection",
+    "first": {
+      "type": "CollectionPage",
+      "partOf": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/references",
+      "items": [
+        "https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8"
+      ]
+    }
+  }
+}
diff --git a/test/fixtures/misskey/quote.json b/test/fixtures/misskey/quote.json
new file mode 100644 (file)
index 0000000..4874610
--- /dev/null
@@ -0,0 +1,50 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    "https://w3id.org/security/v1",
+    {
+      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+      "sensitive": "as:sensitive",
+      "Hashtag": "as:Hashtag",
+      "quoteUrl": "as:quoteUrl",
+      "toot": "http://joinmastodon.org/ns#",
+      "Emoji": "toot:Emoji",
+      "featured": "toot:featured",
+      "discoverable": "toot:discoverable",
+      "schema": "http://schema.org#",
+      "PropertyValue": "schema:PropertyValue",
+      "value": "schema:value",
+      "misskey": "https://misskey-hub.net/ns#",
+      "_misskey_content": "misskey:_misskey_content",
+      "_misskey_quote": "misskey:_misskey_quote",
+      "_misskey_reaction": "misskey:_misskey_reaction",
+      "_misskey_votes": "misskey:_misskey_votes",
+      "_misskey_talk": "misskey:_misskey_talk",
+      "isCat": "misskey:isCat",
+      "vcard": "http://www.w3.org/2006/vcard/ns#"
+    }
+  ],
+  "id": "https://misskey.io/notes/934gok3482",
+  "type": "Note",
+  "attributedTo": "https://misskey.io/users/93492q0ip0",
+  "summary": null,
+  "content": "<p><span>i quompt u<br><br>RE: </span><a href=\"https://example.com/objects/30c543fb-a165-40dd-87fd-4e249ec5a40b\">https://example.com/objects/30c543fb-a165-40dd-87fd-4e249ec5a40b</a></p>",
+  "_misskey_content": "i quompt u",
+  "source": {
+    "content": "i quompt u",
+    "mediaType": "text/x.misskeymarkdown"
+  },
+  "_misskey_quote": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924",
+  "quoteUrl": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924",
+  "published": "2022-07-25T15:21:48.208Z",
+  "to": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "cc": [
+    "https://misskey.io/users/93492q0ip0/followers"
+  ],
+  "inReplyTo": null,
+  "attachment": [],
+  "sensitive": false,
+  "tag": []
+}
diff --git a/test/fixtures/quote_post/fedibird_quote_post.json b/test/fixtures/quote_post/fedibird_quote_post.json
new file mode 100644 (file)
index 0000000..ebf3833
--- /dev/null
@@ -0,0 +1,52 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    {
+      "ostatus": "http://ostatus.org#",
+      "atomUri": "ostatus:atomUri",
+      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+      "conversation": "ostatus:conversation",
+      "sensitive": "as:sensitive",
+      "toot": "http://joinmastodon.org/ns#",
+      "votersCount": "toot:votersCount",
+      "expiry": "toot:expiry"
+    }
+  ],
+  "id": "https://fedibird.com/users/noellabo/statuses/107663670404015196",
+  "type": "Note",
+  "summary": null,
+  "inReplyTo": null,
+  "published": "2022-01-22T02:07:16Z",
+  "url": "https://fedibird.com/@noellabo/107663670404015196",
+  "attributedTo": "https://fedibird.com/users/noellabo",
+  "to": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "cc": [
+    "https://fedibird.com/users/noellabo/followers"
+  ],
+  "sensitive": false,
+  "atomUri": "https://fedibird.com/users/noellabo/statuses/107663670404015196",
+  "inReplyToAtomUri": null,
+  "conversation": "tag:fedibird.com,2022-01-22:objectId=107663670404038002:objectType=Conversation",
+  "context": "https://fedibird.com/contexts/107663670404038002",
+  "quoteURL": "https://misskey.io/notes/8vsn2izjwh",
+  "_misskey_quote": "https://misskey.io/notes/8vsn2izjwh",
+  "_misskey_content": "いつの生まれだシトリン",
+  "content": "<p>いつの生まれだシトリン<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"Citrine@misskey.io\" data-status-id=\"107663207194225003\" href=\"https://misskey.io/notes/8vsn2izjwh\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">misskey.io/notes/8vsn2izjwh</span><span class=\"invisible\"></span></a></span></p>",
+  "contentMap": {
+    "ja": "<p>いつの生まれだシトリン<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"Citrine@misskey.io\" data-status-id=\"107663207194225003\" href=\"https://misskey.io/notes/8vsn2izjwh\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">misskey.io/notes/8vsn2izjwh</span><span class=\"invisible\"></span></a></span></p>"
+  },
+  "attachment": [],
+  "tag": [],
+  "replies": {
+    "id": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies",
+    "type": "Collection",
+    "first": {
+      "type": "CollectionPage",
+      "next": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies?only_other_accounts=true&page=true",
+      "partOf": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies",
+      "items": []
+    }
+  }
+}
diff --git a/test/fixtures/quote_post/fedibird_quote_uri.json b/test/fixtures/quote_post/fedibird_quote_uri.json
new file mode 100644 (file)
index 0000000..7c328fd
--- /dev/null
@@ -0,0 +1,54 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    {
+      "ostatus": "http://ostatus.org#",
+      "atomUri": "ostatus:atomUri",
+      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+      "conversation": "ostatus:conversation",
+      "sensitive": "as:sensitive",
+      "toot": "http://joinmastodon.org/ns#",
+      "votersCount": "toot:votersCount",
+      "fedibird": "http://fedibird.com/ns#",
+      "quoteUri": "fedibird:quoteUri",
+      "expiry": "fedibird:expiry"
+    }
+  ],
+  "id": "https://fedibird.com/users/noellabo/statuses/107699335988346142",
+  "type": "Note",
+  "summary": null,
+  "inReplyTo": null,
+  "published": "2022-01-28T09:17:30Z",
+  "url": "https://fedibird.com/@noellabo/107699335988346142",
+  "attributedTo": "https://fedibird.com/users/noellabo",
+  "to": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "cc": [
+    "https://fedibird.com/users/noellabo/followers"
+  ],
+  "sensitive": false,
+  "atomUri": "https://fedibird.com/users/noellabo/statuses/107699335988346142",
+  "inReplyToAtomUri": null,
+  "conversation": "tag:fedibird.com,2022-01-28:objectId=107699335988345290:objectType=Conversation",
+  "context": "https://fedibird.com/contexts/107699335988345290",
+  "quoteUri": "https://fedibird.com/users/yamako/statuses/107699333438289729",
+  "_misskey_quote": "https://fedibird.com/users/yamako/statuses/107699333438289729",
+  "_misskey_content": "美味しそう",
+  "content": "<p>美味しそう<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"yamako\" data-status-id=\"107699333438289729\" href=\"https://fedibird.com/@yamako/107699333438289729\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">fedibird.com/@yamako/107699333</span><span class=\"invisible\">438289729</span></a></span></p>",
+  "contentMap": {
+    "ja": "<p>美味しそう<span class=\"quote-inline\"><br/>QT: <a class=\"status-url-link\" data-status-account-acct=\"yamako\" data-status-id=\"107699333438289729\" href=\"https://fedibird.com/@yamako/107699333438289729\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">fedibird.com/@yamako/107699333</span><span class=\"invisible\">438289729</span></a></span></p>"
+  },
+  "attachment": [],
+  "tag": [],
+  "replies": {
+    "id": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies",
+    "type": "Collection",
+    "first": {
+      "type": "CollectionPage",
+      "next": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies?only_other_accounts=true&page=true",
+      "partOf": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies",
+      "items": []
+    }
+  }
+}
diff --git a/test/fixtures/quote_post/misskey_quote_post.json b/test/fixtures/quote_post/misskey_quote_post.json
new file mode 100644 (file)
index 0000000..59f677c
--- /dev/null
@@ -0,0 +1,46 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    "https://w3id.org/security/v1",
+    {
+      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+      "sensitive": "as:sensitive",
+      "Hashtag": "as:Hashtag",
+      "quoteUrl": "as:quoteUrl",
+      "toot": "http://joinmastodon.org/ns#",
+      "Emoji": "toot:Emoji",
+      "featured": "toot:featured",
+      "discoverable": "toot:discoverable",
+      "schema": "http://schema.org#",
+      "PropertyValue": "schema:PropertyValue",
+      "value": "schema:value",
+      "misskey": "https://misskey.io/ns#",
+      "_misskey_content": "misskey:_misskey_content",
+      "_misskey_quote": "misskey:_misskey_quote",
+      "_misskey_reaction": "misskey:_misskey_reaction",
+      "_misskey_votes": "misskey:_misskey_votes",
+      "_misskey_talk": "misskey:_misskey_talk",
+      "isCat": "misskey:isCat",
+      "vcard": "http://www.w3.org/2006/vcard/ns#"
+    }
+  ],
+  "id": "https://misskey.io/notes/8vs6ylpfez",
+  "type": "Note",
+  "attributedTo": "https://misskey.io/users/7rkrarq81i",
+  "summary": null,
+  "content": "<p><span>投稿者の設定によるね<br>Fanboxについても投稿者によっては過去の投稿は高額なプランに移動してることがある<br><br>RE: </span><a href=\"https://misskey.io/notes/8vs6wxufd0\">https://misskey.io/notes/8vs6wxufd0</a></p>",
+  "_misskey_content": "投稿者の設定によるね\nFanboxについても投稿者によっては過去の投稿は高額なプランに移動してることがある",
+  "_misskey_quote": "https://misskey.io/notes/8vs6wxufd0",
+  "quoteUrl": "https://misskey.io/notes/8vs6wxufd0",
+  "published": "2022-01-21T16:38:30.243Z",
+  "to": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "cc": [
+    "https://misskey.io/users/7rkrarq81i/followers"
+  ],
+  "inReplyTo": null,
+  "attachment": [],
+  "sensitive": false,
+  "tag": []
+}
diff --git a/test/fixtures/quoted_status.json b/test/fixtures/quoted_status.json
new file mode 100644 (file)
index 0000000..5030d1a
--- /dev/null
@@ -0,0 +1,38 @@
+{
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    "https://example.com/schemas/litepub-0.1.jsonld",
+    {
+      "@language": "und"
+    }
+  ],
+  "actor": "https://example.com/users/user",
+  "attachment": [
+    {
+      "mediaType": "image/png",
+      "name": "",
+      "type": "Document",
+      "url": "https://example.com/media/4d6097ae20200ac371f51d24eae0a94cb4b424b6aff81dcc0f7411b1a74c796f.png"
+    }
+  ],
+  "attributedTo": "https://example.com/users/user",
+  "cc": [
+    "https://example.com/users/user/followers"
+  ],
+  "content": "",
+  "context": "https://example.com/contexts/c2c52511-977e-4168-996c-bcf006789dca",
+  "conversation": "https://example.com/contexts/c2c52511-977e-4168-996c-bcf006789dca",
+  "id": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924",
+  "published": "2022-07-24T17:25:51.614495Z",
+  "sensitive": null,
+  "source": {
+    "content": "",
+    "mediaType": "text/plain"
+  },
+  "summary": "",
+  "tag": [],
+  "to": [
+    "https://www.w3.org/ns/activitystreams#Public"
+  ],
+  "type": "Note"
+}
index 3fe32bce5d24e3852500d1ec2257e4269d3f5d86..640caa2b64c71899fd9cb099abf43c7d6548fd5f 100644 (file)
@@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.BuilderTest do
     test "returns note data" do
       user = insert(:user)
       note = insert(:note)
+      quote = insert(:note)
       user2 = insert(:user)
       user3 = insert(:user)
 
@@ -25,7 +26,8 @@ defmodule Pleroma.Web.ActivityPub.BuilderTest do
         tags: [name: "jimm"],
         summary: "test summary",
         cc: [user3.ap_id],
-        extra: %{"custom_tag" => "test"}
+        extra: %{"custom_tag" => "test"},
+        quote: quote
       }
 
       expected = %{
@@ -39,7 +41,8 @@ defmodule Pleroma.Web.ActivityPub.BuilderTest do
         "tag" => ["jimm"],
         "to" => [user2.ap_id],
         "type" => "Note",
-        "custom_tag" => "test"
+        "custom_tag" => "test",
+        "quoteUri" => quote.data["id"]
       }
 
       assert {:ok, ^expected, []} = Builder.note(draft)
diff --git a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs
new file mode 100644 (file)
index 0000000..4e0910d
--- /dev/null
@@ -0,0 +1,56 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicyTest do
+  alias Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy
+  use Pleroma.DataCase
+
+  test "adds quote URL to post content" do
+    quote_url = "https://example.com/objects/1234"
+
+    activity = %{
+      "type" => "Create",
+      "actor" => "https://example.com/users/alex",
+      "object" => %{
+        "type" => "Note",
+        "content" => "<p>Nice post</p>",
+        "quoteUri" => quote_url
+      }
+    }
+
+    {:ok, %{"object" => %{"content" => filtered}}} = InlineQuotePolicy.filter(activity)
+
+    assert filtered ==
+             "<p>Nice post<span class=\"quote-inline\"><br/><br/>RE: <a href=\"https://example.com/objects/1234\">https://example.com/objects/1234</a></span></p>"
+  end
+
+  test "ignores Misskey quote posts" do
+    object = File.read!("test/fixtures/quote_post/misskey_quote_post.json") |> Jason.decode!()
+
+    activity = %{
+      "type" => "Create",
+      "actor" => "https://misskey.io/users/7rkrarq81i",
+      "object" => object
+    }
+
+    {:ok, filtered} = InlineQuotePolicy.filter(activity)
+    assert filtered == activity
+  end
+
+  test "ignores Fedibird quote posts" do
+    object = File.read!("test/fixtures/quote_post/fedibird_quote_post.json") |> Jason.decode!()
+
+    # Normally the ObjectValidator will fix this before it reaches MRF
+    object = Map.put(object, "quoteUrl", object["quoteURL"])
+
+    activity = %{
+      "type" => "Create",
+      "actor" => "https://fedibird.com/users/noellabo",
+      "object" => object
+    }
+
+    {:ok, filtered} = InlineQuotePolicy.filter(activity)
+    assert filtered == activity
+  end
+end
index 8b39829168bbd06de32a45dab570aeb5ec0807d6..80290a6e315bd312d17fd1b9411a74bb840c8d1e 100644 (file)
@@ -143,5 +143,61 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
         }
       } = ArticleNotePageValidator.cast_and_validate(note)
     end
+
+    test "a misskey quote should work", _ do
+      Tesla.Mock.mock(fn %{
+                           method: :get,
+                           url: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924"
+                         } ->
+        %Tesla.Env{
+          status: 200,
+          body: File.read!("test/fixtures/quoted_status.json"),
+          headers: HttpRequestMock.activitypub_object_headers()
+        }
+      end)
+
+      insert(:user, %{ap_id: "https://misskey.io/users/93492q0ip0"})
+      insert(:user, %{ap_id: "https://example.com/users/user"})
+
+      note =
+        "test/fixtures/misskey/quote.json"
+        |> File.read!()
+        |> Jason.decode!()
+
+      %{
+        valid?: true,
+        changes: %{
+          quoteUri: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924"
+        }
+      } = ArticleNotePageValidator.cast_and_validate(note)
+    end
+
+    test "a fedibird quote should work", _ do
+      Tesla.Mock.mock(fn %{
+                           method: :get,
+                           url: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924"
+                         } ->
+        %Tesla.Env{
+          status: 200,
+          body: File.read!("test/fixtures/quoted_status.json"),
+          headers: HttpRequestMock.activitypub_object_headers()
+        }
+      end)
+
+      insert(:user, %{ap_id: "https://fedibird.com/users/akkoma_ap_integration_tester"})
+      insert(:user, %{ap_id: "https://example.com/users/user"})
+
+      note =
+        "test/fixtures/fedibird/quote.json"
+        |> File.read!()
+        |> Jason.decode!()
+
+      %{
+        valid?: true,
+        changes: %{
+          quoteUri: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924"
+        }
+      } = ArticleNotePageValidator.cast_and_validate(note)
+    end
   end
 end
index 98ab9e71768d658166ef44b1b84f656a52923437..90aa9398f9aaebe0f81a954f71185002fcd62531 100644 (file)
@@ -193,10 +193,14 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do
 
       assert response["irreversible"] == true
 
-      assert response["expires_at"] ==
-               NaiveDateTime.utc_now()
-               |> NaiveDateTime.add(in_seconds)
-               |> Pleroma.Web.CommonAPI.Utils.to_masto_date()
+      expected_time =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(in_seconds)
+
+      assert NaiveDateTime.diff(
+               NaiveDateTime.from_iso8601!(response["expires_at"]),
+               expected_time
+             ) < 5
 
       filter = Filter.get(response["id"], user)
 
index c9f3f66beaf2a77e7a4b959843ce82c1edd3a9eb..b0efddb2a3d542fddab7d7140558124fd93c2245 100644 (file)
@@ -1944,4 +1944,102 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
              } = result
     end
   end
+
+  describe "posting quotes" do
+    setup do: oauth_access(["write:statuses"])
+
+    test "posting a quote", %{conn: conn} do
+      user = insert(:user)
+      {:ok, quoted_status} = CommonAPI.post(user, %{status: "tell me, for whom do you fight?"})
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/statuses", %{
+          "status" => "Hmph, how very glib",
+          "quote_id" => quoted_status.id
+        })
+
+      response = json_response_and_validate_schema(conn, 200)
+
+      assert response["quote_id"] == quoted_status.id
+      assert response["quote"]["id"] == quoted_status.id
+      assert response["quote"]["content"] == quoted_status.object.data["content"]
+    end
+
+    test "posting a quote, quoting a status that isn't public", %{conn: conn} do
+      user = insert(:user)
+
+      Enum.each(["private", "local", "direct"], fn visibility ->
+        {:ok, quoted_status} =
+          CommonAPI.post(user, %{
+            status: "tell me, for whom do you fight?",
+            visibility: visibility
+          })
+
+        assert %{"error" => "You can only quote public or unlisted statuses"} =
+                 conn
+                 |> put_req_header("content-type", "application/json")
+                 |> post("/api/v1/statuses", %{
+                   "status" => "Hmph, how very glib",
+                   "quote_id" => quoted_status.id
+                 })
+                 |> json_response_and_validate_schema(422)
+      end)
+    end
+
+    test "posting a quote, after quote, the status gets deleted", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, quoted_status} =
+        CommonAPI.post(user, %{status: "tell me, for whom do you fight?", visibility: "public"})
+
+      resp =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/statuses", %{
+          "status" => "I fight for eorzea!",
+          "quote_id" => quoted_status.id
+        })
+        |> json_response_and_validate_schema(200)
+
+      {:ok, _} = CommonAPI.delete(quoted_status.id, user)
+
+      resp =
+        conn
+        |> get("/api/v1/statuses/#{resp["id"]}")
+        |> json_response_and_validate_schema(200)
+
+      assert is_nil(resp["quote"])
+    end
+
+    test "posting a quote of a deleted status", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, quoted_status} =
+        CommonAPI.post(user, %{status: "tell me, for whom do you fight?", visibility: "public"})
+
+      {:ok, _} = CommonAPI.delete(quoted_status.id, user)
+
+      assert %{"error" => _} =
+               conn
+               |> put_req_header("content-type", "application/json")
+               |> post("/api/v1/statuses", %{
+                 "status" => "I fight for eorzea!",
+                 "quote_id" => quoted_status.id
+               })
+               |> json_response_and_validate_schema(422)
+    end
+
+    test "posting a quote of a status that doesn't exist", %{conn: conn} do
+      assert %{"error" => "You can't quote a status that doesn't exist"} =
+               conn
+               |> put_req_header("content-type", "application/json")
+               |> post("/api/v1/statuses", %{
+                 "status" => "I fight for eorzea!",
+                 "quote_id" => "oops"
+               })
+               |> json_response_and_validate_schema(422)
+    end
+  end
 end
index caf2594c0330c6052d94ccc4eb9ddd6aa9206479..9ef44cacaf33994000a470efa67a118fb49f35cf 100644 (file)
@@ -305,7 +305,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       },
       akkoma: %{
         source: HTML.filter_tags(object_data["content"])
-      }
+      },
+      quote_id: nil,
+      quote: nil
     }
 
     assert status == expected
@@ -393,6 +395,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     assert status.in_reply_to_id == to_string(note.id)
   end
 
+  test "a quote" do
+    note = insert(:note_activity)
+    user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{status: "hehe", quote_id: note.id})
+
+    status = StatusView.render("show.json", %{activity: activity})
+
+    assert status.quote_id == to_string(note.id)
+
+    [status] = StatusView.render("index.json", %{activities: [activity], as: :activity})
+
+    assert status.quote_id == to_string(note.id)
+  end
+
+  test "a quote that we can't resolve" do
+    note = insert(:note_activity, quoteUri: "oopsie")
+
+    status = StatusView.render("show.json", %{activity: note})
+
+    assert is_nil(status.quote_id)
+    assert is_nil(status.quote)
+  end
+
   test "contains mentions" do
     user = insert(:user)
     mentioned = insert(:user)