117 lines
3.7 KiB
Swift
117 lines
3.7 KiB
Swift
|
|
import Foundation
|
||
|
|
import Combine
|
||
|
|
|
||
|
|
/// Imports a user's **social profile** (Instagram, X) to enrich a revealed
|
||
|
|
/// connection.
|
||
|
|
///
|
||
|
|
/// **Critical privacy boundary:** This service is *never* used to build the
|
||
|
|
/// user's own discoverable account. It is only invoked at **reveal time**,
|
||
|
|
/// after a mutual encounter, when the user explicitly chooses to share a
|
||
|
|
/// richer identity with someone they've actually met.
|
||
|
|
///
|
||
|
|
/// Two separate identities exist in Proximity:
|
||
|
|
/// - **Account identity** (AuthService): who you are *to the app* — private.
|
||
|
|
/// - **Social identity** (this service): what you *choose to reveal* — opt-in.
|
||
|
|
///
|
||
|
|
/// Importing a profile is like handing someone your business card after
|
||
|
|
/// meeting them — not broadcasting it to the world.
|
||
|
|
@MainActor
|
||
|
|
final class ProfileImportService: ObservableObject {
|
||
|
|
|
||
|
|
@Published private(set) var importedProfiles: [ImportedProfile] = []
|
||
|
|
|
||
|
|
private let providers: [ProfileProviderID: ProfileProvider]
|
||
|
|
|
||
|
|
init(providers: [ProfileProviderID: ProfileProvider] = [
|
||
|
|
.instagram: InstagramProfileProvider(),
|
||
|
|
.x: XProfileProvider()
|
||
|
|
]) {
|
||
|
|
self.providers = providers
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Import a profile from a provider. Called only at reveal time.
|
||
|
|
func importProfile(from providerID: ProfileProviderID) async throws -> ImportedProfile {
|
||
|
|
guard let provider = providers[providerID] else {
|
||
|
|
throw ProfileImportError.providerUnavailable
|
||
|
|
}
|
||
|
|
let profile = try await provider.fetchProfile()
|
||
|
|
importedProfiles.append(profile)
|
||
|
|
return profile
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Attach an imported profile to a revealed connection.
|
||
|
|
/// This is the *only* place a social identity can be linked to a person.
|
||
|
|
func attach(_ profile: ImportedProfile, to connectionID: UUID) {
|
||
|
|
// In production: persist the link locally, E2E-encrypted.
|
||
|
|
// The server never stores the raw social profile by default.
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove an imported profile (user revokes a shared identity).
|
||
|
|
func remove(_ profile: ImportedProfile) {
|
||
|
|
importedProfiles.removeAll { $0.id == profile.id }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A social profile imported at reveal time.
|
||
|
|
struct ImportedProfile: Identifiable, Codable, Hashable {
|
||
|
|
let id: UUID
|
||
|
|
let providerID: ProfileProviderID
|
||
|
|
let handle: String
|
||
|
|
let displayName: String?
|
||
|
|
let bio: String?
|
||
|
|
let avatarURL: URL?
|
||
|
|
let importedAt: Date
|
||
|
|
|
||
|
|
init(
|
||
|
|
id: UUID = UUID(),
|
||
|
|
providerID: ProfileProviderID,
|
||
|
|
handle: String,
|
||
|
|
displayName: String? = nil,
|
||
|
|
bio: String? = nil,
|
||
|
|
avatarURL: URL? = nil,
|
||
|
|
importedAt: Date = Date()
|
||
|
|
) {
|
||
|
|
self.id = id
|
||
|
|
self.providerID = providerID
|
||
|
|
self.handle = handle
|
||
|
|
self.displayName = displayName
|
||
|
|
self.bio = bio
|
||
|
|
self.avatarURL = avatarURL
|
||
|
|
self.importedAt = importedAt
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Providers that can supply a social profile.
|
||
|
|
enum ProfileProviderID: String, Codable, CaseIterable {
|
||
|
|
case instagram
|
||
|
|
case x
|
||
|
|
|
||
|
|
var displayName: String {
|
||
|
|
switch self {
|
||
|
|
case .instagram: return "Instagram"
|
||
|
|
case .x: return "X"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Errors from profile import.
|
||
|
|
enum ProfileImportError: Error, LocalizedError {
|
||
|
|
case providerUnavailable
|
||
|
|
case authorizationFailed
|
||
|
|
case noProfile
|
||
|
|
|
||
|
|
var errorDescription: String? {
|
||
|
|
switch self {
|
||
|
|
case .providerUnavailable: return "This profile provider is unavailable."
|
||
|
|
case .authorizationFailed: return "Authorization with the provider failed."
|
||
|
|
case .noProfile: return "No profile was returned."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A provider capable of fetching a user's own social profile.
|
||
|
|
protocol ProfileProvider {
|
||
|
|
var providerID: ProfileProviderID { get }
|
||
|
|
func fetchProfile() async throws -> ImportedProfile
|
||
|
|
}
|