diff --git a/Inkwell/BrowseDocumentsView.swift b/Inkwell/BrowseDocumentsView.swift new file mode 100644 index 0000000..1c74284 --- /dev/null +++ b/Inkwell/BrowseDocumentsView.swift @@ -0,0 +1,233 @@ +// +// BrowseDocumentsView.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// + +import SwiftUI +import ATProtoKit + +struct BrowseDocumentsView: View { + @Environment(LoginStateManager.self) private var loginStateManager + + @State private var publications: [PublicationEntry] = [] + @State private var documents: [DocumentEntry] = [] + @State private var isLoading = true + @State private var errorMessage: String? = nil + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Fetching standard.site documents...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = errorMessage { + ContentUnavailableView( + "Failed to Load Documents", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if documents.isEmpty { + ContentUnavailableView( + "No Documents Found", + systemImage: "doc.text", + description: Text("Create publication or documents in standard.site to read them here.") + ) + } else { + List { + ForEach(publications) { publication in + Section { + let pubDocs = documents.filter { publication.contains($0.record) } + + ForEach(pubDocs) { document in + NavigationLink { + ReadView( + document: document.record, + publication: publication.record, + documentURI: document.uri, + authorDID: document.authorDID + ) + } label: { + DocumentRowView(document: document.record, publication: publication.record) + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + } + } header: { + PublicationHeaderView(publication: publication.record) + } + } + + // Loose documents not associated with any known publication + let looseDocs = documents.filter { document in + !publications.contains(where: { $0.contains(document.record) }) + } + + if !looseDocs.isEmpty { + Section("Other Documents") { + ForEach(looseDocs) { document in + NavigationLink { + ReadView( + document: document.record, + publication: nil, + documentURI: document.uri, + authorDID: document.authorDID + ) + } label: { + DocumentRowView(document: document.record, publication: nil) + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await loadData() + } + } + } + .navigationTitle("Reader") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(role: .destructive, action: loginStateManager.signOut) { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await loadData() + } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + .task { + await loadData() + } + } + } + + private func loadData() async { + isLoading = true + errorMessage = nil + + do { + async let fetchedPubs = loginStateManager.fetchPublicationsWithURIs() + async let fetchedDocs = loginStateManager.fetchDocumentsWithURIs() + + let (pubs, docs) = try await (fetchedPubs, fetchedDocs) + + // Sort documents by published date descending + self.publications = pubs + self.documents = docs.sorted { $0.record.publishedAt > $1.record.publishedAt } + } catch { + self.errorMessage = error.localizedDescription + } + + self.isLoading = false + } +} + +struct PublicationHeaderView: View { + @Environment(LoginStateManager.self) private var loginStateManager + let publication: SiteStandardLexicon.PublicationRecord + + var body: some View { + HStack(spacing: 12) { + if let icon = publication.icon, let did = loginStateManager.currentDID { + let urlString = "https://cdn.bsky.app/img/avatar/plain/\(did)/\(icon.reference.link)" + AsyncImage(url: URL(string: urlString)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Color.gray.opacity(0.1) + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + ) + } else { + Image(systemName: "building.2.crop.left.right.fill") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .background(Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(publication.name) + .font(.headline) + .foregroundStyle(.primary) + + if let desc = publication.description, !desc.isEmpty { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.vertical, 6) + .textCase(nil) // prevent system upper-casing header text + } +} + +struct DocumentRowView: View { + @Environment(LoginStateManager.self) private var loginStateManager + let document: SiteStandardLexicon.DocumentRecord + let publication: SiteStandardLexicon.PublicationRecord? + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(document.title) + .font(.system(.body, design: .serif)) + .fontWeight(.semibold) + .lineLimit(2) + .foregroundStyle(.primary) + + if let desc = document.description, !desc.isEmpty { + Text(desc) + .font(.system(.caption, design: .serif)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(formatDate(document.publishedAt)) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + + Spacer() + + if let cover = document.coverImage, let did = loginStateManager.currentDID { + let urlString = "https://cdn.bsky.app/img/feed_thumbnail/plain/\(did)/\(cover.reference.link)" + AsyncImage(url: URL(string: urlString)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Color.gray.opacity(0.05) + } + .frame(width: 70, height: 70) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 4) + } + + private func formatDate(_ date: Date) -> String { + let outputFormatter = DateFormatter() + outputFormatter.dateStyle = .medium + outputFormatter.timeStyle = .none + return outputFormatter.string(from: date) + } +} diff --git a/Inkwell/DiscoverView.swift b/Inkwell/DiscoverView.swift new file mode 100644 index 0000000..1a1ea3d --- /dev/null +++ b/Inkwell/DiscoverView.swift @@ -0,0 +1,245 @@ +// +// DiscoverView.swift +// Inkwell +// +// Cross-platform Standard.site discovery backed by pub search. Search +// results provide discovery metadata; Inkwell reads the authoritative record +// directly from the author's PDS before rendering or subscribing. +// + +import SwiftUI + +struct DiscoverView: View { + @Environment(LoginStateManager.self) private var loginStateManager + + @State private var query = "" + @State private var results: [ReaderSearchResult] = [] + @State private var subscriptions: Set = [] + @State private var isSearching = false + @State private var errorMessage: String? + + private var publications: [ReaderSearchResult] { results.filter(\.isPublication) } + private var documents: [ReaderSearchResult] { results.filter { !$0.isPublication } } + + var body: some View { + NavigationStack { + List { + Section { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search publications and articles", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .onSubmit { Task { await search() } } + } + } + + if isSearching { + Section { + HStack { + ProgressView() + Text("Searching the Standard.site network...") + .foregroundStyle(.secondary) + } + } + } else if results.isEmpty && errorMessage == nil { + Section { + ContentUnavailableView( + "Search the Open Web", + systemImage: "text.magnifyingglass", + description: Text("Find Standard.site writing from Leaflet, pckt, Offprint, and independent publishers.") + ) + } + } + + if !documents.isEmpty { + Section("Documents") { + ForEach(documents) { result in + if result.isStandardSiteDocument { + NavigationLink { + RemoteDocumentView(documentURI: result.uri) + } label: { + DocumentSearchRow(result: result) + } + } else if let url = result.webURL { + Link(destination: url) { + DocumentSearchRow(result: result) + } + } + } + } + } + + if !publications.isEmpty { + Section("Publications") { + ForEach(publications) { publication in + PublicationSearchRow( + publication: publication, + isSubscribed: subscriptions.contains(publication.uri), + canSubscribe: ATURI.parse(publication.uri)?.collection == SiteStandardLexicon.PublicationRecord.type, + onSubscribe: { Task { await toggleSubscription(publication) } } + ) + } + } + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Discover") + .task { await loadSubscriptions() } + } + } + + private func search() async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + isSearching = true + errorMessage = nil + defer { isSearching = false } + + do { + results = try await StandardReaderAPI.shared.search(query: trimmed).results + } catch { + errorMessage = "Search is unavailable: \(error.localizedDescription)" + } + } + + private func loadSubscriptions() async { + let records = (try? await loginStateManager.fetchSubscriptions()) ?? [] + subscriptions = Set(records.map { $0.record.publication }) + } + + private func toggleSubscription(_ publication: ReaderSearchResult) async { + do { + if subscriptions.contains(publication.uri) { + let records = try await loginStateManager.fetchSubscriptions() + if let record = records.first(where: { $0.record.publication == publication.uri }) { + try await loginStateManager.deleteSubscription(recordKey: record.recordKey) + subscriptions.remove(publication.uri) + } + } else { + let record = try await loginStateManager.fetchPublication(uri: publication.uri) + _ = try await SiteStandardLexicon.Verification.verify( + publicationURI: record.uri, + publication: record.record + ) + _ = try await loginStateManager.createSubscription(publicationURI: publication.uri) + subscriptions.insert(publication.uri) + await NotificationManager.shared.requestPermission() + } + } catch { + errorMessage = "Could not update subscription: \(error.localizedDescription)" + } + } +} + +struct RemoteDocumentView: View { + @Environment(LoginStateManager.self) private var loginStateManager + let documentURI: String + + @State private var document: DocumentEntry? + @State private var publication: PublicationEntry? + @State private var errorMessage: String? + + var body: some View { + Group { + if let document { + ReadView( + document: document.record, + publication: publication?.record, + documentURI: document.uri, + authorDID: document.authorDID + ) + } else if let errorMessage { + ContentUnavailableView( + "Document Unavailable", + systemImage: "exclamationmark.triangle", + description: Text(errorMessage) + ) + } else { + ProgressView("Fetching from the author's PDS...") + } + } + .task { await load() } + } + + private func load() async { + do { + let document = try await loginStateManager.fetchDocument(uri: documentURI) + self.document = document + if ATURI.parse(document.record.site)?.collection == SiteStandardLexicon.PublicationRecord.type { + publication = try? await loginStateManager.fetchPublication(uri: document.record.site) + } + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct DocumentSearchRow: View { + let result: ReaderSearchResult + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(result.title) + .font(.system(.body, design: .serif, weight: .semibold)) + .lineLimit(2) + if let snippet = result.snippet, !snippet.isEmpty { + Text(snippet) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + HStack { + Text(result.platform ?? "standard.site") + if let handle = result.handle { Text(handle) } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } +} + +private struct PublicationSearchRow: View { + let publication: ReaderSearchResult + let isSubscribed: Bool + let canSubscribe: Bool + let onSubscribe: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "building.2.crop.left.right.fill") + .foregroundStyle(.secondary) + .frame(width: 40, height: 40) + .background(Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 4) { + Text(publication.title) + .font(.headline) + if let basePath = publication.basePath { + Text(basePath) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button(action: onSubscribe) { + Image(systemName: isSubscribed ? "bell.fill" : "bell") + .foregroundStyle(isSubscribed ? Color.accentColor : Color.secondary) + } + .buttonStyle(.borderless) + .disabled(!canSubscribe) + } + .padding(.vertical, 4) + } +} diff --git a/Inkwell/LoginStateManager.swift b/Inkwell/LoginStateManager.swift index e417c95..feb4872 100644 --- a/Inkwell/LoginStateManager.swift +++ b/Inkwell/LoginStateManager.swift @@ -51,6 +51,7 @@ final class LoginStateManager { // MARK: - Public State private(set) var isAuthenticated = false private(set) var currentHandle: String? + private(set) var currentDID: String? private(set) var displayName: String? private(set) var avatarURL: URL? private(set) var errorMessage: String? @@ -82,9 +83,18 @@ final class LoginStateManager { // MARK: - Internal Session private var config: ATProtocolConfiguration? private var atProto: ATProtoKit? + private var repositoryClients: [String: ATProtoKit] = [:] + private var repositoryPDSURLs: [String: URL] = [:] init(defaults: UserDefaults = .standard) { self.defaults = defaults + // Record-type registration (site.standard.* plus every content + // format) happens once, centrally, in InkwellApp's `.task` — see + // `SiteStandardRegistration.swift` and `ContentFormatRegistration.swift`. + // It used to be duplicated here against the old, now-removed + // StandardPublication/StandardDocument types, which raced the + // SiteStandardLexicon registration for the same `$type` keys + // depending on which ran first. } // MARK: - Authentication @@ -146,6 +156,7 @@ final class LoginStateManager { self.config = cfg self.atProto = proto self.currentHandle = trimmed + self.currentDID = identity.did self.isAuthenticated = true self.errorMessage = nil self.defaults.set(trimmed, forKey: self.storedHandleKey) @@ -266,6 +277,7 @@ final class LoginStateManager { self.config = cfg self.atProto = proto self.currentHandle = session.handle + self.currentDID = session.sessionDID self.isAuthenticated = true self.errorMessage = nil print("[RestoreSession] \(Date()) session restored for \(session.handle)") @@ -314,10 +326,470 @@ final class LoginStateManager { } } + // MARK: - standard.site Records API + + /// Fetches all standard.site publication records from the user's repository. + func fetchPublications() async throws -> [SiteStandardLexicon.PublicationRecord] { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + _ = atProto + let records = try await listAllRecords(from: session.sessionDID, collection: SiteStandardLexicon.PublicationRecord.type) + + var publications: [SiteStandardLexicon.PublicationRecord] = [] + for record in records { + if let value = record.value, let pub = value.getRecord(ofType: SiteStandardLexicon.PublicationRecord.self) { + publications.append(pub) + } + } + return publications + } + + /// Fetches all standard.site document records from the user's repository. + func fetchDocuments() async throws -> [SiteStandardLexicon.DocumentRecord] { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + _ = atProto + let records = try await listAllRecords(from: session.sessionDID, collection: SiteStandardLexicon.DocumentRecord.type) + + var documents: [SiteStandardLexicon.DocumentRecord] = [] + for record in records { + if let value = record.value, let doc = value.getRecord(ofType: SiteStandardLexicon.DocumentRecord.self) { + documents.append(doc) + } + } + return documents + } + + /// Downloads raw bytes of a blob by its CID. + func downloadBlob(cid: String) async throws -> Data { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + return try await atProto.getBlob(from: session.sessionDID, cid: cid) + } + + /// Downloads a blob from the PDS hosting the specified repository. + func downloadBlob(cid: String, fromDID did: String) async throws -> Data { + let client = try await repositoryClient(for: did) + return try await client.getBlob(from: did, cid: cid) + } + + /// Creates and publishes a new standard.site document record. + /// + /// - Parameters: + /// - title: The document title. + /// - description: Optional description. + /// - path: Optional URL path segment. + /// - site: The publication URL this document belongs to. + /// - markdown: The markdown content to convert and store. + /// - provider: The content provider that converts markdown to the + /// format-specific AT Protocol record. + @discardableResult + func createDocument( + title: String, + description: String?, + path: String?, + site: String, + markdown: String, + provider: ContentProvider + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + // Convert markdown to the format-specific content record. + guard let contentRecord = provider.fromMarkdown(markdown) else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "Failed to convert markdown to \(provider.label) format"]) + } + + // Build the standard.site document wrapper. publishedAt is a `Date` + // here, not a pre-formatted ISO8601 string — DocumentRecord's own + // `encode(to:)` handles AT Protocol datetime formatting. + guard (ATURI.parse(site)?.collection == SiteStandardLexicon.PublicationRecord.type) || + (URL(string: site)?.scheme?.lowercased() == "https") else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "The document site must be a publication AT-URI or HTTPS URL."]) + } + + let normalizedPath = path.flatMap { value -> String? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.hasPrefix("/") ? trimmed : "/\(trimmed)" + } + let plainText = (try? AttributedString(markdown: markdown)) + .map { String($0.characters) } + .flatMap { $0.isEmpty ? nil : $0 } + + var normalizedSite = site + while normalizedSite.hasSuffix("/") { + normalizedSite.removeLast() + } + let document = SiteStandardLexicon.DocumentRecord( + site: normalizedSite, + title: title, + publishedAt: Date(), + path: normalizedPath, + description: description, + coverImage: nil, + content: contentRecord, + textContent: plainText + ) + + // Wrap in UnknownType and create the record. shouldValidate is false + // because site.standard.document is not a Bluesky lexicon — the PDS + // would reject it if validation were enabled. + let record = UnknownType.record(document) + return try await atProto.createRecord( + repositoryDID: session.sessionDID, + collection: "site.standard.document", + shouldValidate: false, + record: record + ) + } + + // MARK: - Subscription API + + /// Creates a `site.standard.graph.subscription` record, subscribing the + /// user to the publication at the given AT-URI. + /// + /// - Parameter publicationURI: The AT-URI of the publication record + /// (e.g. `at://did:plc:abc123/site.standard.publication/xyz789`). + /// - Returns: The strong reference (URI + CID) of the created subscription. + @discardableResult + func createSubscription(publicationURI: String) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + guard ATURI.parse(publicationURI)?.collection == SiteStandardLexicon.PublicationRecord.type else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "Subscriptions must reference a Standard.site publication AT-URI."]) + } + + let subscription = SiteStandardLexicon.Graph.SubscriptionRecord( + publication: publicationURI, + createdAt: Date() + ) + let record = UnknownType.record(subscription) + return try await atProto.createRecord( + repositoryDID: session.sessionDID, + collection: "site.standard.graph.subscription", + shouldValidate: false, + record: record + ) + } + + /// Fetches all of the user's subscription records. + /// + /// Each entry includes the subscription record itself plus the AT-URI and + /// record key (needed to unsubscribe). + func fetchSubscriptions() async throws -> [SubscriptionEntry] { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + _ = atProto + let records = try await listAllRecords( + from: session.sessionDID, + collection: SiteStandardLexicon.Graph.SubscriptionRecord.type + ) + + var entries: [SubscriptionEntry] = [] + for record in records { + if let value = record.value, + let sub = value.getRecord(ofType: SiteStandardLexicon.Graph.SubscriptionRecord.self) { + let rkey = ATURI.parse(record.uri)?.recordKey ?? "" + entries.append(SubscriptionEntry( + uri: record.uri, + recordKey: rkey, + record: sub + )) + } + } + return entries + } + + /// Deletes a subscription record (unsubscribes from a publication). + /// + /// - Parameter recordKey: The record key of the subscription to delete. + func deleteSubscription(recordKey: String) async throws { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + try await atProto.deleteRecord( + repositoryDID: session.sessionDID, + collection: "site.standard.graph.subscription", + recordKey: recordKey + ) + } + + // MARK: - Cross-repo Record Fetching + + /// Fetches publication records from any user's repository (not just the + /// current user's). Each entry includes the AT-URI and author DID alongside + /// the decoded record. + /// + /// - Parameter did: The DID of the repository to fetch from. + func fetchPublications(fromDID did: String) async throws -> [PublicationEntry] { + let records = try await listAllRecords(from: did, collection: SiteStandardLexicon.PublicationRecord.type) + + var entries: [PublicationEntry] = [] + for record in records { + if let value = record.value, + let pub = value.getRecord(ofType: SiteStandardLexicon.PublicationRecord.self) { + entries.append(PublicationEntry( + uri: record.uri, + authorDID: did, + record: pub + )) + } + } + return entries + } + + /// Fetches document records from any user's repository. + /// + /// - Parameter did: The DID of the repository to fetch from. + func fetchDocuments(fromDID did: String) async throws -> [DocumentEntry] { + let records = try await listAllRecords(from: did, collection: SiteStandardLexicon.DocumentRecord.type) + + var entries: [DocumentEntry] = [] + for record in records { + if let value = record.value, + let doc = value.getRecord(ofType: SiteStandardLexicon.DocumentRecord.self) { + entries.append(DocumentEntry( + uri: record.uri, + authorDID: did, + record: doc + )) + } + } + return entries + } + + /// Fetches and decodes one Standard.site publication from its AT-URI. + func fetchPublication(uri: String) async throws -> PublicationEntry { + guard let parsed = ATURI.parse(uri), + parsed.collection == SiteStandardLexicon.PublicationRecord.type else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "Invalid publication AT-URI."]) + } + let client = try await repositoryClient(for: parsed.did) + let output = try await client.getRepositoryRecord( + from: parsed.did, + collection: parsed.collection, + recordKey: parsed.recordKey + ) + guard let value = output.value, + let publication = value.getRecord(ofType: SiteStandardLexicon.PublicationRecord.self) else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "The record isn't a Standard.site publication."]) + } + return PublicationEntry(uri: output.uri, authorDID: parsed.did, record: publication) + } + + /// Fetches and decodes one Standard.site document from its AT-URI. + func fetchDocument(uri: String) async throws -> DocumentEntry { + guard let parsed = ATURI.parse(uri), + parsed.collection == SiteStandardLexicon.DocumentRecord.type else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "Invalid document AT-URI."]) + } + let client = try await repositoryClient(for: parsed.did) + let output = try await client.getRepositoryRecord( + from: parsed.did, + collection: parsed.collection, + recordKey: parsed.recordKey + ) + guard let value = output.value, + let document = value.getRecord(ofType: SiteStandardLexicon.DocumentRecord.self) else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "The record isn't a Standard.site document."]) + } + return DocumentEntry(uri: output.uri, authorDID: parsed.did, record: document) + } + + private func repositoryClient(for did: String) async throws -> ATProtoKit { + if did == currentDID, let atProto { + return atProto + } + if let cached = repositoryClients[did] { + return cached + } + + let pdsURL = try await repositoryPDSURL(for: did) + let remoteConfiguration = ATProtocolConfiguration(pdsURL: pdsURL.absoluteString) + let client = await ATProtoKit(sessionConfiguration: remoteConfiguration) + repositoryClients[did] = client + return client + } + + private func repositoryPDSURL(for did: String) async throws -> URL { + if let cached = repositoryPDSURLs[did] { + return cached + } + if did == currentDID, + let storedPDS = defaults.string(forKey: storedPDSKey), + let url = URL(string: storedPDS) { + repositoryPDSURLs[did] = url + return url + } + + let identity = try await resolveIdentityViaSlingshot(identifier: did) + guard identity.did == did, let url = URL(string: identity.pds) else { + throw NSError(domain: "LoginStateManager", code: 404, userInfo: [NSLocalizedDescriptionKey: "Could not resolve the repository's PDS."]) + } + repositoryPDSURLs[did] = url + return url + } + + private func listAllRecords( + from did: String, + collection: String, + maximumCount: Int = 1_000 + ) async throws -> [ComAtprotoLexicon.Repository.GetRecordOutput] { + let pdsURL = try await repositoryPDSURL(for: did) + var records: [ComAtprotoLexicon.Repository.GetRecordOutput] = [] + var cursor: String? + + repeat { + var components = URLComponents( + url: pdsURL.appending(path: "xrpc/com.atproto.repo.listRecords"), + resolvingAgainstBaseURL: false + ) + var queryItems = [ + URLQueryItem(name: "repo", value: did), + URLQueryItem(name: "collection", value: collection), + URLQueryItem(name: "limit", value: String(min(100, maximumCount - records.count))) + ] + if let cursor { + queryItems.append(URLQueryItem(name: "cursor", value: cursor)) + } + components?.queryItems = queryItems + guard let url = components?.url else { throw URLError(.badURL) } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + let page = try JSONDecoder().decode(ComAtprotoLexicon.Repository.ListRecordsOutput.self, from: data) + records.append(contentsOf: page.records) + cursor = page.cursor + } while cursor != nil && records.count < maximumCount + + return Array(records.prefix(maximumCount)) + } + + /// Fetches publications from the current user's repository, enriched with + /// their AT-URIs and author DID. + func fetchPublicationsWithURIs() async throws -> [PublicationEntry] { + guard let session = try await atProto?.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + return try await fetchPublications(fromDID: session.sessionDID) + } + + /// Fetches documents from the current user's repository, enriched with + /// their AT-URIs and author DID. + func fetchDocumentsWithURIs() async throws -> [DocumentEntry] { + guard let session = try await atProto?.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + return try await fetchDocuments(fromDID: session.sessionDID) + } + + // MARK: - Publication Creation + + /// Creates a `site.standard.publication` record in the user's repository. + /// + /// - Parameters: + /// - url: The base URL for the publication (e.g. `https://mysite.com`). + /// - name: The display name of the publication. + /// - description: Optional description. + /// - Returns: The strong reference (URI + CID) of the created publication. + @discardableResult + func createPublication( + url: String, + name: String, + description: String? + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + let publication = SiteStandardLexicon.PublicationRecord( + url: url, + name: name, + description: description + ) + let record = UnknownType.record(publication) + return try await atProto.createRecord( + repositoryDID: session.sessionDID, + collection: "site.standard.publication", + shouldValidate: false, + record: record + ) + } + + // MARK: - Recommend API + + /// Creates a `site.standard.graph.recommend` record, endorsing a document. + /// + /// - Parameter documentURI: The AT-URI of the document record to recommend. + /// - Returns: The strong reference of the created recommend. + @discardableResult + func createRecommend(documentURI: String) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + guard ATURI.parse(documentURI)?.collection == SiteStandardLexicon.DocumentRecord.type else { + throw NSError(domain: "LoginStateManager", code: 422, userInfo: [NSLocalizedDescriptionKey: "Recommends must reference a Standard.site document AT-URI."]) + } + + let recommend = SiteStandardLexicon.Graph.RecommendRecord( + document: documentURI, + createdAt: Date() + ) + let record = UnknownType.record(recommend) + return try await atProto.createRecord( + repositoryDID: session.sessionDID, + collection: "site.standard.graph.recommend", + shouldValidate: false, + record: record + ) + } + + /// Fetches all of the user's recommend records. + func fetchRecommends() async throws -> [RecommendEntry] { + guard let atProto = atProto, let session = try await atProto.getUserSession() else { + throw NSError(domain: "LoginStateManager", code: 401, userInfo: [NSLocalizedDescriptionKey: "User not authenticated"]) + } + + _ = atProto + let records = try await listAllRecords( + from: session.sessionDID, + collection: SiteStandardLexicon.Graph.RecommendRecord.type + ) + + var entries: [RecommendEntry] = [] + for record in records { + if let value = record.value, + let rec = value.getRecord(ofType: SiteStandardLexicon.Graph.RecommendRecord.self) { + let rkey = ATURI.parse(record.uri)?.recordKey ?? "" + entries.append(RecommendEntry( + uri: record.uri, + recordKey: rkey, + record: rec + )) + } + } + return entries + } + // MARK: - Helper private func clearSession(clearStoredAccount: Bool = false) { self.isAuthenticated = false self.currentHandle = nil + self.currentDID = nil self.displayName = nil self.avatarURL = nil self.config = nil diff --git a/Inkwell/ReadView.swift b/Inkwell/ReadView.swift new file mode 100644 index 0000000..301ea12 --- /dev/null +++ b/Inkwell/ReadView.swift @@ -0,0 +1,526 @@ +// +// ReadView.swift +// Inkwell +// +// Created by Ewan Croft on 20/06/2026. +// + +import SwiftUI +import ATProtoKit + +struct ReadView: View { + @Environment(LoginStateManager.self) private var loginStateManager + + let document: SiteStandardLexicon.DocumentRecord + let publication: SiteStandardLexicon.PublicationRecord? + var documentURI: String? = nil + var authorDID: String? = nil + + @State private var pages: [LeafletPage] = [] + @State private var markdownContent: String? = nil + @State private var isLoading = true + @State private var errorMessage: String? = nil + @State private var isVerified: Bool? + + // Theme styling matching standard.site's basicTheme colors + private var backgroundColor: Color { + if let rgb = publication?.basicTheme?.background { + return rgb.color + } + return Color(uiColor: .systemBackground) + } + + private var foregroundColor: Color { + if let rgb = publication?.basicTheme?.foreground { + return rgb.color + } + return Color(uiColor: .label) + } + + private var accentColor: Color { + if let rgb = publication?.basicTheme?.accent { + return rgb.color + } + return .accentColor + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Header section + VStack(alignment: .leading, spacing: 12) { + if let pubName = publication?.name { + Text(pubName.uppercased()) + .font(.system(.caption, design: .serif)) + .fontWeight(.bold) + .foregroundStyle(accentColor) + .tracking(2) + } + + Text(document.title) + .font(.system(.largeTitle, design: .serif)) + .fontWeight(.bold) + .foregroundStyle(foregroundColor) + .lineSpacing(4) + + HStack(spacing: 8) { + Text("Published") + Text(formatDate(document.publishedAt)) + .fontWeight(.medium) + + if let path = document.path { + Spacer() + Text(path) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + if let isVerified { + Label( + isVerified ? "Verified source" : "Unverified source", + systemImage: isVerified ? "checkmark.seal.fill" : "exclamationmark.triangle" + ) + .foregroundStyle(isVerified ? Color.green : Color.orange) + } + if let url = document.canonicalURL(publication: publication) { + Link("Open original", destination: url) + } + } + .font(.caption) + + Divider() + .background(accentColor.opacity(0.3)) + } + .padding(.top, 16) + + // Excerpt/description + if let desc = document.description, !desc.isEmpty { + Text(desc) + .font(.system(.body, design: .serif)) + .italic() + .foregroundStyle(.secondary) + .lineSpacing(6) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(accentColor.opacity(0.05)) + ) + } + + // Cover Image + if let cover = document.coverImage, let did = authorDID ?? loginStateManager.currentDID { + let urlString = "https://cdn.bsky.app/img/feed_fullsize/plain/\(did)/\(cover.reference.link)" + if let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.08), radius: 8, y: 4) + } placeholder: { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 200) + } + } + } + + // Content Section + if isLoading { + HStack { + Spacer() + ProgressView("Loading publication content...") + Spacer() + } + .padding(.vertical, 40) + } else if let errorMessage = errorMessage { + ContentUnavailableView("Failed to load content", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + } else if let markdown = markdownContent { + // Multi-format rendering: content was converted to + // markdown by the ContentProvider system. + MarkdownRendererView(markdown: markdown, foregroundColor: foregroundColor, accentColor: accentColor) + } else if !pages.isEmpty { + // Leaflet block rendering (original path, with blob page + // support that the markdown path doesn't need). + ForEach(pages, id: \.self) { page in + if let blocks = page.blocks { + ForEach(blocks.indices, id: \.self) { idx in + renderBlock(blocks[idx].block, alignment: blocks[idx].alignment) + } + } + } + } else { + ContentUnavailableView("Empty Document", systemImage: "doc.text", description: Text("This document has no readable content.")) + } + } + .padding(.horizontal, 20) + } + .background(backgroundColor) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(backgroundColor, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .task { + await loadContent() + } + .task(id: documentURI) { + await verifySource() + } + } + + // MARK: - Content Loader + + private func loadContent() async { + guard let contentUnknown = document.content else { + self.markdownContent = document.textContent + isLoading = false + return + } + + // First, try the ContentProvider system — this handles all + // supported formats (Leaflet, Markpub, Pckt, Offprint) by + // detecting the $type and converting to markdown. + if let provider = ProviderRegistry.detectProvider(contentUnknown) { + let result = provider.toMarkdown(contentUnknown) + if !result.markdown.isEmpty { + self.markdownContent = result.markdown + self.isLoading = false + return + } + } + + // Fallback: try Leaflet-specific decoding with blob page support. + // This path handles Leaflet documents that store their blocks in a + // blob (downloaded separately), which the ContentProvider system + // doesn't handle because it would need async blob fetching. + if let leaflet = contentUnknown.getRecord(ofType: LeafletContent.self) { + if let inlinePages = leaflet.pages, !inlinePages.isEmpty { + self.pages = inlinePages + self.isLoading = false + return + } + + if let blobPages = leaflet.blobPages { + do { + let data: Data + if let authorDID { + data = try await loginStateManager.downloadBlob(cid: blobPages.reference.link, fromDID: authorDID) + } else { + data = try await loginStateManager.downloadBlob(cid: blobPages.reference.link) + } + let decodedPages = try JSONDecoder().decode([LeafletPage].self, from: data) + self.pages = decodedPages + } catch { + self.errorMessage = "Failed to download pages blob: \(error.localizedDescription)" + } + } + } + + if markdownContent == nil && pages.isEmpty { + markdownContent = document.textContent + } + + self.isLoading = false + } + + private func verifySource() async { + guard let documentURI, + document.canonicalURL(publication: publication) != nil else { + return + } + do { + try await SiteStandardLexicon.Verification.verify( + documentURI: documentURI, + document: document, + publication: publication + ) + isVerified = true + } catch { + isVerified = false + } + } + + // MARK: - Block Renderers + + @ViewBuilder + private func renderBlock(_ block: LeafletBlock, alignment: String?) -> some View { + let align: Alignment = { + if let a = alignment { + if a.hasSuffix("textAlignRight") { return .trailing } + if a.hasSuffix("textAlignCenter") { return .center } + } + return .leading + }() + + let textAlignment: TextAlignment = { + if let a = alignment { + if a.hasSuffix("textAlignRight") { return .trailing } + if a.hasSuffix("textAlignCenter") { return .center } + } + return .leading + }() + + VStack(alignment: align.horizontal, spacing: 12) { + switch block.type { + case "pub.leaflet.blocks.header": + let level = block.level ?? 1 + let fontSize: CGFloat = { + switch level { + case 1: return 28 + case 2: return 24 + case 3: return 20 + default: return 18 + } + }() + + Text(renderText(block.plaintext ?? "", facets: block.facets)) + .font(.system(size: fontSize, design: .serif)) + .fontWeight(.bold) + .foregroundStyle(foregroundColor) + .multilineTextAlignment(textAlignment) + .padding(.top, 8) + + case "pub.leaflet.blocks.text": + if let text = block.plaintext, !text.isEmpty { + Text(renderText(text, facets: block.facets)) + .font(.system(.body, design: .serif)) + .foregroundStyle(foregroundColor) + .lineSpacing(6) + .multilineTextAlignment(textAlignment) + } else { + Spacer().frame(height: 12) // empty block spacing + } + + case "pub.leaflet.blocks.blockquote": + HStack(spacing: 0) { + Rectangle() + .fill(accentColor) + .frame(width: 4) + .padding(.trailing, 16) + + Text(renderText(block.plaintext ?? "", facets: block.facets)) + .font(.system(.body, design: .serif)) + .italic() + .foregroundStyle(.secondary) + .lineSpacing(6) + .multilineTextAlignment(textAlignment) + } + .padding(.vertical, 8) + + case "pub.leaflet.blocks.code": + VStack(alignment: .leading, spacing: 6) { + if let lang = block.language, !lang.isEmpty { + Text(lang.uppercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.05)) + .cornerRadius(4) + } + + ScrollView(.horizontal, showsIndicators: false) { + Text(block.plaintext ?? "") + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.primary) + .padding(12) + } + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + .padding(.vertical, 6) + + case "pub.leaflet.blocks.math": + HStack { + Spacer() + Text("$$ \(block.tex ?? "") $$") + .font(.system(.body, design: .serif)) + .italic() + .padding(12) + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + Spacer() + } + .padding(.vertical, 8) + + case "pub.leaflet.blocks.horizontalRule": + Divider() + .background(accentColor.opacity(0.2)) + .padding(.vertical, 12) + + case "pub.leaflet.blocks.image": + if let img = block.image, let did = authorDID ?? loginStateManager.currentDID { + let urlString = "https://cdn.bsky.app/img/feed_fullsize/plain/\(did)/\(img.reference.link)" + if let url = URL(string: urlString) { + VStack(alignment: .center, spacing: 8) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .frame(minHeight: 180) + case .success(let image): + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failure: + Image(systemName: "photo") + .font(.largeTitle) + .foregroundStyle(.secondary) + .frame(minHeight: 180) + @unknown default: + EmptyView() + } + } + + if let alt = block.alt, !alt.isEmpty { + Text(alt) + .font(.caption) + .italic() + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + } + } + + case "pub.leaflet.blocks.unorderedList": + renderList(block.children, ordered: false, startIndex: nil) + + case "pub.leaflet.blocks.orderedList": + renderList(block.children, ordered: true, startIndex: block.startIndex ?? 1) + + default: + // Fallback for unsupported blocks + HStack { + Image(systemName: "questionmark.square.dashed") + .foregroundStyle(.secondary) + Text("Unsupported standard.site content block type") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(8) + .background(Color.primary.opacity(0.02)) + .cornerRadius(6) + } + } + .frame(maxWidth: .infinity, alignment: align) + } + + private func renderList(_ items: [LeafletListItem]?, ordered: Bool, startIndex: Int?) -> AnyView { + if let items = items { + return AnyView( + VStack(alignment: .leading, spacing: 8) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + HStack(alignment: .top, spacing: 8) { + if ordered { + let itemNumber = (startIndex ?? 1) + index + Text("\(itemNumber).") + .font(.system(.body, design: .serif)) + .foregroundStyle(accentColor) + .frame(width: 20, alignment: .trailing) + } else { + Text("•") + .font(.title3) + .foregroundStyle(accentColor) + .frame(width: 20, alignment: .center) + } + + VStack(alignment: .leading, spacing: 8) { + if let content = item.content { + renderBlock(content, alignment: nil) + } + + if let nestedUnordered = item.children { + renderList(nestedUnordered, ordered: false, startIndex: nil) + .padding(.leading, 12) + } + + if let nestedOrdered = item.orderedListChildren?.value.block { + renderBlock(nestedOrdered, alignment: nil) + .padding(.leading, 12) + } + } + } + } + } + .padding(.leading, 4) + ) + } + return AnyView(EmptyView()) + } + + // MARK: - AttributedString Rich Text Parser + + private func renderText(_ plaintext: String, facets: [LeafletFacet]?) -> AttributedString { + var attrString = AttributedString(plaintext) + guard let facets = facets, !facets.isEmpty else { return attrString } + + for facet in facets { + let byteStart = facet.index.byteStart + let byteEnd = facet.index.byteEnd + + guard let range = stringRange(from: byteStart, byteEnd: byteEnd, in: plaintext) else { continue } + guard let attrRange = Range(range, in: attrString) else { continue } + + for feature in facet.features { + switch feature.type { + case "pub.leaflet.richtext.facet#bold": + attrString[attrRange].inlinePresentationIntent = .stronglyEmphasized + case "pub.leaflet.richtext.facet#italic": + attrString[attrRange].inlinePresentationIntent = .emphasized + case "pub.leaflet.richtext.facet#code": + attrString[attrRange].font = .system(.body, design: .monospaced) + case "pub.leaflet.richtext.facet#strikethrough": + attrString[attrRange].strikethroughStyle = .single + case "pub.leaflet.richtext.facet#link": + if let uriString = feature.uri, let url = URL(string: uriString) { + attrString[attrRange].link = url + } + default: + break + } + } + } + + return attrString + } + + private func stringRange(from byteStart: Int, byteEnd: Int, in string: String) -> Range? { + let utf8 = string.utf8 + guard byteStart >= 0, byteEnd >= byteStart, byteEnd <= utf8.count else { return nil } + + guard let startIdx = utf8.index(utf8.startIndex, offsetBy: byteStart, limitedBy: utf8.endIndex), + let endIdx = utf8.index(utf8.startIndex, offsetBy: byteEnd, limitedBy: utf8.endIndex) else { + return nil + } + + return startIdx.. String { + let outputFormatter = DateFormatter() + outputFormatter.dateStyle = .medium + outputFormatter.timeStyle = .none + return outputFormatter.string(from: date) + } +} + +extension Alignment { + var horizontal: HorizontalAlignment { + switch self { + case .trailing: return .trailing + case .center: return .center + default: return .leading + } + } +} diff --git a/Inkwell/StandardReaderAPI.swift b/Inkwell/StandardReaderAPI.swift new file mode 100644 index 0000000..d7186d1 --- /dev/null +++ b/Inkwell/StandardReaderAPI.swift @@ -0,0 +1,89 @@ +// +// StandardReaderAPI.swift +// Inkwell +// +// Search client for pub search, the maintained cross-platform index listed +// by Standard.site. The index covers Standard.site plus legacy Leaflet and +// WhiteWind records; record values are still fetched from their owning PDS. +// + +import Foundation + +struct ReaderSearchResult: Identifiable, Codable, Equatable, Hashable { + let type: String + let uri: String + let did: String + let title: String + let snippet: String? + let createdAt: String? + let rkey: String? + let basePath: String? + let platform: String? + let path: String? + let coverImage: String? + let handle: String? + + var id: String { uri } + var isPublication: Bool { type == "publication" } + var isStandardSiteDocument: Bool { + ATURI.parse(uri)?.collection == SiteStandardLexicon.DocumentRecord.type + } + + var createdDate: Date? { + guard let createdAt else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: createdAt) ?? ISO8601DateFormatter().date(from: createdAt) + } + + var webURL: URL? { + guard let basePath, !basePath.isEmpty else { return nil } + let origin = basePath.hasPrefix("http") ? basePath : "https://\(basePath)" + if isPublication { return URL(string: origin) } + if let path, !path.isEmpty { + return URL(string: path.hasPrefix("/") ? origin + path : origin + "/" + path) + } + guard platform == "leaflet", let rkey else { return nil } + return URL(string: origin + "/" + rkey) + } +} + +struct ReaderSearchResponse: Decodable { + let results: [ReaderSearchResult] + let total: Int + let hasMore: Bool +} + +final class StandardReaderAPI { + static let shared = StandardReaderAPI() + + private let baseURL = URL(string: "https://leaflet-search-backend.fly.dev")! + private let session: URLSession + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 30 + session = URLSession(configuration: configuration) + } + + func search(query: String, limit: Int = 40) async throws -> ReaderSearchResponse { + var components = URLComponents( + url: baseURL.appending(path: "search"), + resolvingAgainstBaseURL: false + )! + components.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "mode", value: "keyword"), + URLQueryItem(name: "limit", value: String(max(1, min(limit, 100)))), + URLQueryItem(name: "format", value: "v2") + ] + + let (data, response) = try await session.data(from: components.url!) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return try JSONDecoder().decode(ReaderSearchResponse.self, from: data) + } +} diff --git a/Inkwell/SubscriptionsView.swift b/Inkwell/SubscriptionsView.swift new file mode 100644 index 0000000..29651f9 --- /dev/null +++ b/Inkwell/SubscriptionsView.swift @@ -0,0 +1,249 @@ +// +// SubscriptionsView.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// +// Shows the user's subscribed publications and a unified feed of documents +// from all of them. Subscriptions are `site.standard.graph.subscription` +// records in the user's repo; each one points at a publication record's +// AT-URI. To build the feed, we parse the subscription's publication URI +// to get the author's DID, then fetch documents from that author's repo. +// +// This is the "reading from other people" half of the reader: +// BrowseDocumentsView shows the user's own documents, while this view +// shows documents from publications the user has chosen to follow. +// + +import SwiftUI + +struct SubscriptionsView: View { + @Environment(LoginStateManager.self) private var loginStateManager + @State private var notificationManager = NotificationManager.shared + + @State private var subscriptions: [SubscriptionEntry] = [] + @State private var feed: [FeedItem] = [] + @State private var isLoading = true + @State private var errorMessage: String? + + /// A document from a subscribed publication, with the publication context. + struct FeedItem: Identifiable { + let document: DocumentEntry + let publication: PublicationEntry? + var id: String { document.uri } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading subscriptions...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = errorMessage { + ContentUnavailableView( + "Failed to Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if subscriptions.isEmpty { + ContentUnavailableView( + "No Subscriptions", + systemImage: "bell.slash", + description: Text("Discover publications in the Discover tab and subscribe to see their latest documents here.") + ) + } else { + List { + if !notificationManager.notifications.isEmpty { + Section("Updates") { + ForEach(notificationManager.notifications) { notification in + NavigationLink { + RemoteDocumentView(documentURI: notification.documentURI) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(notification.documentTitle) + .font(.headline) + if let publicationName = notification.publicationName { + Text(publicationName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + Button("Clear Updates", role: .destructive) { + notificationManager.clearAll() + } + } + } + + // MARK: - Subscriptions list + Section("Following") { + ForEach(subscriptions) { sub in + SubscriptionRow(subscription: sub) + } + } + + // MARK: - Feed + if !feed.isEmpty { + Section("Latest from Subscriptions") { + ForEach(feed) { item in + NavigationLink { + ReadView( + document: item.document.record, + publication: item.publication?.record, + documentURI: item.document.uri, + authorDID: item.document.authorDID + ) + } label: { + FeedDocumentRow(item: item) + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await loadData() + } + } + } + .navigationTitle("Subscriptions") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { await loadData() } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + .task { + await loadData() + notificationManager.markAllAsRead() + } + } + } + + // MARK: - Data Loading + + private func loadData() async { + isLoading = true + errorMessage = nil + + do { + let subs = try await loginStateManager.fetchSubscriptions() + await MainActor.run { + self.subscriptions = subs + } + + // For each subscription, fetch documents from the publication author's repo. + var items: [FeedItem] = [] + for sub in subs { + guard let pubURI = sub.publicationURI else { continue } + + // Fetch the publication record to get its metadata. + let pubs: [PublicationEntry] = (try? await loginStateManager.fetchPublications(fromDID: pubURI.did)) ?? [] + let pubEntry = pubs.first(where: { $0.uri == sub.record.publication }) + + // Fetch documents from the publication author's repo. + let docs: [DocumentEntry] = (try? await loginStateManager.fetchDocuments(fromDID: pubURI.did)) ?? [] + + // Filter documents that belong to this publication. + let pubDocs: [DocumentEntry] + if let pubEntry { + pubDocs = docs.filter { pubEntry.contains($0.record) } + } else { + pubDocs = docs.filter { $0.record.site == sub.record.publication } + } + + for doc in pubDocs { + items.append(FeedItem(document: doc, publication: pubEntry)) + } + } + + // Sort by published date descending. + items.sort { $0.document.record.publishedAt > $1.document.record.publishedAt } + + await MainActor.run { + self.feed = items + self.isLoading = false + } + } catch { + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } +} + +// MARK: - Subviews + +struct SubscriptionRow: View { + let subscription: SubscriptionEntry + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "bell.fill") + .foregroundStyle(Color.accentColor) + .font(.title3) + + VStack(alignment: .leading, spacing: 4) { + if let pubURI = subscription.publicationURI { + Text(pubURI.did) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Text(subscription.record.publication) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 4) + } +} + +struct FeedDocumentRow: View { + let item: SubscriptionsView.FeedItem + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + if let pubName = item.publication?.record.name { + Text(pubName.uppercased()) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .tracking(1) + } + + Text(item.document.record.title) + .font(.system(.body, design: .serif)) + .fontWeight(.semibold) + .lineLimit(2) + + if let desc = item.document.record.description, !desc.isEmpty { + Text(desc) + .font(.system(.caption, design: .serif)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(formatDate(item.document.record.publishedAt)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} diff --git a/Inkwell/WriteView.swift b/Inkwell/WriteView.swift new file mode 100644 index 0000000..4b55543 --- /dev/null +++ b/Inkwell/WriteView.swift @@ -0,0 +1,374 @@ +// +// WriteView.swift +// Inkwell +// +// Created by Letta on 20/06/2026. +// +// Universal markdown editor for writing standard.site documents in any +// supported format (Leaflet, Markpub, Pckt, Offprint). The editor works +// in markdown; the selected provider converts to/from the format-specific +// AT Protocol record on publish. +// + +import SwiftUI +import ATProtoKit + +struct WriteView: View { + @Environment(LoginStateManager.self) private var loginStateManager + + @State private var publications: [PublicationEntry] = [] + @State private var selectedPublication: PublicationEntry? + @State private var selectedProviderId: String = ProviderRegistry.defaultProvider.id + @State private var title: String = "" + @State private var description: String = "" + @State private var markdown: String = "" + @State private var path: String = "" + @State private var isPublishing = false + @State private var publishError: String? + @State private var publishSuccess: String? + @State private var isLoadingPublications = true + @State private var showCreatePublication = false + @State private var verifiedPublicationURI: String? + @State private var verificationMessage: String? + @State private var isVerifyingPublication = false + + var body: some View { + NavigationStack { + Form { + // MARK: - Publication + if isLoadingPublications { + HStack { + ProgressView() + Text("Loading publications...") + } + } else if publications.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("No publications found.") + .font(.callout) + .foregroundStyle(.secondary) + Button { + showCreatePublication = true + } label: { + Label("Create a Publication", systemImage: "plus.circle") + } + } + } else { + Picker("Publication", selection: $selectedPublication) { + ForEach(publications) { publication in + Text(publication.record.name).tag(Optional(publication)) + } + } + Button { + showCreatePublication = true + } label: { + Label("New Publication", systemImage: "plus") + } + } + + if let publication = selectedPublication { + Section("Verification") { + Label( + verifiedPublicationURI == publication.uri ? "Verified publication" : "Verification required", + systemImage: verifiedPublicationURI == publication.uri ? "checkmark.seal.fill" : "exclamationmark.triangle" + ) + .foregroundStyle(verifiedPublicationURI == publication.uri ? Color.green : Color.orange) + + if let verificationMessage { + Text(verificationMessage) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Button("Verify Again") { + Task { await verifySelectedPublication() } + } + .disabled(isVerifyingPublication) + } + } + + // MARK: - Format Provider + Picker("Format", selection: $selectedProviderId) { + ForEach(ProviderRegistry.providers, id: \.id) { provider in + Text(provider.label).tag(provider.id) + } + } + + // MARK: - Document metadata + TextField("Title", text: $title) + TextField("Description", text: $description, axis: .vertical) + .lineLimit(2...4) + TextField("Path (optional)", text: $path) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + // MARK: - Markdown editor + Section("Content") { + TextEditor(text: $markdown) + .frame(minHeight: 300) + .font(.body.monospaced()) + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled(false) + } + + // MARK: - Publish + if let error = publishError { + Text(error) + .foregroundStyle(.red) + .font(.callout) + } + if let success = publishSuccess { + Text(success) + .foregroundStyle(.green) + .font(.callout) + } + Button { + publish() + } label: { + if isPublishing { + HStack { + ProgressView() + Text("Publishing...") + } + } else { + Label("Publish", systemImage: "paperplane.fill") + } + } + .disabled( + isPublishing || publications.isEmpty || title.isEmpty || + verifiedPublicationURI != selectedPublication?.uri + ) + } + .navigationTitle("Write") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(role: .destructive, action: loginStateManager.signOut) { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } + ToolbarItem(placement: .principal) { + HStack(spacing: 8) { + avatarView + Text(timeZoneAwareGreeting) + .font(.headline) + } + } + } + .task { + await loadPublications() + } + .task(id: selectedPublication?.uri) { + await verifySelectedPublication() + } + .sheet(isPresented: $showCreatePublication) { + CreatePublicationView { url, name, desc in + Task { + do { + let reference = try await loginStateManager.createPublication( + url: url, + name: name, + description: desc + ) + await loadPublications(selecting: reference.recordURI) + publishSuccess = "Publication record created. Configure its verification endpoint before publishing." + } catch { + publishError = "Failed to create publication: \(error.localizedDescription)" + } + } + } + } + } + } + + // MARK: - Actions + + private func loadPublications(selecting uri: String? = nil) async { + isLoadingPublications = true + do { + publications = try await loginStateManager.fetchPublicationsWithURIs() + selectedPublication = uri.flatMap { selectedURI in + publications.first(where: { $0.uri == selectedURI }) + } ?? publications.first + } catch { + publishError = "Failed to load publications: \(error.localizedDescription)" + } + isLoadingPublications = false + } + + private func publish() { + guard let pub = selectedPublication, + let provider = ProviderRegistry.providerById(selectedProviderId) else { + publishError = "Select a publication and format." + return + } + + guard !title.isEmpty else { + publishError = "Title is required." + return + } + + guard verifiedPublicationURI == pub.uri else { + publishError = "Verify the publication domain before publishing." + return + } + + isPublishing = true + publishError = nil + publishSuccess = nil + + Task { + do { + let reference = try await loginStateManager.createDocument( + title: title, + description: description.isEmpty ? nil : description, + path: path.isEmpty ? nil : path, + site: pub.uri, + markdown: markdown, + provider: provider + ) + let linkTag = SiteStandardLexicon.Verification.discoveryLinkTag( + forRecordURI: reference.recordURI, + relation: SiteStandardLexicon.DocumentRecord.type + ) + publishSuccess = "Published \(reference.recordURI)\nAdd this to the document page's :\n\(linkTag)" + // Reset form + title = "" + description = "" + path = "" + markdown = "" + } catch { + publishError = "Failed to publish: \(error.localizedDescription)" + } + isPublishing = false + } + } + + private func verifySelectedPublication() async { + guard let publication = selectedPublication else { + verifiedPublicationURI = nil + verificationMessage = nil + return + } + + isVerifyingPublication = true + defer { isVerifyingPublication = false } + do { + try await SiteStandardLexicon.Verification.verify( + publicationURI: publication.uri, + publication: publication.record + ) + verifiedPublicationURI = publication.uri + verificationMessage = "The publication domain points back to this record." + } catch { + verifiedPublicationURI = nil + let endpoint = SiteStandardLexicon.Verification.publicationVerificationURL( + for: publication.record.url + )?.absoluteString ?? publication.record.url + verificationMessage = "Serve \(publication.uri) as plain text from \(endpoint), then verify again." + } + } + + // MARK: - Header views (shared with BrowseDocumentsView) + + private var avatarView: some View { + AsyncImage(url: loginStateManager.avatarURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + + private var timeZoneAwareGreeting: String { + let hour = Calendar.current.component(.hour, from: Date()) + let period: String + switch hour { + case 5..<12: period = "morning" + case 12..<17: period = "afternoon" + case 17..<22: period = "evening" + default: period = "night" + } + if let name = loginStateManager.displayName { + return "Good \(period), \(name)" + } else { + return "Good \(period)" + } + } +} + +// MARK: - Create Publication View + +struct CreatePublicationView: View { + @Environment(\.dismiss) private var dismiss + + let onCreate: (String, String, String?) -> Void + + @State private var url = "" + @State private var name = "" + @State private var description = "" + @State private var isCreating = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField("URL (e.g. https://mysite.com)", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Publication Name", text: $name) + TextField("Description (optional)", text: $description, axis: .vertical) + .lineLimit(2...4) + } + Section { + Button { + create() + } label: { + if isCreating { + HStack { + ProgressView() + Text("Creating...") + } + } else { + Label("Create Publication", systemImage: "plus.circle") + } + } + .disabled(url.isEmpty || name.isEmpty || isCreating) + } + } + .navigationTitle("New Publication") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + } + } + } + + private func create() { + isCreating = true + var trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + while trimmedURL.hasSuffix("/") { + trimmedURL.removeLast() + } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDesc = description.trimmingCharacters(in: .whitespacesAndNewlines) + + onCreate(trimmedURL, trimmedName, trimmedDesc.isEmpty ? nil : trimmedDesc) + isCreating = false + dismiss() + } +} + +#Preview { + WriteView() + .environment(LoginStateManager()) +}