Release recipient inbox SDKs 0.1.0 preview
This commit is contained in:
commit
d0781a9f3b
23 files changed
+1846
No files matched your search
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
private final class InboxRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
static let shared=InboxRedirectPolicy()
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {completionHandler(nil)}
|
||||
}
|
||||
|
||||
/// Recipient-only client. Fetch recipient tokens from your own backend; never embed business API keys.
|
||||
public final class InboxClient: @unchecked Sendable {
|
||||
public let endpoint: URL
|
||||
private let token: @Sendable () async throws -> String
|
||||
private let session: URLSession
|
||||
private let timeout: TimeInterval
|
||||
private let lock=NSLock()
|
||||
private var closed=false
|
||||
private var cancellers=[UUID: @Sendable () -> Void]()
|
||||
public init(endpoint: URL = URL(string:"https://notifications.wrnexus.com")!, timeout: TimeInterval = 15, session: URLSession = .shared, token: @escaping @Sendable () async throws -> String) throws {
|
||||
guard Self.validEndpoint(endpoint), timeout > 0 else { throw InboxError(code:"invalid-endpoint",message:"Use an HTTPS origin or localhost for development, with a positive timeout.") }
|
||||
self.endpoint=endpoint; self.timeout=timeout; self.session=session; self.token=token
|
||||
}
|
||||
private static func validEndpoint(_ u: URL) -> Bool {
|
||||
guard u.host != nil, u.user == nil, u.password == nil, u.query == nil, u.fragment == nil, ["","/"].contains(u.path) else { return false }
|
||||
return u.scheme == "https" || (u.scheme == "http" && ["localhost","127.0.0.1","[::1]","::1"].contains(u.host!))
|
||||
}
|
||||
public func close() { lock.lock(); closed=true; let callbacks=Array(cancellers.values); cancellers.removeAll(); lock.unlock(); for cancel in callbacks {cancel()} }
|
||||
private func track(_ id: UUID, _ cancel: @escaping @Sendable () -> Void) {lock.lock(); let stop=closed; if !stop {cancellers[id]=cancel}; lock.unlock(); if stop {cancel()} }
|
||||
private func untrack(_ id: UUID) {lock.lock();cancellers.removeValue(forKey:id);lock.unlock()}
|
||||
private func isClosed() -> Bool { lock.lock(); defer {lock.unlock()}; return closed }
|
||||
private func request(_ path: String, method: String = "GET", body: Data? = nil, stream: Bool = false) async throws -> URLRequest {
|
||||
guard !isClosed() else { throw InboxError(code:"closed",message:"The recipient session is closed.") }
|
||||
let value=try await token()
|
||||
guard !value.isEmpty, !value.contains("\r"), !value.contains("\n") else { throw InboxError(code:"recipient-token-required",message:"Pass a recipient token.") }
|
||||
guard !isClosed() else { throw CancellationError() }
|
||||
var r=URLRequest(url:URL(string:"/api/v1/inbox/"+path,relativeTo:endpoint)!,timeoutInterval:stream ? 75 : timeout)
|
||||
r.httpMethod=method; r.httpBody=body; r.setValue("Bearer "+value,forHTTPHeaderField:"Authorization")
|
||||
r.setValue(stream ? "text/event-stream" : "application/json",forHTTPHeaderField:"Accept")
|
||||
if body != nil { r.setValue("application/json",forHTTPHeaderField:"Content-Type") }; return r
|
||||
}
|
||||
static func decoder() -> JSONDecoder { let d=JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d }
|
||||
private func check(_ response: URLResponse, data: Data) throws {
|
||||
guard let r=response as? HTTPURLResponse else { throw InboxError(code:"network-error",message:"Invalid HTTP response.") }
|
||||
guard (200..<300).contains(r.statusCode) else {
|
||||
let json=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any]; let error=json?["error"] as? [String:Any]
|
||||
throw InboxError(code:error?["code"] as? String ?? "request-failed",status:r.statusCode,message:error?["message"] as? String ?? "Request failed with HTTP \(r.statusCode).")
|
||||
}
|
||||
}
|
||||
private func call<T:Decodable>(_ path: String, method: String = "GET", body: Data? = nil) async throws -> T {
|
||||
let r=try await request(path,method:method,body:body)
|
||||
let id=UUID(); let task=Task { try await session.data(for:r,delegate:InboxRedirectPolicy.shared) };track(id,{task.cancel()});defer {untrack(id)}
|
||||
do {
|
||||
let (data,response)=try await withTaskCancellationHandler(operation:{try await task.value},onCancel:{task.cancel()}); try Task.checkCancellation()
|
||||
guard !isClosed() else { throw CancellationError() }; try check(response,data:data)
|
||||
do { return try Self.decoder().decode(T.self,from:data) } catch {throw InboxError(code:"invalid-response",message:"Invalid inbox response.")}
|
||||
} catch let error as URLError {
|
||||
if error.code == .cancelled || isClosed() || Task.isCancelled {throw CancellationError()}
|
||||
throw InboxError(code:error.code == .timedOut ? "timeout" : "network-error",message:error.code == .timedOut ? "The request timed out." : "Unable to connect to the inbox.")
|
||||
}
|
||||
}
|
||||
private func json(_ value: [String:Any]) throws -> Data { try JSONSerialization.data(withJSONObject:value) }
|
||||
public func messages(limit: Int? = nil) async throws -> InboxSnapshot {
|
||||
if let limit, !(1...100).contains(limit) { throw InboxError(code:"invalid-limit",message:"Limit must be between 1 and 100.") }
|
||||
return try await call("messages"+(limit.map {"?limit=\($0)"} ?? ""))
|
||||
}
|
||||
public func markRead(_ id: String) async throws -> MessageResult { try await call("messages",method:"POST",body:json(["id":id,"action":"read"])) }
|
||||
public func archive(_ id: String) async throws -> MessageResult { try await call("messages",method:"POST",body:json(["id":id,"action":"archive"])) }
|
||||
public func preferences() async throws -> InboxPreferences { try await call("preferences") }
|
||||
public func savePreferences(_ input: PreferenceInput) async throws { let _:EmptyResult=try await call("preferences",method:"POST",body:JSONEncoder().encode(input)) }
|
||||
public func devices() async throws -> InboxDevices { try await call("devices") }
|
||||
/// Re-register the same installation ID when FCM/APNs rotates its token.
|
||||
public func registerMobilePush(_ input: MobileRegistration) async throws -> DeviceResult {
|
||||
var value=try JSONSerialization.jsonObject(with:JSONEncoder().encode(input)) as! [String:Any]; value["action"]="register-mobile"
|
||||
return try await call("devices",method:"POST",body:json(value))
|
||||
}
|
||||
public func unregisterMobilePush(_ id: String) async throws -> DeviceResult { try await call("devices",method:"POST",body:json(["action":"unregister-mobile","deviceId":id])) }
|
||||
public func unregisterWebPush(_ id: String) async throws -> DeviceResult { try await call("devices",method:"POST",body:json(["action":"unregister","deviceId":id])) }
|
||||
/// Cancelling the consuming task stops streaming. Close on logout and create a new client for a new recipient.
|
||||
public func updates() -> AsyncThrowingStream<InboxSnapshot,Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let id=UUID()
|
||||
let task=Task {
|
||||
defer {self.untrack(id)}
|
||||
do {
|
||||
while !Task.isCancelled && !self.isClosed() {
|
||||
do {
|
||||
let r=try await self.request("stream",stream:true)
|
||||
#if canImport(Darwin)
|
||||
let (bytes,response)=try await self.session.bytes(for:r,delegate:InboxRedirectPolicy.shared); try self.check(response,data:Data())
|
||||
var parser=InboxEventParser()
|
||||
for try await line in bytes.lines {
|
||||
try Task.checkCancellation(); if self.isClosed() { break }
|
||||
if let snapshot=try parser.line(line) { continuation.yield(snapshot) }
|
||||
}
|
||||
#else
|
||||
let (data,response)=try await self.session.data(for:r,delegate:InboxRedirectPolicy.shared); try self.check(response,data:data)
|
||||
var parser=InboxEventParser()
|
||||
for line in String(decoding:data,as:UTF8.self).components(separatedBy:"\n") { if let snapshot=try parser.line(line) { continuation.yield(snapshot) } }
|
||||
#endif
|
||||
} catch let error as InboxError { throw error }
|
||||
catch is CancellationError { throw CancellationError() }
|
||||
catch let error as DecodingError { throw error }
|
||||
catch { /* Transient connection failures reconnect; writes are never retried. */ }
|
||||
try await Task.sleep(nanoseconds:2_000_000_000)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch { if Task.isCancelled || self.isClosed() { continuation.finish() } else { continuation.finish(throwing:error) } }
|
||||
}
|
||||
self.track(id,{task.cancel()})
|
||||
continuation.onTermination={ @Sendable _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
public func logoURL(_ branding: InboxBranding) -> URL? { guard let path=branding.logoUrl, path.range(of:"^/api/v1/inbox/branding-logo/[a-f0-9]{64}$",options:.regularExpression) != nil else {return nil}; return URL(string:path,relativeTo:endpoint)?.absoluteURL }
|
||||
public static func safeActionURL(_ text: String?) -> URL? { guard let text, let u=URL(string:text),u.scheme=="https",u.host != nil,u.user==nil,u.password==nil else {return nil}; return u }
|
||||
}
|
||||
|
||||
struct InboxEventParser {
|
||||
private var event=""; private var data=[String](); private var size=0
|
||||
mutating func line(_ raw: String) throws -> InboxSnapshot? {
|
||||
let line=raw.hasSuffix("\r") ? String(raw.dropLast()) : raw
|
||||
if line.isEmpty { defer {event="";data=[];size=0}; guard event=="inbox", !data.isEmpty else {return nil}; return try InboxClient.decoder().decode(InboxSnapshot.self,from:Data(data.joined(separator:"\n").utf8)) }
|
||||
size += line.utf8.count; guard size < 2_097_152 else { throw InboxError(code:"stream-too-large",message:"Inbox frame exceeded the size limit.") }
|
||||
if line.hasPrefix("event:") {event=String(line.dropFirst(6)).trimmingCharacters(in:.whitespaces)}
|
||||
if line.hasPrefix("data:") { var value=String(line.dropFirst(5)); if value.hasPrefix(" ") {value.removeFirst()}; data.append(value) }; return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
/// A complete recipient inbox, using organization branding returned by the recipient API.
|
||||
@available(iOS 15.0, macOS 12.0, *)
|
||||
public struct InboxView: View {
|
||||
private let client: InboxClient
|
||||
private let onAction: (InboxMessage, InboxAction?) -> Void
|
||||
@State private var snapshot: InboxSnapshot?
|
||||
@State private var branding: InboxBranding?
|
||||
@State private var error: String?
|
||||
@State private var busy: Set<String> = []
|
||||
@State private var generation=0
|
||||
@State private var recipientClient: ObjectIdentifier?
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
public init(client: InboxClient, onAction: @escaping (InboxMessage, InboxAction?) -> Void = {_,_ in}) { self.client=client; self.onAction=onAction }
|
||||
private var visibleBranding: InboxBranding? { recipientClient == ObjectIdentifier(client) ? branding : nil }
|
||||
private var brand: Color { color(visibleBranding?.brandColor) ?? Color.accentColor }
|
||||
public var body: some View {
|
||||
let currentSnapshot = recipientClient == ObjectIdentifier(client) ? snapshot : nil
|
||||
VStack(alignment:.leading,spacing:0) {
|
||||
HStack(spacing:12) {
|
||||
if let branding=visibleBranding, let url=client.logoURL(branding) { AsyncImage(url:url) { image in image.resizable().scaledToFit() } placeholder: { Color.clear }.frame(width:36,height:36).accessibilityHidden(true) }
|
||||
VStack(alignment:.leading) { Text(visibleBranding?.displayName ?? "Notifications").font(.title2.bold()); if let snapshot=currentSnapshot { Text("\(snapshot.unreadCount) unread").font(.subheadline).foregroundStyle(.secondary) } }
|
||||
Spacer()
|
||||
Button { generation += 1 } label: { Image(systemName:"arrow.clockwise") }.accessibilityLabel("Refresh notifications")
|
||||
}.padding().overlay(alignment:.bottom) { Rectangle().fill(brand).frame(height:2) }
|
||||
if let error,recipientClient == ObjectIdentifier(client) { HStack { Text(error).font(.callout); Spacer(); Button("Retry") {generation += 1} }.padding().accessibilityElement(children:.contain) }
|
||||
if let snapshot=currentSnapshot {
|
||||
if snapshot.messages.isEmpty { VStack(spacing:8) { Image(systemName:"bell").font(.largeTitle).foregroundStyle(brand); Text("No notifications yet").font(.headline); Text("Your notifications will appear here.").foregroundStyle(.secondary) }.frame(maxWidth:.infinity,maxHeight:.infinity).padding() }
|
||||
else { List(snapshot.messages) { message in
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack { if message.readAt == nil { Circle().fill(brand).frame(width:8,height:8).accessibilityLabel("Unread") }; Text(message.title).font(.headline); Spacer() }
|
||||
Text(message.body).font(.body)
|
||||
Text(message.createdAt).font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
if message.readAt == nil { Button("Mark read") { mutate(message,archive:false) } }
|
||||
Button("Archive") { mutate(message,archive:true) }
|
||||
if let url=InboxClient.safeActionURL(message.url) { Link("Open",destination:url).simultaneousGesture(TapGesture().onEnded { onAction(message,nil) }) }
|
||||
}.buttonStyle(.borderless).disabled(busy.contains(message.id))
|
||||
ForEach(message.actions ?? [],id:\.id) { action in
|
||||
if let url=InboxClient.safeActionURL(action.url) { Link(action.label,destination:url).simultaneousGesture(TapGesture().onEnded {onAction(message,action)}) }
|
||||
else if action.url == nil { Button(action.label) {onAction(message,action)}.buttonStyle(.borderless) }
|
||||
}
|
||||
}.padding(.vertical,8)
|
||||
}.listStyle(.plain) }
|
||||
} else if error == nil || recipientClient != ObjectIdentifier(client) { ProgressView("Loading notifications").frame(maxWidth:.infinity,maxHeight:.infinity) }
|
||||
}.tint(.primary).task(id:"\(ObjectIdentifier(client)):\(generation)") { await load() }
|
||||
.onChange(of:scenePhase) { _ in generation += 1 }
|
||||
}
|
||||
@MainActor private func load() async {
|
||||
guard scenePhase == .active else {return}
|
||||
let identity=ObjectIdentifier(client)
|
||||
if recipientClient != identity {snapshot=nil;branding=nil;busy=[];recipientClient=identity}
|
||||
error=nil
|
||||
do { let settings=try await client.preferences();let first=try await client.messages();try Task.checkCancellation();guard recipientClient==identity else{return};branding=settings.branding;snapshot=first; for try await value in client.updates() { try Task.checkCancellation();guard recipientClient==identity else{return};snapshot=value } }
|
||||
catch { if !Task.isCancelled && recipientClient==identity { self.error=error.localizedDescription } }
|
||||
}
|
||||
private func mutate(_ message: InboxMessage, archive: Bool) {
|
||||
let identity=ObjectIdentifier(client)
|
||||
busy.insert(message.id)
|
||||
Task { @MainActor in
|
||||
defer {if recipientClient==identity {busy.remove(message.id)}}
|
||||
do { if archive { _=try await client.archive(message.id) } else { _=try await client.markRead(message.id) }; let value=try await client.messages();if recipientClient==identity {snapshot=value} }
|
||||
catch {if recipientClient==identity {self.error=error.localizedDescription}}
|
||||
}
|
||||
}
|
||||
private func color(_ text: String?) -> Color? {
|
||||
guard let text,text.range(of:"^#[a-fA-F0-9]{6}$",options:.regularExpression) != nil, let hex=UInt32(text.dropFirst(),radix:16) else {return nil}
|
||||
return Color(red:Double((hex>>16)&255)/255,green:Double((hex>>8)&255)/255,blue:Double(hex&255)/255)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
public struct InboxAction: Codable, Sendable { public let id: String; public let label: String; public let url: String? }
|
||||
public struct InboxMessage: Codable, Identifiable, Sendable {
|
||||
public let id: String; public let recipientId: String; public let title: String; public let body: String
|
||||
public let purpose: String; public let category: String; public let url: String?; public let actions: [InboxAction]?
|
||||
public let createdAt: String; public let readAt: String?; public let archivedAt: String?
|
||||
}
|
||||
public struct InboxSnapshot: Codable, Sendable { public let messages: [InboxMessage]; public let unreadCount: Int }
|
||||
public struct InboxBranding: Codable, Sendable { public let displayName: String?; public let brandColor: String?; public let brandTextColor: String?; public let logoUrl: String? }
|
||||
public struct QuietHours: Codable, Sendable {
|
||||
public let start: String; public let end: String; public let days: [Int]
|
||||
public init(start: String, end: String, days: [Int]) { self.start=start; self.end=end; self.days=days }
|
||||
}
|
||||
public struct DeliveryRules: Codable, Sendable {
|
||||
public let timezone: String; public let quietHours: QuietHours?; public let marketingDailyLimit: Int?
|
||||
public init(timezone: String, quietHours: QuietHours? = nil, marketingDailyLimit: Int? = nil) { self.timezone=timezone; self.quietHours=quietHours; self.marketingDailyLimit=marketingDailyLimit }
|
||||
// Null explicitly clears a saved rule; omission would preserve it.
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c=encoder.container(keyedBy: CodingKeys.self); try c.encode(timezone, forKey:.timezone)
|
||||
try c.encode(quietHours, forKey:.quietHours); try c.encode(marketingDailyLimit, forKey:.marketingDailyLimit)
|
||||
}
|
||||
}
|
||||
public struct CategoryPreference: Codable, Sendable { public let category: String; public let inAppEnabled: Int; public let marketingEnabled: Int }
|
||||
public struct ChannelPreference: Codable, Sendable { public let channel: String; public let category: String; public let enabled: Int }
|
||||
public struct InboxPreferences: Codable, Sendable { public let branding: InboxBranding?; public let deliveryRules: DeliveryRules; public let preferences: [CategoryPreference]; public let channelPreferences: [ChannelPreference] }
|
||||
public struct PreferenceInput: Encodable, Sendable {
|
||||
public let category: String; public let inAppEnabled: Bool; public let marketingEnabled: Bool; public let externalChannels: [String:Bool]?; public let deliveryRules: DeliveryRules?
|
||||
public init(category: String, inAppEnabled: Bool, marketingEnabled: Bool, externalChannels: [String:Bool]? = nil, deliveryRules: DeliveryRules? = nil) { self.category=category; self.inAppEnabled=inAppEnabled; self.marketingEnabled=marketingEnabled; self.externalChannels=externalChannels; self.deliveryRules=deliveryRules }
|
||||
}
|
||||
public enum MobilePlatform: String, Codable, Sendable { case android, ios }
|
||||
public struct MobileRegistration: Encodable, Sendable {
|
||||
public let connectionId: String; public let installationId: String; public let name: String; public let platform: MobilePlatform; public let token: String; public let marketingConsent: Bool; public let consentEvidence: String
|
||||
public init(connectionId: String, installationId: String, name: String, platform: MobilePlatform = .ios, token: String, marketingConsent: Bool = false, consentEvidence: String = "") { self.connectionId=connectionId; self.installationId=installationId; self.name=name; self.platform=platform; self.token=token; self.marketingConsent=marketingConsent; self.consentEvidence=consentEvidence }
|
||||
}
|
||||
public struct MobileDevice: Decodable, Sendable { public let id: String; public let connectionId: String; public let installationId: String; public let name: String; public let platform: String; public let active: Int; public let revision: Int; public let createdAt: String; public let updatedAt: String }
|
||||
public struct WebDevice: Decodable, Sendable { public let id: String; public let connectionId: String; public let name: String; public let active: Int; public let createdAt: String; public let updatedAt: String }
|
||||
public struct MobileConnection: Decodable, Sendable { public let id: String; public let provider: String; public let applicationId: String; public let isDefault: Bool }
|
||||
public struct WebConnection: Decodable, Sendable { public let id: String; public let publicKey: String; public let isDefault: Bool }
|
||||
public struct InboxDevices: Decodable, Sendable { public let devices: [WebDevice]; public let mobileDevices: [MobileDevice]; public let connections: [WebConnection]; public let mobileConnections: [MobileConnection] }
|
||||
public struct DeviceResult: Decodable, Sendable { public let id: String; public let revision: Int?; public let replayed: Bool }
|
||||
public struct MessageResult: Decodable, Sendable { public let id: String }
|
||||
struct EmptyResult: Decodable { }
|
||||
public struct InboxError: Error, LocalizedError, Sendable {
|
||||
public let code: String; public let status: Int; public let message: String
|
||||
public var errorDescription: String? { message }
|
||||
public init(code: String, status: Int = 0, message: String) { self.code=code; self.status=status; self.message=message }
|
||||
}
|
||||
Reference in new issue
Block a user