Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions App/Features/Settings/IntegrationsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// IntegrationsView
//
// Settings ▸ Integrations (GitHub #47 / G33) — every connected provider in one
// place, with Link, Verify and Disconnect as each row's actions.
//
// `LinkedAccountsView` could only **link**. Once an account was connected there
// was no way to check it, reconfigure it, or remove it from the Mac.
//
// The rows are **data-driven** rather than a switch statement per provider: what
// a row offers comes from `IdentityProvider`'s capability flags, so adding a
// provider does not mean finding every place that enumerates them. That is also
// what makes Mastodon work — it is the one provider that supports several
// connections, and it renders as one row per instance.
//
// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`.

import SwiftUI
import InterlinedDomain

struct IntegrationsView: View {

@Environment(\.appEnvironment) private var environment
@Environment(\.openURL) private var openURL
@State private var viewModel: IntegrationsViewModel?
@State private var pendingDisconnect: LinkedIdentity?

var body: some View {
Form {
if let viewModel {
connectedSection(viewModel)
githubSection(viewModel)
connectSection(viewModel)
if let error = viewModel.loadError {
Section {
Text(error.localizedDescription)
.font(.ilMono(10))
.foregroundStyle(.orange)
}
}
}
}
.formStyle(.grouped)
.task {
guard viewModel == nil, let environment else { return }
let model = IntegrationsViewModel(user: environment.userService)
viewModel = model
await model.load()
}
.confirmationDialog(
"Disconnect \(pendingDisconnect?.provider.displayName ?? "")?",
isPresented: Binding(
get: { pendingDisconnect != nil },
set: { if !$0 { pendingDisconnect = nil } }
),
titleVisibility: .visible
) {
Button("Disconnect", role: .destructive) {
guard let identity = pendingDisconnect, let viewModel else { return }
pendingDisconnect = nil
Task { await viewModel.disconnect(identity) }
}
Button("Cancel", role: .cancel) { pendingDisconnect = nil }
} message: {
// The consequence is the question. A generic "are you sure" tells
// the user nothing they did not already know.
if let identity = pendingDisconnect, let viewModel {
Text(viewModel.disconnectConsequence(for: identity))
}
}
}

// MARK: - Connected

@ViewBuilder
private func connectedSection(_ viewModel: IntegrationsViewModel) -> some View {
Section("Connected") {
if viewModel.isLoading && viewModel.identities.isEmpty {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Loading connections…").foregroundStyle(.secondary)
}
} else if viewModel.connectedIdentities.isEmpty {
Text("No accounts are connected yet.")
.font(.ilMono(10))
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.connectedIdentities) { identity in
identityRow(identity, viewModel: viewModel)
}
}
}
}

@ViewBuilder
private func identityRow(
_ identity: LinkedIdentity,
viewModel: IntegrationsViewModel
) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Image(systemName: identity.provider.iconName)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
// The instance is part of the identity for Mastodon, so it
// belongs in the title — two rows reading "Mastodon" would
// be indistinguishable.
Text(rowTitle(identity))
.font(.ilBody())
if let handle = identity.handle, !handle.isEmpty {
Text(handle)
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)

if identity.provider.isVerifiable {
Button("Verify") {
Task { await viewModel.verify(identity) }
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(viewModel.isVerifying(identity) || viewModel.isDisconnecting(identity))
}
if identity.provider.isDisconnectable {
Button("Disconnect", role: .destructive) {
pendingDisconnect = identity
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(viewModel.isDisconnecting(identity))
}
if viewModel.isVerifying(identity) || viewModel.isDisconnecting(identity) {
ProgressView().controlSize(.mini)
}
}

statusLine(identity, viewModel: viewModel)
}
.padding(.vertical, 2)
}

private func rowTitle(_ identity: LinkedIdentity) -> String {
guard let instance = identity.instance, !instance.isEmpty else {
return identity.provider.displayName
}
return "\(identity.provider.displayName) · \(instance)"
}

