Repository: TootSDK/TootSDK (swift)

Issue:
Inconsistent order of media attachments for scheduled posts
Hey 👋,
I'm building an app to schedule posts and noticed that the `mediaAttachments` and `params.mediaIds` in `ScheduledPost` have inconsistent ordering. `params.mediaIds` appears to have the correct order, while `mediaAttachments` are sorted numerically by their id. I'm not sure whether this would be considered to be a bug in the Mastodon API, but it would be nice to have consistent ordering in TootSDK. If you agree, I can create a pull request to add a decodable implementation of `ScheduledPost` that fixes the ordering based on `params.mediaIds`.

Pull request description:
This fixes #246

Patch:
diff --git a/Package.swift b/Package.swift
index f6055cfe..f0a7b636 100644
--- a/Package.swift
+++ b/Package.swift
@@ -74,6 +74,7 @@ let package = Package(
                 .copy("Resources/relationship.json"),
                 .copy("Resources/scheduled_post_sensitive.json"),
                 .copy("Resources/scheduled_post_attachment.json"),
+                .copy("Resources/scheduled_post_multiple_attachments.json"),
                 .copy("Resources/scheduled_post_reply.json"),
                 .copy("Resources/tag.json"),
                 .copy("Resources/encrypted_push_notification.base64"),
diff --git a/Sources/TootSDK/Models/ScheduledPost.swift b/Sources/TootSDK/Models/ScheduledPost.swift
index e2bdd3d6..90c97b78 100644
--- a/Sources/TootSDK/Models/ScheduledPost.swift
+++ b/Sources/TootSDK/Models/ScheduledPost.swift
@@ -13,4 +13,26 @@ public struct ScheduledPost: Codable, Equatable, Hashable, Identifiable, Sendabl
     public var params: ScheduledPostParams
     /// Media that is attached to this post.
     public var mediaAttachments: [MediaAttachment]
+
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.id = try container.decode(String.self, forKey: .id)
+        self.scheduledAt = try container.decodeIfPresent(Date.self, forKey: .scheduledAt)
+
+        let postParams = try container.decode(ScheduledPostParams.self, forKey: .params)
+        self.params = postParams
+
+        let attachments = try container.decode([MediaAttachment].self, forKey: .mediaAttachments)
+
+        if let mediaIds = params.mediaIds {
+            // sort attachments in the order of mediaIds
+            self.mediaAttachments = attachments.sorted(by: { attachmentA, attachmentB in
+                guard let aIndex = mediaIds.firstIndex(of: attachmentA.id) else { return false }
+                guard let bIndex = mediaIds.firstIndex(of: attachmentB.id) else { return true }
+                return aIndex < bIndex
+            })
+        } else {
+            self.mediaAttachments = attachments
+        }
+    }
 }


Test patch:
diff --git a/Tests/TootSDKTests/PostTests.swift b/Tests/TootSDKTests/PostTests.swift
index 76550d58..6c846583 100644
--- a/Tests/TootSDKTests/PostTests.swift
+++ b/Tests/TootSDKTests/PostTests.swift
@@ -88,6 +88,34 @@ final class PostTests: XCTestCase {
          XCTAssertEqual(result.params.visibility, .private)
      }
     
+    func testScheduledPostMultipleMedias() throws {
+        // arrange
+        let json = localContent("scheduled_post_multiple_attachments")
+        let decoder = TootDecoder()
+        
+        // act
+        let result = try decoder.decode(ScheduledPost.self, from: json)
+        
+        // assert
+        XCTAssertNotNil(result)
+        XCTAssertEqual(result.id, "104394")
+        XCTAssertNotNil(result.scheduledAt)
+        XCTAssertNotNil(result.params)
+        XCTAssertNil(result.params.inReplyToId)
+        XCTAssertNil(result.params.inReplyToConversationId)
+        XCTAssertNil(result.params.sensitive)
+        XCTAssertEqual(result.params.text, "Testing scheduled attachments")
+        XCTAssertNil(result.params.scheduledAt)
+        XCTAssertNotNil(result.params.mediaIds)
+        XCTAssertNil(result.params.poll)
+        XCTAssertNil(result.params.idempotency)
+        XCTAssertNil(result.params.spoilerText)
+        XCTAssertEqual(result.params.language, "en")
+        XCTAssertEqual(result.params.visibility, .private)
+        XCTAssertEqual(result.params.mediaIds, ["111892073708535727", "111892073823106369"])
+        XCTAssertEqual(result.params.mediaIds, result.mediaAttachments.map(\.id))
+    }
+    
     func testScheduledPostReplyIds() throws {
          // arrange
          let json = localContent("scheduled_post_reply")
diff --git a/Tests/TootSDKTests/Resources/scheduled_post_multiple_attachments.json b/Tests/TootSDKTests/Resources/scheduled_post_multiple_attachments.json
new file mode 100644
index 00000000..f3daa3f9
--- /dev/null
+++ b/Tests/TootSDKTests/Resources/scheduled_post_multiple_attachments.json
@@ -0,0 +1,72 @@
+{
+    "id": "104394",
+    "scheduled_at" : "2024-08-30T23:20:00.000Z",
+    "params": {
+        "scheduled_at" : null,
+        "in_reply_to_id" : null,
+        "media_ids" : [
+            "111892073708535727",
+            "111892073823106369"
+        ],
+        "application_id" : 91595,
+        "poll" : null,
+        "spoiler_text" : null,
+        "idempotency" : null,
+        "allowed_mentions" : null,
+        "text" : "Testing scheduled attachments",
+        "language" : "en",
+        "visibility" : "private",
+        "with_rate_limit" : false,
+        "sensitive" : null
+    },
+    "media_attachments": [
+        {
+            "id": "111892073823106369",
+            "blurhash": "UdFPHT_4%g%M%MM{RjofM{Rjofj]xutRj[Ri",
+            "meta": {
+                "small": {
+                    "size": "392x588",
+                    "height": 588,
+                    "width": 392,
+                    "aspect": 0.6666666666666666
+                },
+                "original": {
+                    "width": 1668,
+                    "aspect": 0.6672,
+                    "size": "1668x2500",
+                    "height": 2500
+                }
+            },
+            "type": "image",
+            "description": "Second Image",
+            "remote_url" : null,
+            "preview_remote_url" : null,
+            "preview_url": "https:\/\/files.mastodon.social/media_attachments\/files\/111\/892\/073\/823\/106\/369\/small\/3e200069e30773df.jpeg",
+            "url": "https:\/\/files.mastodon.social\/media_attachments\/files\/111\/892\/073\/823\/106\/369\/original\/3e200069e30773df.jpeg"
+        },
+        {
+            "id": "111892073708535727",
+            "blurhash": "UaFrnr00xZxu?cD%s+xaE3%1RjRkE3%1RjRk",
+            "meta": {
+                "small": {
+                    "height": 392,
+                    "size": "587x392",
+                    "width": 587,
+                    "aspect": 1.4974489795918366
+                },
+                "original": {
+                    "aspect": 1.4985014985014986,
+                    "size": "3000x2002",
+                    "width": 3000,
+                    "height": 2002
+                }
+            },
+            "type": "image",
+            "description": "First Image",
+            "remote_url" : null,
+            "preview_remote_url" : null,
+            "preview_url": "https:\/\/files.mastodon.social\/media_attachments\/files\/111\/892\/073\/708\/535\/727\/small\/9127e8fcf18dec4f.jpeg",
+            "url": "https:\/\/files.mastodon.social\/media_attachments\/files\/111\/892\/073\/708\/535\/727\/original\/9127e8fcf18dec4f.jpeg"
+        }
+    ]
+}