Repository: andregasser/bigbone (kotlin)

Issue:
Add quote attribute to Status
Mastodon 4.4.0 added a `quote` attribute to `Status` in preparation for an upcoming "quote post" functionality in 4.5.0.

In addition to that attribute, the new `Quote` and `ShallowQuote` entities would need to be implemented.

[Mastodon documentation](https://docs.joinmastodon.org/entities/Status/#quote)

Pull request:
# Description

Adds support for quote posts:
- `Status` attribute `quote`
- `Quote` entity implementing both Mastodon's `Quote` and `ShallowQuote`
- tests and sample

Closes #612.

# Type of Change

- New feature

# How Has This Been Tested?

On top of tests, the newly added sample can be used to retrieve existing statuses with and without quoted status and observe the output. Mastodon CTO Renaud Chaput (@renchap@oisaur.com) recently posted at least one status with a quoted status attached that can be used for testing.

# Mandatory Checklist

- [X] I ran `gradle check` and there were no errors reported
- [X] I have performed a self-review of my code
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] All tests pass locally with my changes
- [X] I have added KDoc documentation to all public methods

Patch:
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/Quote.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/Quote.kt
new file mode 100644
index 000000000..1dd8b3152
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/Quote.kt
@@ -0,0 +1,77 @@
+package social.bigbone.api.entity
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents a quote or a quote placeholder, with the current authorization status.
+ *
+ * Note: This implements both the Quote and the ShallowQuote entity as defined by Mastodon.
+ *
+ * @see <a href="https://docs.joinmastodon.org/entities/Quote/">Mastodon API Quote</a>
+ * @see <a href="https://docs.joinmastodon.org/entities/ShallowQuote/">Mastodon API ShallowQuote</a>
+ */
+@Serializable
+data class Quote(
+    /**
+     *  The state of the quote.
+     */
+    @SerialName("state")
+    val state: State,
+
+    /**
+     * The status being quoted, if the quote has been accepted. This will be null, unless the state attribute is accepted.
+     * Note: only one of this and [quotedStatusId] will ever be non-null.
+     */
+    @SerialName("quoted_status")
+    val quotedStatus: Status? = null,
+
+    /**
+     * The identifier of the status being quoted, if the quote has been accepted. This will be null, unless the state attribute is accepted.
+     * Note: only one of this and [quotedStatus] will ever be non-null.
+     */
+    @SerialName("quoted_status_id")
+    val quotedStatusId: String? = null
+) {
+    /**
+     * The action to be taken when a status matches this filter.
+     */
+    @Serializable
+    enum class State {
+        /**
+         * The quote has not been acknowledged by the quoted account yet, and requires authorization before being displayed.
+         */
+        @SerialName("pending")
+        PENDING,
+
+        /**
+         * The quote has been accepted and can be displayed. This is the only case where one of quotedStatus or quotedStatusId is non-null.
+         */
+        @SerialName("accepted")
+        ACCEPTED,
+
+        /**
+         * The quote has been explicitly rejected by the quoted account, and cannot be displayed.
+         */
+        @SerialName("rejected")
+        REJECTED,
+
+        /**
+         * The quote has been previously accepted, but is now revoked, and thus cannot be displayed.
+         */
+        @SerialName("revoked")
+        REVOKED,
+
+        /**
+         * The quote has been approved, but the quoted post itself has now been deleted.
+         */
+        @SerialName("deleted")
+        DELETED,
+
+        /**
+         * The quote has been approved, but cannot be displayed because the user is not authorized to see it.
+         */
+        @SerialName("unauthorized")
+        UNAUTHORIZED
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/Status.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/Status.kt
index 5ed3ff487..1c903c33f 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/entity/Status.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/Status.kt
@@ -167,6 +167,12 @@ data class Status(
     @Serializable(with = DateTimeSerializer::class)
     val editedAt: PrecisionDateTime = InvalidPrecisionDateTime.Unavailable,
 
+    /**
+     * Information about the status being quoted, if any.
+     */
+    @SerialName("quote")
+    val quote: Quote? = null,
+
     /**
      * If the current token has an authorized user: Have you favourited this status? (optional)
      */
diff --git a/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetQuotedStatus.kt b/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetQuotedStatus.kt
new file mode 100644
index 000000000..e67e15b19
--- /dev/null
+++ b/sample-kotlin/src/main/kotlin/social/bigbone/sample/GetQuotedStatus.kt
@@ -0,0 +1,52 @@
+package social.bigbone.sample
+
+import social.bigbone.MastodonClient
+import social.bigbone.api.entity.Quote
+
+object GetQuotedStatus {
+    @JvmStatic
+    fun main(args: Array<String>) {
+        val instance = args[0]
+
+        // access token with at least Scope.READ.STATUSES
+        val accessToken = args[1]
+
+        // ID value of a status that should be retrieved and checked for a quoted status
+        val statusId = args[2]
+
+        // instantiate client
+        val client = MastodonClient.Builder(instance)
+            .accessToken(accessToken)
+            .build()
+
+        // get the status
+        val status = client.statuses.getStatus(statusId).execute()
+
+        // status may not have a quoted status
+        if (status.quote == null) {
+            println("Status with ID $statusId does not contain a quoted status")
+        } else {
+            // if it has, quote might not have been accepted by the original author
+            if (status.quote?.state != Quote.State.ACCEPTED) {
+                println("Status with ID $statusId contains a quoted status in a non-accepted state")
+            }
+
+            // if the quote is accepted...
+            if (status.quote?.state == Quote.State.ACCEPTED) {
+                // ...the quoted status might be contained directly
+                status.quote?.quotedStatus?.let {
+                    println(
+                        "Status with ID $statusId contains a quoted status with the following text content, " +
+                            "which was returned directly:\n ${it.content} (${it.id})"
+                    )
+                }
+
+                // ...or might need to be retrieved via its ID
+                status.quote?.quotedStatusId?.let {
+                    val quotedStatus = client.statuses.getStatus(it).execute()
+                    println("Quoted status has the following text content, retrieved via its ID value:\n ${quotedStatus.content}")
+                }
+            }
+        }
+    }
+}


Test patch:
diff --git a/bigbone/src/test/assets/status_with_quote.json b/bigbone/src/test/assets/status_with_quote.json
new file mode 100644
index 000000000..ac809b1de
--- /dev/null
+++ b/bigbone/src/test/assets/status_with_quote.json
@@ -0,0 +1,79 @@
+{
+  "account": {
+    "acct": "test",
+    "avatar": "test",
+    "avatar_static": "test",
+    "created_at": "2017-04-13T13:09:20.869Z",
+    "display_name": "test",
+    "followers_count": 1,
+    "following_count": 0,
+    "header": "https://mstdn.jp/headers/original/missing.png",
+    "header_static": "https://mstdn.jp/headers/original/missing.png",
+    "id": "14476",
+    "locked": false,
+    "note": "test",
+    "statuses_count": 10,
+    "url": "https://mstdn.jp/test",
+    "username": "test"
+  },
+  "application": null,
+  "content": "Test Status",
+  "created_at": "2017-04-14T06:11:41.893Z",
+  "quote": {
+    "state": "accepted",
+    "quoted_status": {
+      "account": {
+        "acct": "test",
+        "avatar": "test",
+        "avatar_static": "test",
+        "created_at": "2017-04-13T13:09:20.869Z",
+        "display_name": "test",
+        "followers_count": 1,
+        "following_count": 0,
+        "header": "https://mstdn.jp/headers/original/missing.png",
+        "header_static": "https://mstdn.jp/headers/original/missing.png",
+        "id": "14477",
+        "locked": false,
+        "note": "test",
+        "statuses_count": 10,
+        "url": "https://mstdn.jp/test",
+        "username": "test"
+      },
+      "application": null,
+      "content": "Test Status",
+      "created_at": "2017-04-14T06:11:41.893Z",
+      "favourited": null,
+      "favourites_count": 0,
+      "id": "11112",
+      "in_reply_to_account_id": null,
+      "in_reply_to_id": null,
+      "media_attachments": [],
+      "mentions": [],
+      "reblog": null,
+      "reblogged": null,
+      "reblogs_count": 0,
+      "sensitive": false,
+      "spoiler_text": "",
+      "tags": [],
+      "uri": "tag:mstdn.jp,2017-04-14:objectId=172429:objectType=Status",
+      "url": "https://mstdn.jp/test",
+      "visibility": "public"
+    }
+  },
+  "favourited": null,
+  "favourites_count": 0,
+  "id": "11111",
+  "in_reply_to_account_id": null,
+  "in_reply_to_id": null,
+  "media_attachments": [],
+  "mentions": [],
+  "reblog": null,
+  "reblogged": null,
+  "reblogs_count": 0,
+  "sensitive": false,
+  "spoiler_text": "",
+  "tags": [],
+  "uri": "tag:mstdn.jp,2017-04-14:objectId=172429:objectType=Status",
+  "url": "https://mstdn.jp/test",
+  "visibility": "public"
+}
diff --git a/bigbone/src/test/assets/status_with_revoked_quote.json b/bigbone/src/test/assets/status_with_revoked_quote.json
new file mode 100644
index 000000000..d5d8750d5
--- /dev/null
+++ b/bigbone/src/test/assets/status_with_revoked_quote.json
@@ -0,0 +1,41 @@
+{
+  "account": {
+    "acct": "test",
+    "avatar": "test",
+    "avatar_static": "test",
+    "created_at": "2017-04-13T13:09:20.869Z",
+    "display_name": "test",
+    "followers_count": 1,
+    "following_count": 0,
+    "header": "https://mstdn.jp/headers/original/missing.png",
+    "header_static": "https://mstdn.jp/headers/original/missing.png",
+    "id": "14476",
+    "locked": false,
+    "note": "test",
+    "statuses_count": 10,
+    "url": "https://mstdn.jp/test",
+    "username": "test"
+  },
+  "application": null,
+  "content": "Test Status",
+  "created_at": "2017-04-14T06:11:41.893Z",
+  "quote": {
+    "state": "revoked"
+  },
+  "favourited": null,
+  "favourites_count": 0,
+  "id": "11111",
+  "in_reply_to_account_id": null,
+  "in_reply_to_id": null,
+  "media_attachments": [],
+  "mentions": [],
+  "reblog": null,
+  "reblogged": null,
+  "reblogs_count": 0,
+  "sensitive": false,
+  "spoiler_text": "",
+  "tags": [],
+  "uri": "tag:mstdn.jp,2017-04-14:objectId=172429:objectType=Status",
+  "url": "https://mstdn.jp/test",
+  "visibility": "public"
+}
diff --git a/bigbone/src/test/assets/status_with_shallow_quote.json b/bigbone/src/test/assets/status_with_shallow_quote.json
new file mode 100644
index 000000000..321eb9359
--- /dev/null
+++ b/bigbone/src/test/assets/status_with_shallow_quote.json
@@ -0,0 +1,42 @@
+{
+  "account": {
+    "acct": "test",
+    "avatar": "test",
+    "avatar_static": "test",
+    "created_at": "2017-04-13T13:09:20.869Z",
+    "display_name": "test",
+    "followers_count": 1,
+    "following_count": 0,
+    "header": "https://mstdn.jp/headers/original/missing.png",
+    "header_static": "https://mstdn.jp/headers/original/missing.png",
+    "id": "14476",
+    "locked": false,
+    "note": "test",
+    "statuses_count": 10,
+    "url": "https://mstdn.jp/test",
+    "username": "test"
+  },
+  "application": null,
+  "content": "Test Status",
+  "created_at": "2017-04-14T06:11:41.893Z",
+  "quote": {
+    "state": "accepted",
+    "quoted_status_id": "999999"
+  },
+  "favourited": null,
+  "favourites_count": 0,
+  "id": "11111",
+  "in_reply_to_account_id": null,
+  "in_reply_to_id": null,
+  "media_attachments": [],
+  "mentions": [],
+  "reblog": null,
+  "reblogged": null,
+  "reblogs_count": 0,
+  "sensitive": false,
+  "spoiler_text": "",
+  "tags": [],
+  "uri": "tag:mstdn.jp,2017-04-14:objectId=172429:objectType=Status",
+  "url": "https://mstdn.jp/test",
+  "visibility": "public"
+}
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/entity/StatusTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/entity/StatusTest.kt
index 2daafc3b8..04eb4a285 100644
--- a/bigbone/src/test/kotlin/social/bigbone/api/entity/StatusTest.kt
+++ b/bigbone/src/test/kotlin/social/bigbone/api/entity/StatusTest.kt
@@ -22,6 +22,34 @@ class StatusTest {
         status.isReblogged shouldBeEqualTo false
     }
 
+    @Test
+    fun deserializeStatusWithQuote() {
+        val json = AssetsUtil.readFromAssets("status_with_quote.json")
+        val status: Status = JSON_SERIALIZER.decodeFromString(json)
+        status.quote?.state shouldBeEqualTo Quote.State.ACCEPTED
+        status.quote?.quotedStatus?.account?.id shouldBeEqualTo "14477"
+        status.quote?.quotedStatus?.id shouldBeEqualTo "11112"
+        status.quote?.quotedStatusId shouldBeEqualTo null
+    }
+
+    @Test
+    fun deserializeStatusWithShallowQuote() {
+        val json = AssetsUtil.readFromAssets("status_with_shallow_quote.json")
+        val status: Status = JSON_SERIALIZER.decodeFromString(json)
+        status.quote?.state shouldBeEqualTo Quote.State.ACCEPTED
+        status.quote?.quotedStatus shouldBeEqualTo null
+        status.quote?.quotedStatusId shouldBeEqualTo "999999"
+    }
+
+    @Test
+    fun deserializeStatusWithRevokedQuote() {
+        val json = AssetsUtil.readFromAssets("status_with_revoked_quote.json")
+        val status: Status = JSON_SERIALIZER.decodeFromString(json)
+        status.quote?.state shouldBeEqualTo Quote.State.REVOKED
+        status.quote?.quotedStatus shouldBeEqualTo null
+        status.quote?.quotedStatusId shouldBeEqualTo null
+    }
+
     @Test
     fun constructor() {
         val status = Status(id = "123", visibility = Visibility.PRIVATE)