@ViewBuilder
private func statusLine(
_ identity: LinkedIdentity,
viewModel: IntegrationsViewModel
) -> some View {
// Precedence matters: an error from this row's own last action is more
// useful than a stale verify result, and both beat the connected-at
// date. One line, not three.
if let failure = viewModel.rowErrors[identity.id] {
Text(failure)
.font(.ilMono(10))
.foregroundStyle(.orange)
} else if let verified = viewModel.verifyResults[identity.id] {
Label(
verified ? "Connection is working" : "Connection isn't responding — reconnect it",
systemImage: verified ? "checkmark.circle" : "exclamationmark.triangle"
)
.font(.ilMono(10))
.foregroundStyle(verified ? Color.secondary : Color.orange)
} else if let lastVerified = identity.lastVerifiedAt {
Text("Last checked \(lastVerified.formatted(date: .abbreviated, time: .omitted))")
.font(.ilMono(10))
.foregroundStyle(.secondary)
} else if let connectedAt = identity.connectedAt {
Text("Connected \(connectedAt.formatted(date: .abbreviated, time: .omitted))")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}

// MARK: - GitHub

@ViewBuilder
private func githubSection(_ viewModel: IntegrationsViewModel) -> some View {
if let github = viewModel.github {
Section("GitHub") {
if !github.isConfigured {
// A different message from "you have not linked GitHub":
// no amount of user action fixes this one.
Text("GitHub sign-in isn't configured on InterlinedList, so linking a GitHub account won't work right now.")
.font(.ilMono(10))
.foregroundStyle(.orange)
} else if let url = github.manageOrgAccessURL {
Button("Update organization access…") { openURL(url) }
Text("Opens GitHub, where you grant or revoke this app's access to your organizations. Needed before a GitHub-backed list can read an org's issues.")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
}
}

// MARK: - Connect

@ViewBuilder
private func connectSection(_ viewModel: IntegrationsViewModel) -> some View {
Section("Connect an account") {
ForEach(viewModel.connectableProviders, id: \.wireToken) { provider in
HStack(spacing: 8) {
Image(systemName: provider.iconName)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(provider.displayName)
if provider.supportsMultipleInstances {
Text("· you can connect several instances")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
Spacer()
}
}
// Linking itself stays in the Linked Accounts pane, which owns the
// OAuth session and the Mastodon instance prompt. Duplicating that
// flow here would mean two copies of an authorization handshake.
Text("Connecting an account happens in Settings ▸ Linked accounts, which handles the sign-in window.")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
}
}
166 changes: 166 additions & 0 deletions App/Features/Settings/IntegrationsViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// IntegrationsViewModel
//
// Drives Settings ▸ Integrations (GitHub #47 / G33) — the pane that manages
// every connected provider in one place.
//
// `LinkedAccountsView` could only **link**. Once an account was connected there
// was no way to check it, reconfigure it, or remove it from the Mac; the web's
// `/integrations` page does all four.
//
// Three things here come from the 2026-09-15 probe rather than from the docs:
//
// - **Mastodon arrives instance-qualified** (`"mastodon:techhub.social"`), so
// every Mastodon identity used to decode as an unknown provider. Fixed in
// `IdentityProvider`; this pane is where it becomes visible, as one row per
// instance.
// - **X was missing from the client's provider list**, despite the account
// having a linked X identity and cross-posting to X being shipped.
// - **`GET /api/user/identities` accepts Bearer**, contrary to its `.session`
// annotation — a redundant cookie login on every load.
//
// Per Decision 0003 this view model consumes only `InterlinedDomain`.

import Foundation
import Observation
import InterlinedDomain

@MainActor
@Observable
final class IntegrationsViewModel {

private let user: UserServicing

// MARK: - Observable state

private(set) var identities: [LinkedIdentity] = []

/// The app's GitHub configuration, when the status read succeeded.
private(set) var github: GitHubConnection?

private(set) var isLoading: Bool = false
private(set) var loadError: Error?

/// Identities with a verify in flight, keyed by identity id.
private(set) var verifyingIDs: Set<String> = []

/// Identities with a disconnect in flight.
private(set) var disconnectingIDs: Set<String> = []

/// The outcome of the most recent verify per identity. `true` means the
/// connection answered; `false` means it did not.
///
/// Per-row on purpose: a pane with five providers and one shared status
/// line cannot say *which* connection is broken, which is the only thing
/// the user needs to know.
private(set) var verifyResults: [String: Bool] = [:]

/// The error from the most recent failed action on a row, keyed by identity
/// id, so a failure reports where it happened.
private(set) var rowErrors: [String: String] = [:]

// MARK: - Projections

/// Every provider the pane shows a row for, in a stable order: the
/// connected ones first, then the ones that could be connected.
var connectedIdentities: [LinkedIdentity] {
identities.sorted { lhs, rhs in
if lhs.provider.displayName != rhs.provider.displayName {
return lhs.provider.displayName < rhs.provider.displayName
}
// Several Mastodon instances sort by host, so the list is stable
// across loads rather than following server order.
return (lhs.instance ?? "") < (rhs.instance ?? "")
}
}

/// Providers with no connection yet.
///
/// Mastodon is **never** listed here even when instances are already
/// connected: it supports several, so "connect another instance" stays
/// available. Every other provider disappears from this list once linked.
var connectableProviders: [IdentityProvider] {
let connected = Set(identities.map(\.provider))
return [.github, .mastodon, .bluesky, .linkedin, .twitter].filter { provider in
provider.supportsMultipleInstances || !connected.contains(provider)
}
}

// MARK: - Init

init(user: UserServicing) {
self.user = user
}

// MARK: - Intents

func load() async {
isLoading = true
loadError = nil
defer { isLoading = false }
do {
identities = try await user.identities()
} catch {
loadError = error
return
}
// The GitHub status is a soft follow-up: the pane is fully usable
// without it, and only the GitHub row loses an affordance.
github = try? await user.githubConnectionStatus()
}

func verify(_ identity: LinkedIdentity) async {
guard !verifyingIDs.contains(identity.id) else { return }
verifyingIDs.insert(identity.id)
rowErrors[identity.id] = nil
defer { verifyingIDs.remove(identity.id) }
do {
verifyResults[identity.id] = try await user.verifyIdentity(identity)
} catch {
// A failed verify is not a failed connection — the request itself
// did not complete — so the row reports the error rather than
// claiming the provider is broken.
rowErrors[identity.id] = error.localizedDescription
}
}

/// Disconnects a provider.
///
/// Not optimistic. Disconnecting stops cross-posting, and a row that
/// disappears and then comes back because the write failed is worse than a
/// row that waits — the user needs to know whether it actually happened.
func disconnect(_ identity: LinkedIdentity) async {
guard !disconnectingIDs.contains(identity.id) else { return }
disconnectingIDs.insert(identity.id)
rowErrors[identity.id] = nil
defer { disconnectingIDs.remove(identity.id) }
do {
try await user.unlinkIdentity(identity)
identities.removeAll { $0.id == identity.id }
verifyResults[identity.id] = nil
} catch {
rowErrors[identity.id] = error.localizedDescription
}
}

/// What a disconnect confirmation should say stops working.
///
/// Named per provider rather than a generic "are you sure": the consequence
/// is the question, and it differs — losing GitHub takes GitHub-backed lists
/// with it, losing a cross-post provider silently stops those posts.
func disconnectConsequence(for identity: LinkedIdentity) -> String {
switch identity.provider {
case .github:
return "Disconnecting GitHub stops GitHub-backed lists from syncing and signs this app out of GitHub Issues."
case .mastodon:
let host = identity.instance.map { " on \($0)" } ?? ""
return "Disconnecting Mastodon\(host) stops cross-posting to that instance."
case .bluesky, .linkedin, .twitter:
return "Disconnecting \(identity.provider.displayName) stops cross-posting to it."
case .other(let raw):
return "Disconnecting \(raw) may stop features that depend on it."
}
}

func isVerifying(_ identity: LinkedIdentity) -> Bool { verifyingIDs.contains(identity.id) }
func isDisconnecting(_ identity: LinkedIdentity) -> Bool { disconnectingIDs.contains(identity.id) }
}
Loading