1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
import Foundation
import UIKit
/// Everything the composer has settled on before posting a story.
struct StoryDraft {
struct Location {
let h3: String
let name: String
let address: [String: AnyCodable]?
}
let image: UIImage
var location: Location?
var labels: Set<String> = []
var postToBluesky = false
/// The `social.grain.story` record for an already-uploaded blob.
///
/// Pure so the shape can be pinned by a test without a server: the media
/// and aspect ratio always, location and address only when set, and the
/// self-label wrapper only when there is at least one label.
func record(blob: BlobRef, size: CGSize, createdAt: String) -> [String: AnyCodable] {
let blobDict: [String: AnyCodable] = [
"$type": AnyCodable(blob.type ?? "blob"),
"ref": AnyCodable(["$link": AnyCodable(blob.ref?.link ?? "")] as [String: AnyCodable]),
"mimeType": AnyCodable(blob.mimeType ?? "image/jpeg"),
"size": AnyCodable(blob.size ?? 0),
]
var record: [String: AnyCodable] = [
"media": AnyCodable(blobDict),
"aspectRatio": AnyCodable([
"width": AnyCodable(Int(size.width)),
"height": AnyCodable(Int(size.height)),
] as [String: AnyCodable]),
"createdAt": AnyCodable(createdAt),
]
if let location {
record["location"] = AnyCodable([
"value": AnyCodable(location.h3),
"name": AnyCodable(location.name),
] as [String: AnyCodable])
if let address = location.address {
record["address"] = AnyCodable(address)
}
}
if !labels.isEmpty {
let values = labels.sorted().map { ["val": AnyCodable($0)] as [String: AnyCodable] }
record["labels"] = AnyCodable([
"$type": AnyCodable("com.atproto.label.defs#selfLabels"),
"values": AnyCodable(values as [[String: AnyCodable]]),
] as [String: AnyCodable])
}
return record
}
}
/// Publishing `social.grain.story` records.
///
/// This used to be inline in the composer, where the only test that could
/// reach it was a render. Uploading, writing the record, and the best-effort
/// Bluesky cross-post now live here so each step can be pinned on its own.
enum StoryService {
static let collection = "social.grain.story"
/// Uploads the photo, writes the story, and cross-posts if asked.
///
/// The cross-post runs after the story is already live and is deliberately
/// best-effort: Bluesky being down must not fail a story the PDS accepted.
@discardableResult
static func publish(
_ draft: StoryDraft,
client: XRPCClient,
repo: String,
auth: AuthContext
) async throws -> CreateRecordResponse {
let (resized, size) = ImageProcessing.resizeImage(draft.image, maxDimension: 2000, maxBytes: 900_000)
let upload = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: auth)
let result = try await client.createRecord(
collection: collection,
repo: repo,
record: AnyCodable(draft.record(blob: upload.blob, size: size, createdAt: DateFormatting.nowISO())),
auth: auth
)
if draft.postToBluesky, let uri = result.uri {
let rkey = uri.split(separator: "/").last.map(String.init) ?? ""
let options = BlueskyPostOptions(
url: "https://grain.social/profile/\(repo)/story/\(rkey)",
title: nil,
location: draft.location.map { ($0.name, $0.address) },
description: nil,
images: [(blob: upload.blob, alt: "", width: Int(size.width), height: Int(size.height))]
)
do {
try await BlueskyPost.create(options: options, client: client, repo: repo, auth: auth)
} catch {
// The story is live; a failed cross-post must not undo that.
}
}
return result
}
/// Resolves credentials, publishes, and reports the outcome.
///
/// Callers get a `Result` because there is no sensible default for a failed
/// post — the composer has to say something.
@MainActor
static func publish(
_ draft: StoryDraft,
client: XRPCClient,
auth: AuthManager
) async -> Result<Void, Error> {
guard let context = await auth.authContext(), let repo = auth.userDID else {
return .failure(StoryPublishError.notSignedIn)
}
do {
try await publish(draft, client: client, repo: repo, auth: context)
return .success(())
} catch {
return .failure(error)
}
}
/// What the composer shows for a failed publish. A rejected request keeps
/// the status and body, which is what makes a server complaint diagnosable.
static func message(for error: Error) -> String {
if case let XRPCError.httpError(statusCode, body) = error {
let bodyText = body.flatMap { String(data: $0, encoding: .utf8) } ?? "no body"
return "HTTP \(statusCode): \(bodyText)"
}
return error.localizedDescription
}
}
/// The one failure `StoryService.publish` can hit before it reaches the network.
enum StoryPublishError: LocalizedError, Equatable {
case notSignedIn
var errorDescription: String? {
switch self {
case .notSignedIn: "You're signed out. Sign in and try again."
}
}
}