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,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 WRNexus contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
let package = Package(name: "WRNexusInbox", platforms: [.iOS(.v15), .macOS(.v12)], products: [.library(name: "WRNexusInbox", targets: ["WRNexusInbox"])], targets: [.target(name: "WRNexusInbox"), .testTarget(name: "WRNexusInboxTests", dependencies: ["WRNexusInbox"])])
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# WRNexusInbox 0.1.0 preview
|
||||||
|
|
||||||
|
SwiftPM recipient client and SwiftUI `InboxView`, supporting iOS 15+ and macOS 12+. Distribution uses a versioned Git package; the release report records the published repository when available.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import WRNexusInbox
|
||||||
|
let inbox = try InboxClient(token: { try await yourBackend.fetchRecipientToken() })
|
||||||
|
InboxView(client: inbox) { message, action in
|
||||||
|
// Handle custom action.id. HTTPS links are handled by SwiftUI Link.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Your server issues a recipient token through the Notifications business API. Never embed a business API key. The token provider runs before each request/reconnect, allowing refresh. Configure a verified custom domain through `endpoint:`. API errors expose `InboxError.code`, `status` and `message`.
|
||||||
|
|
||||||
|
Async methods: `messages(limit:)`, `markRead(_:)`, `archive(_:)`, `preferences()`, `savePreferences(_:)`, `devices()`, `registerMobilePush(_:)`, `unregisterMobilePush(_:)`, `unregisterWebPush(_:)`, and `updates()`. Cancel the task consuming `updates()` on pause/disappear; the component manages its own subscription. Null quiet-hours/marketing-limit settings explicitly clear saved delivery rules. Omitted preference fields preserve existing settings. Writes are not retried.
|
||||||
|
|
||||||
|
Configure an active APNs or FCM connection in Notifications. The host requests `UNUserNotificationCenter` permission and registers with APNs or initializes Firebase. Convert the APNs `Data` device token to lowercase hexadecimal using `deviceToken.map { String(format: "%02x", $0) }.joined()`. Persist a random installation ID in the app's private storage and reuse it when the provider rotates the token. Pass `MobileRegistration(connectionId:installationId:name:token:marketingConsent:consentEvidence:)`; the platform defaults to `ios`. Retain the returned device ID per recipient. Marketing consent must reflect the recipient's real choice.
|
||||||
|
|
||||||
|
On logout, unregister the device ID for that recipient, remove their view, cancel subscription tasks and call `close()`. If unregister fails, have your backend deactivate the device. Create a new client for each new recipient. The host owns provider configuration, consent, permissions and account storage.
|
||||||
|
|
||||||
|
`swift test` verifies Foundation models, requests, errors, SSE parsing and logout behavior. Linux tests do not compile SwiftUI or prove iOS simulator/device behavior. Validate `InboxView` with an Apple SDK before a stable release; real APNs/FCM delivery remains a device acceptance check.
|
||||||
|
|
||||||
|
SwiftPM install: `.package(url: "https://git.workroot.in/Clintchiz/WRNexus-Mobile-SDKs.git", exact: "0.1.0")`. Flutter: Git dependency at the same URL, ref `0.1.0`, path `flutter`.
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import WRNexusInbox
|
||||||
|
import Foundation
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
final class InboxTests: XCTestCase {
|
||||||
|
func testEndpointAndActionValidation() throws {
|
||||||
|
for text in ["http://example.com", "https://u:p@example.com", "https://example.com/a", "https://example.com?q=x"] { XCTAssertThrowsError(try InboxClient(endpoint:URL(string:text)!,token:{"recipient"})) }
|
||||||
|
XCTAssertNoThrow(try InboxClient(endpoint:URL(string:"http://127.0.0.1:1234")!,token:{"recipient"}))
|
||||||
|
XCTAssertNil(InboxClient.safeActionURL("javascript:alert(1)")); XCTAssertNil(InboxClient.safeActionURL("https://u:p@example.com")); XCTAssertNotNil(InboxClient.safeActionURL("https://example.com/path"))
|
||||||
|
}
|
||||||
|
func testSnakeCaseAndNulls() throws {
|
||||||
|
let text=#"{"messages":[{"id":"m","recipient_id":"r","title":"Title","body":"Body","purpose":"transactional","category":"orders","url":null,"actions":[],"created_at":"2026-10-09","read_at":null,"archived_at":null}],"unreadCount":1}"#
|
||||||
|
let value=try InboxClient.decoder().decode(InboxSnapshot.self,from:Data(text.utf8)); XCTAssertEqual(value.messages[0].recipientId,"r"); XCTAssertNil(value.messages[0].readAt)
|
||||||
|
let data=try JSONEncoder().encode(DeliveryRules(timezone:"UTC")); let rules=try JSONSerialization.jsonObject(with:data) as! [String:Any]; XCTAssertTrue(rules["quietHours"] is NSNull); XCTAssertTrue(rules["marketingDailyLimit"] is NSNull)
|
||||||
|
}
|
||||||
|
func testSSEFrames() throws {
|
||||||
|
var p=InboxEventParser(); XCTAssertNil(try p.line(": heartbeat")); XCTAssertNil(try p.line("")); XCTAssertNil(try p.line("event: inbox\r")); XCTAssertNil(try p.line(#"data: {"messages":[],"unreadCount":4}"#)); XCTAssertEqual(try p.line("")?.unreadCount,4)
|
||||||
|
_=try p.line("event: inbox"); _=try p.line("data: broken"); XCTAssertThrowsError(try p.line(""))
|
||||||
|
}
|
||||||
|
func testClosedClientCannotRequest() async throws {
|
||||||
|
let client=try InboxClient(token:{"recipient"}); client.close()
|
||||||
|
do {_=try await client.messages(); XCTFail("closed client requested")} catch let error as InboxError {XCTAssertEqual(error.code,"closed")}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import WRNexusInbox
|
||||||
|
import Foundation
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
private final class StubProtocol: URLProtocol {
|
||||||
|
static var handler: ((URLRequest) -> (Int,String))!
|
||||||
|
override class func canInit(with request:URLRequest)->Bool {true}
|
||||||
|
override class func canonicalRequest(for request:URLRequest)->URLRequest {request}
|
||||||
|
override func startLoading() {
|
||||||
|
let (status,text)=Self.handler(request)
|
||||||
|
client?.urlProtocol(self,didReceive:HTTPURLResponse(url:request.url!,statusCode:status,httpVersion:nil,headerFields:["Content-Type":"application/json"])!,cacheStoragePolicy:.notAllowed)
|
||||||
|
client?.urlProtocol(self,didLoad:Data(text.utf8));client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
override func stopLoading() {}
|
||||||
|
}
|
||||||
|
private final class Counter: @unchecked Sendable {let lock=NSLock();var count=0;func next()->String{lock.lock();defer{lock.unlock()};count+=1;return "recipient-\(count)"}}
|
||||||
|
final class TransportTests:XCTestCase {
|
||||||
|
func testAllRecipientOperationsAndTokenRefresh() async throws {
|
||||||
|
let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[StubProtocol.self];let session=URLSession(configuration:config);defer{session.invalidateAndCancel()}
|
||||||
|
var requests=[URLRequest]();let counter=Counter()
|
||||||
|
StubProtocol.handler={request in
|
||||||
|
requests.append(request)
|
||||||
|
if request.httpMethod=="GET",request.url!.path.hasSuffix("messages") {return (200,#"{"messages":[],"unreadCount":2}"#)}
|
||||||
|
if request.httpMethod=="GET",request.url!.path.hasSuffix("preferences") {return (200,##"{"branding":{"displayName":"Acme","brandColor":"#123456","brandTextColor":"#ffffff","logoUrl":null},"deliveryRules":{"timezone":"UTC","quietHours":null,"marketingDailyLimit":null},"preferences":[{"category":"*","in_app_enabled":1,"marketing_enabled":0}],"channelPreferences":[]}"##)}
|
||||||
|
if request.httpMethod=="GET",request.url!.path.hasSuffix("devices") {return (200,#"{"devices":[],"mobileDevices":[],"connections":[],"mobileConnections":[]}"#)}
|
||||||
|
return (200,#"{"ok":true,"id":"d","revision":2,"replayed":false}"#)
|
||||||
|
}
|
||||||
|
let c=try InboxClient(endpoint:URL(string:"http://localhost")!,session:session,token:{counter.next()})
|
||||||
|
let snapshot=try await c.messages(limit:20);XCTAssertEqual(snapshot.unreadCount,2)
|
||||||
|
_=try await c.markRead("m");_=try await c.archive("m")
|
||||||
|
let preferences=try await c.preferences();XCTAssertEqual(preferences.preferences[0].inAppEnabled,1);XCTAssertEqual(preferences.branding?.displayName,"Acme")
|
||||||
|
try await c.savePreferences(PreferenceInput(category:"*",inAppEnabled:true,marketingEnabled:false,deliveryRules:DeliveryRules(timezone:"UTC")))
|
||||||
|
_=try await c.devices();let device=try await c.registerMobilePush(MobileRegistration(connectionId:"c",installationId:"i",name:"Phone",token:"rotated"));XCTAssertEqual(device.revision,2)
|
||||||
|
_=try await c.unregisterMobilePush("d");_=try await c.unregisterWebPush("w")
|
||||||
|
XCTAssertEqual(requests.count,9);XCTAssertEqual(requests[0].url?.query,"limit=20")
|
||||||
|
for (i,r) in requests.enumerated(){XCTAssertEqual(r.value(forHTTPHeaderField:"Authorization"),"Bearer recipient-\(i+1)")}
|
||||||
|
let encoded=try JSONSerialization.jsonObject(with:requests[4].httpBody!) as! [String:Any];let rules=encoded["deliveryRules"] as! [String:Any];XCTAssertTrue(rules["quietHours"] is NSNull);XCTAssertNil(encoded["externalChannels"])
|
||||||
|
let registration=try JSONSerialization.jsonObject(with:requests[6].httpBody!) as! [String:Any];XCTAssertEqual(registration["action"] as? String,"register-mobile");XCTAssertEqual(registration["installationId"] as? String,"i")
|
||||||
|
c.close()
|
||||||
|
}
|
||||||
|
func testErrorHasCodeAndNoWriteRetry() async throws {
|
||||||
|
var requests=0;StubProtocol.handler={_ in requests+=1;return (401,#"{"error":{"code":"recipient-token-invalid","message":"Expired"}}"#)}
|
||||||
|
let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[StubProtocol.self];let session=URLSession(configuration:config);defer{session.invalidateAndCancel()}
|
||||||
|
let c=try InboxClient(session:session,token:{"expired"});defer{c.close()}
|
||||||
|
do {_=try await c.archive("m");XCTFail()} catch let e as InboxError {XCTAssertEqual(e.status,401);XCTAssertEqual(e.code,"recipient-token-invalid")};XCTAssertEqual(requests,1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 WRNexus contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# WRNexus Inbox 0.1.0 preview
|
||||||
|
|
||||||
|
Flutter 3.35+/Dart 3.9+ recipient inbox client and `WRNexusInboxView`. Distribution uses a versioned Git package; the release report records the published repository when available. Use `sdk/flutter` locally while publication is pending.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||||
|
final inbox = InboxClient(token: () => yourBackend.fetchRecipientToken());
|
||||||
|
WRNexusInboxView(client: inbox, onAction: (message, action, url) {
|
||||||
|
// Route the HTTPS URL in your app, or handle action?.id.
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The view needs bounded height, for example a `Scaffold.body` or `Expanded`. It loads organization branding, shows unread count and messages, and supports read/archive, pull-to-refresh and custom actions. Text renders as text. Unsafe action URLs are refused. It pauses streaming with app lifecycle and cancels on dispose. Replacing `client` clears the previous recipient's state before loading another.
|
||||||
|
|
||||||
|
Methods: `messages(limit:)`, `markRead(id)`, `archive(id)`, `preferences()`, `savePreferences(input)`, `devices()`, `registerMobilePush(input)`, `unregisterMobilePush(id)`, `unregisterWebPush(id)` and `updates()`. Errors expose `InboxException.code`, `status` and `message`. Each request/reconnect fetches a fresh recipient token. Delivery-rule null values clear saved rules; omitted fields preserve existing values. Writes never retry automatically.
|
||||||
|
|
||||||
|
Your backend issues recipient tokens; never include a business API key. For push, configure an active Notifications FCM/APNs connection. Initialize the host's provider SDK, request notification permission, persist a random installation ID, and register the token using `MobileRegistration` with `MobilePlatform.android` or `.ios`. Subscribe to the provider's token-refresh callback and register the replacement using the same installation ID. Retain the returned device ID per signed-in recipient and record actual marketing consent/evidence.
|
||||||
|
|
||||||
|
Before logout unregister that recipient's stored device ID, dispose the view/subscription and call `inbox.close()`. If unregister fails, have your backend deactivate the device. A new recipient gets a new client; do not reuse inbox state across accounts. Provider setup and permission handling remain with the host app.
|
||||||
|
|
||||||
|
Validation: `flutter analyze` and `flutter test`. Tests exercise transport, errors, actions, read/archive, disposal and a branded widget golden. The golden uses explicitly labeled test fixture data. Real Android/iOS push delivery remains a separate device check.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
/// Never pass a business API key. Tokens must be issued by your application backend.
|
||||||
|
class InboxClient {
|
||||||
|
final Uri endpoint;
|
||||||
|
final FutureOr<String> Function() token;
|
||||||
|
final Duration timeout;
|
||||||
|
final http.Client _http;
|
||||||
|
final http.Client Function() _streamFactory;
|
||||||
|
final Set<void Function()> _stoppers = {};
|
||||||
|
bool _closed = false;
|
||||||
|
InboxClient({
|
||||||
|
required this.token,
|
||||||
|
Uri? endpoint,
|
||||||
|
this.timeout = const Duration(seconds: 15),
|
||||||
|
http.Client? transport,
|
||||||
|
http.Client Function()? streamTransport,
|
||||||
|
}) : endpoint = endpoint ?? Uri.parse('https://notifications.wrnexus.com'),
|
||||||
|
_http = transport ?? http.Client(),
|
||||||
|
_streamFactory = streamTransport ?? http.Client.new {
|
||||||
|
final u = this.endpoint;
|
||||||
|
if (u.host.isEmpty ||
|
||||||
|
u.userInfo.isNotEmpty ||
|
||||||
|
u.hasQuery ||
|
||||||
|
u.hasFragment ||
|
||||||
|
!['', '/'].contains(u.path) ||
|
||||||
|
(u.scheme != 'https' &&
|
||||||
|
!(u.scheme == 'http' &&
|
||||||
|
['localhost', '127.0.0.1', '::1'].contains(u.host))) ||
|
||||||
|
timeout <= Duration.zero) {
|
||||||
|
throw const InboxException(
|
||||||
|
'invalid-endpoint',
|
||||||
|
'Use an HTTPS origin or localhost with a positive timeout.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void close() {
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
for (final stop in _stoppers.toList()) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
_http.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkOpen() {
|
||||||
|
if (_closed) {
|
||||||
|
throw const InboxException('closed', 'The recipient session is closed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _headers({bool stream = false}) async {
|
||||||
|
_checkOpen();
|
||||||
|
final value = await token();
|
||||||
|
_checkOpen();
|
||||||
|
if (value.isEmpty || value.contains('\r') || value.contains('\n')) {
|
||||||
|
throw const InboxException(
|
||||||
|
'recipient-token-required',
|
||||||
|
'Pass a recipient token.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'authorization': 'Bearer $value',
|
||||||
|
'accept': stream ? 'text/event-stream' : 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _url(String path) => endpoint.resolve('/api/v1/inbox/$path');
|
||||||
|
InboxException _failure(int status, String body) {
|
||||||
|
try {
|
||||||
|
final j = jsonDecode(body) as Json;
|
||||||
|
final e = j['error'] as Json?;
|
||||||
|
return InboxException(
|
||||||
|
e?['code'] as String? ?? 'request-failed',
|
||||||
|
e?['message'] as String? ?? 'Request failed with HTTP $status.',
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return InboxException(
|
||||||
|
'request-failed',
|
||||||
|
'Request failed with HTTP $status.',
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Json> _call(String method, String path, [Json? body]) async {
|
||||||
|
_checkOpen();
|
||||||
|
try {
|
||||||
|
final headers = await _headers();
|
||||||
|
final response =
|
||||||
|
await (method == 'GET'
|
||||||
|
? _http.get(_url(path), headers: headers)
|
||||||
|
: _http.post(
|
||||||
|
_url(path),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
))
|
||||||
|
.timeout(timeout);
|
||||||
|
_checkOpen();
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw _failure(response.statusCode, response.body);
|
||||||
|
}
|
||||||
|
return jsonDecode(response.body) as Json;
|
||||||
|
} on TimeoutException {
|
||||||
|
throw const InboxException('timeout', 'The request timed out.');
|
||||||
|
} on http.ClientException {
|
||||||
|
throw const InboxException(
|
||||||
|
'network-error',
|
||||||
|
'Unable to connect to the inbox.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<InboxSnapshot> messages({int? limit}) async {
|
||||||
|
if (limit != null && (limit < 1 || limit > 100)) {
|
||||||
|
throw const InboxException(
|
||||||
|
'invalid-limit',
|
||||||
|
'Limit must be between 1 and 100.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return InboxSnapshot.fromJson(
|
||||||
|
await _call('GET', 'messages${limit == null ? '' : '?limit=$limit'}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRead(String id) async {
|
||||||
|
await _call('POST', 'messages', {'id': id, 'action': 'read'});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> archive(String id) async {
|
||||||
|
await _call('POST', 'messages', {'id': id, 'action': 'archive'});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<InboxPreferences> preferences() async =>
|
||||||
|
InboxPreferences.fromJson(await _call('GET', 'preferences'));
|
||||||
|
Future<void> savePreferences(PreferenceInput input) async {
|
||||||
|
await _call('POST', 'preferences', input.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<InboxDevices> devices() async =>
|
||||||
|
InboxDevices.fromJson(await _call('GET', 'devices'));
|
||||||
|
|
||||||
|
/// Use the same installationId when the provider token rotates.
|
||||||
|
Future<DeviceResult> registerMobilePush(MobileRegistration input) async =>
|
||||||
|
DeviceResult.fromJson(await _call('POST', 'devices', input.toJson()));
|
||||||
|
Future<DeviceResult> unregisterMobilePush(String id) async =>
|
||||||
|
DeviceResult.fromJson(
|
||||||
|
await _call('POST', 'devices', {
|
||||||
|
'action': 'unregister-mobile',
|
||||||
|
'deviceId': id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Future<DeviceResult> unregisterWebPush(String id) async =>
|
||||||
|
DeviceResult.fromJson(
|
||||||
|
await _call('POST', 'devices', {
|
||||||
|
'action': 'unregister',
|
||||||
|
'deviceId': id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Uri? logoUri(InboxBranding branding) {
|
||||||
|
final path = branding.logoUrl;
|
||||||
|
return path != null &&
|
||||||
|
RegExp(r'^/api/v1/inbox/branding-logo/[a-f0-9]{64}$').hasMatch(path)
|
||||||
|
? endpoint.resolve(path)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri? safeActionUri(String? text) {
|
||||||
|
final u = text == null ? null : Uri.tryParse(text);
|
||||||
|
return u != null &&
|
||||||
|
u.scheme == 'https' &&
|
||||||
|
u.host.isNotEmpty &&
|
||||||
|
u.userInfo.isEmpty
|
||||||
|
? u
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the subscription on pause/dispose. It cancels its HTTP connection immediately.
|
||||||
|
Stream<InboxSnapshot> updates() {
|
||||||
|
late StreamController<InboxSnapshot> controller;
|
||||||
|
http.Client? active;
|
||||||
|
bool stopped = false;
|
||||||
|
final done = Completer<void>();
|
||||||
|
void stop() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
active?.close();
|
||||||
|
if (!done.isCompleted) done.complete();
|
||||||
|
_stoppers.remove(stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
_stoppers.add(stop);
|
||||||
|
try {
|
||||||
|
while (!stopped && !_closed) {
|
||||||
|
active = _streamFactory();
|
||||||
|
try {
|
||||||
|
final request = http.Request('GET', _url('stream'));
|
||||||
|
request.headers.addAll(await _headers(stream: true));
|
||||||
|
if (stopped) break;
|
||||||
|
final response = await active!.send(request).timeout(timeout);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw _failure(
|
||||||
|
response.statusCode,
|
||||||
|
await response.stream.bytesToString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final parser = InboxEventParser();
|
||||||
|
await for (final line
|
||||||
|
in response.stream
|
||||||
|
.transform(utf8.decoder)
|
||||||
|
.transform(const LineSplitter())) {
|
||||||
|
if (stopped || _closed) break;
|
||||||
|
final snapshot = parser.line(line);
|
||||||
|
if (snapshot != null) controller.add(snapshot);
|
||||||
|
}
|
||||||
|
} on InboxException {
|
||||||
|
rethrow;
|
||||||
|
} on FormatException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
if (stopped || _closed) break;
|
||||||
|
} finally {
|
||||||
|
active?.close();
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
if (!stopped && !_closed) {
|
||||||
|
await Future.any([
|
||||||
|
Future<void>.delayed(const Duration(seconds: 2)),
|
||||||
|
done.future,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
if (!stopped && !_closed) controller.addError(error, stack);
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
unawaited(controller.close());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = StreamController<InboxSnapshot>(
|
||||||
|
onListen: () {
|
||||||
|
unawaited(run());
|
||||||
|
},
|
||||||
|
onCancel: stop,
|
||||||
|
);
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxEventParser {
|
||||||
|
String _event = '';
|
||||||
|
final List<String> _data = [];
|
||||||
|
int _size = 0;
|
||||||
|
InboxSnapshot? line(String line) {
|
||||||
|
if (line.isEmpty) {
|
||||||
|
final event = _event;
|
||||||
|
final data = _data.join('\n');
|
||||||
|
_event = '';
|
||||||
|
_data.clear();
|
||||||
|
_size = 0;
|
||||||
|
return event == 'inbox' && data.isNotEmpty
|
||||||
|
? InboxSnapshot.fromJson(jsonDecode(data) as Json)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
_size += utf8.encode(line).length;
|
||||||
|
if (_size > 2097152) {
|
||||||
|
throw const InboxException(
|
||||||
|
'stream-too-large',
|
||||||
|
'Inbox frame exceeded the size limit.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (line.startsWith('event:')) _event = line.substring(6).trim();
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
var value = line.substring(5);
|
||||||
|
if (value.startsWith(' ')) value = value.substring(1);
|
||||||
|
_data.add(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'client.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
/// Real recipient content only. The host owns navigation and custom-action handling.
|
||||||
|
class WRNexusInboxView extends StatefulWidget {
|
||||||
|
final InboxClient client;
|
||||||
|
final void Function(InboxMessage message, InboxAction? action, Uri? url)?
|
||||||
|
onAction;
|
||||||
|
const WRNexusInboxView({super.key, required this.client, this.onAction});
|
||||||
|
@override
|
||||||
|
State<WRNexusInboxView> createState() => _InboxViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InboxViewState extends State<WRNexusInboxView>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
InboxSnapshot? _snapshot;
|
||||||
|
InboxBranding? _branding;
|
||||||
|
String? _error;
|
||||||
|
final Set<String> _busy = {};
|
||||||
|
StreamSubscription<InboxSnapshot>? _subscription;
|
||||||
|
int _generation = 0;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
unawaited(_load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant WRNexusInboxView old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.client != widget.client) {
|
||||||
|
_generation++;
|
||||||
|
_snapshot = null;
|
||||||
|
_branding = null;
|
||||||
|
_error = null;
|
||||||
|
_busy.clear();
|
||||||
|
unawaited(_load());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_generation++;
|
||||||
|
_subscription?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_load());
|
||||||
|
} else {
|
||||||
|
_generation++;
|
||||||
|
_subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _current(int generation) => mounted && generation == _generation;
|
||||||
|
Future<void> _load() async {
|
||||||
|
final generation = ++_generation;
|
||||||
|
final previous = _subscription;
|
||||||
|
_subscription = null;
|
||||||
|
if (previous != null) {
|
||||||
|
unawaited(previous.cancel());
|
||||||
|
}
|
||||||
|
if (!_current(generation)) return;
|
||||||
|
setState(() => _error = null);
|
||||||
|
try {
|
||||||
|
final preferences = await widget.client.preferences();
|
||||||
|
final snapshot = await widget.client.messages();
|
||||||
|
if (!_current(generation)) return;
|
||||||
|
setState(() {
|
||||||
|
_branding = preferences.branding;
|
||||||
|
_snapshot = snapshot;
|
||||||
|
});
|
||||||
|
_subscription = widget.client.updates().listen(
|
||||||
|
(value) {
|
||||||
|
if (_current(generation)) setState(() => _snapshot = value);
|
||||||
|
},
|
||||||
|
onError: (Object error) {
|
||||||
|
if (_current(generation)) setState(() => _error = error.toString());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (_current(generation)) setState(() => _error = error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _mutate(InboxMessage message, bool archive) async {
|
||||||
|
final generation = _generation;
|
||||||
|
final client = widget.client;
|
||||||
|
setState(() => _busy.add(message.id));
|
||||||
|
try {
|
||||||
|
if (archive) {
|
||||||
|
await client.archive(message.id);
|
||||||
|
} else {
|
||||||
|
await client.markRead(message.id);
|
||||||
|
}
|
||||||
|
final snapshot = await client.messages();
|
||||||
|
if (_current(generation)) setState(() => _snapshot = snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (_current(generation)) setState(() => _error = error.toString());
|
||||||
|
} finally {
|
||||||
|
if (_current(generation)) setState(() => _busy.remove(message.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _color(String? text) =>
|
||||||
|
text != null && RegExp(r'^#[a-fA-F0-9]{6}$').hasMatch(text)
|
||||||
|
? Color(0xff000000 | int.parse(text.substring(1), radix: 16))
|
||||||
|
: null;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final brand = _color(_branding?.brandColor) ?? theme.colorScheme.primary;
|
||||||
|
final logo = _branding == null ? null : widget.client.logoUri(_branding!);
|
||||||
|
return Theme(
|
||||||
|
data: theme.copyWith(
|
||||||
|
colorScheme: theme.colorScheme.copyWith(
|
||||||
|
primary: brand.computeLuminance() > 0.18
|
||||||
|
? theme.colorScheme.onSurface
|
||||||
|
: brand,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: brand, width: 2)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (logo != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Image.network(
|
||||||
|
logo.toString(),
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
const SizedBox(width: 36, height: 36),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_branding?.displayName ?? 'Notifications',
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (_snapshot != null)
|
||||||
|
Text(
|
||||||
|
'${_snapshot!.unreadCount} unread',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh notifications',
|
||||||
|
onPressed: () => unawaited(_load()),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
MaterialBanner(
|
||||||
|
content: Text(_error!),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => unawaited(_load()),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _snapshot == null
|
||||||
|
? (_error == null
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
semanticsLabel: 'Loading notifications',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox())
|
||||||
|
: _snapshot!.messages.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.notifications_none,
|
||||||
|
color: brand,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No notifications yet',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Your notifications will appear here.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.separated(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
itemCount: _snapshot!.messages.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = _snapshot!.messages[index];
|
||||||
|
final busy = _busy.contains(message.id);
|
||||||
|
final url = InboxClient.safeActionUri(message.url);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (message.readAt == null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 7,
|
||||||
|
right: 8,
|
||||||
|
),
|
||||||
|
child: Semantics(
|
||||||
|
label: 'Unread',
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: brand,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message.title,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(message.body),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
message.createdAt,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (message.readAt == null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () => unawaited(
|
||||||
|
_mutate(message, false),
|
||||||
|
),
|
||||||
|
child: const Text('Mark read'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
unawaited(_mutate(message, true)),
|
||||||
|
child: const Text('Archive'),
|
||||||
|
),
|
||||||
|
if (url != null && widget.onAction != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
widget.onAction!(message, null, url),
|
||||||
|
child: const Text('Open'),
|
||||||
|
),
|
||||||
|
for (final action in message.actions)
|
||||||
|
if (widget.onAction != null &&
|
||||||
|
(action.url == null ||
|
||||||
|
InboxClient.safeActionUri(
|
||||||
|
action.url,
|
||||||
|
) !=
|
||||||
|
null))
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => widget.onAction!(
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
InboxClient.safeActionUri(action.url),
|
||||||
|
),
|
||||||
|
child: Text(action.label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
typedef Json = Map<String, dynamic>;
|
||||||
|
|
||||||
|
class InboxAction {
|
||||||
|
final String id, label;
|
||||||
|
final String? url;
|
||||||
|
InboxAction.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
label = j['label'] as String,
|
||||||
|
url = j['url'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxMessage {
|
||||||
|
final String id, recipientId, title, body, purpose, category, createdAt;
|
||||||
|
final String? url, readAt, archivedAt;
|
||||||
|
final List<InboxAction> actions;
|
||||||
|
InboxMessage.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
recipientId = j['recipient_id'] as String,
|
||||||
|
title = j['title'] as String,
|
||||||
|
body = j['body'] as String,
|
||||||
|
purpose = j['purpose'] as String,
|
||||||
|
category = j['category'] as String,
|
||||||
|
createdAt = j['created_at'] as String,
|
||||||
|
url = j['url'] as String?,
|
||||||
|
readAt = j['read_at'] as String?,
|
||||||
|
archivedAt = j['archived_at'] as String?,
|
||||||
|
actions = (j['actions'] as List? ?? [])
|
||||||
|
.map((v) => InboxAction.fromJson(v as Json))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxSnapshot {
|
||||||
|
final List<InboxMessage> messages;
|
||||||
|
final int unreadCount;
|
||||||
|
InboxSnapshot.fromJson(Json j)
|
||||||
|
: messages = (j['messages'] as List)
|
||||||
|
.map((v) => InboxMessage.fromJson(v as Json))
|
||||||
|
.toList(growable: false),
|
||||||
|
unreadCount = j['unreadCount'] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxBranding {
|
||||||
|
final String? displayName, brandColor, brandTextColor, logoUrl;
|
||||||
|
InboxBranding.fromJson(Json j)
|
||||||
|
: displayName = j['displayName'] as String?,
|
||||||
|
brandColor = j['brandColor'] as String?,
|
||||||
|
brandTextColor = j['brandTextColor'] as String?,
|
||||||
|
logoUrl = j['logoUrl'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuietHours {
|
||||||
|
final String start, end;
|
||||||
|
final List<int> days;
|
||||||
|
const QuietHours({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.days,
|
||||||
|
});
|
||||||
|
QuietHours.fromJson(Json j)
|
||||||
|
: start = j['start'] as String,
|
||||||
|
end = j['end'] as String,
|
||||||
|
days = (j['days'] as List).cast<int>();
|
||||||
|
Json toJson() => {'start': start, 'end': end, 'days': days};
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeliveryRules {
|
||||||
|
final String timezone;
|
||||||
|
final QuietHours? quietHours;
|
||||||
|
final int? marketingDailyLimit;
|
||||||
|
const DeliveryRules({
|
||||||
|
required this.timezone,
|
||||||
|
this.quietHours,
|
||||||
|
this.marketingDailyLimit,
|
||||||
|
});
|
||||||
|
DeliveryRules.fromJson(Json j)
|
||||||
|
: timezone = j['timezone'] as String,
|
||||||
|
quietHours = j['quietHours'] == null
|
||||||
|
? null
|
||||||
|
: QuietHours.fromJson(j['quietHours'] as Json),
|
||||||
|
marketingDailyLimit = j['marketingDailyLimit'] as int?;
|
||||||
|
Json toJson() => {
|
||||||
|
'timezone': timezone,
|
||||||
|
'quietHours': quietHours?.toJson(),
|
||||||
|
'marketingDailyLimit': marketingDailyLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CategoryPreference {
|
||||||
|
final String category;
|
||||||
|
final bool inAppEnabled, marketingEnabled;
|
||||||
|
CategoryPreference.fromJson(Json j)
|
||||||
|
: category = j['category'] as String,
|
||||||
|
inAppEnabled = j['in_app_enabled'] == 1,
|
||||||
|
marketingEnabled = j['marketing_enabled'] == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelPreference {
|
||||||
|
final String channel, category;
|
||||||
|
final bool enabled;
|
||||||
|
ChannelPreference.fromJson(Json j)
|
||||||
|
: channel = j['channel'] as String,
|
||||||
|
category = j['category'] as String,
|
||||||
|
enabled = j['enabled'] == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxPreferences {
|
||||||
|
final InboxBranding? branding;
|
||||||
|
final DeliveryRules deliveryRules;
|
||||||
|
final List<CategoryPreference> preferences;
|
||||||
|
final List<ChannelPreference> channelPreferences;
|
||||||
|
InboxPreferences.fromJson(Json j)
|
||||||
|
: branding = j['branding'] == null
|
||||||
|
? null
|
||||||
|
: InboxBranding.fromJson(j['branding'] as Json),
|
||||||
|
deliveryRules = DeliveryRules.fromJson(j['deliveryRules'] as Json),
|
||||||
|
preferences = (j['preferences'] as List)
|
||||||
|
.map((v) => CategoryPreference.fromJson(v as Json))
|
||||||
|
.toList(growable: false),
|
||||||
|
channelPreferences = (j['channelPreferences'] as List)
|
||||||
|
.map((v) => ChannelPreference.fromJson(v as Json))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreferenceInput {
|
||||||
|
final String category;
|
||||||
|
final bool inAppEnabled, marketingEnabled;
|
||||||
|
final Map<String, bool>? externalChannels;
|
||||||
|
final DeliveryRules? deliveryRules;
|
||||||
|
const PreferenceInput({
|
||||||
|
required this.category,
|
||||||
|
required this.inAppEnabled,
|
||||||
|
required this.marketingEnabled,
|
||||||
|
this.externalChannels,
|
||||||
|
this.deliveryRules,
|
||||||
|
});
|
||||||
|
Json toJson() => {
|
||||||
|
'category': category,
|
||||||
|
'inAppEnabled': inAppEnabled,
|
||||||
|
'marketingEnabled': marketingEnabled,
|
||||||
|
if (externalChannels != null) 'externalChannels': externalChannels,
|
||||||
|
if (deliveryRules != null) 'deliveryRules': deliveryRules!.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MobilePlatform { android, ios }
|
||||||
|
|
||||||
|
class MobileRegistration {
|
||||||
|
final String connectionId, installationId, name, token, consentEvidence;
|
||||||
|
final MobilePlatform platform;
|
||||||
|
final bool marketingConsent;
|
||||||
|
const MobileRegistration({
|
||||||
|
required this.connectionId,
|
||||||
|
required this.installationId,
|
||||||
|
required this.name,
|
||||||
|
required this.platform,
|
||||||
|
required this.token,
|
||||||
|
this.marketingConsent = false,
|
||||||
|
this.consentEvidence = '',
|
||||||
|
});
|
||||||
|
Json toJson() => {
|
||||||
|
'action': 'register-mobile',
|
||||||
|
'connectionId': connectionId,
|
||||||
|
'installationId': installationId,
|
||||||
|
'name': name,
|
||||||
|
'platform': platform.name,
|
||||||
|
'token': token,
|
||||||
|
'marketingConsent': marketingConsent,
|
||||||
|
'consentEvidence': consentEvidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileDevice {
|
||||||
|
final String id,
|
||||||
|
connectionId,
|
||||||
|
installationId,
|
||||||
|
name,
|
||||||
|
platform,
|
||||||
|
createdAt,
|
||||||
|
updatedAt;
|
||||||
|
final bool active;
|
||||||
|
final int revision;
|
||||||
|
MobileDevice.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
connectionId = j['connection_id'] as String,
|
||||||
|
installationId = j['installation_id'] as String,
|
||||||
|
name = j['name'] as String,
|
||||||
|
platform = j['platform'] as String,
|
||||||
|
createdAt = j['created_at'] as String,
|
||||||
|
updatedAt = j['updated_at'] as String,
|
||||||
|
active = j['active'] == 1,
|
||||||
|
revision = j['revision'] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebDevice {
|
||||||
|
final String id, connectionId, name, createdAt, updatedAt;
|
||||||
|
final bool active;
|
||||||
|
WebDevice.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
connectionId = j['connection_id'] as String,
|
||||||
|
name = j['name'] as String,
|
||||||
|
createdAt = j['created_at'] as String,
|
||||||
|
updatedAt = j['updated_at'] as String,
|
||||||
|
active = j['active'] == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobileConnection {
|
||||||
|
final String id, provider, applicationId;
|
||||||
|
final bool isDefault;
|
||||||
|
MobileConnection.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
provider = j['provider'] as String,
|
||||||
|
applicationId = j['applicationId'] as String,
|
||||||
|
isDefault = j['isDefault'] as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebConnection {
|
||||||
|
final String id, publicKey;
|
||||||
|
final bool isDefault;
|
||||||
|
WebConnection.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
publicKey = j['publicKey'] as String,
|
||||||
|
isDefault = j['isDefault'] as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxDevices {
|
||||||
|
final List<MobileDevice> mobileDevices;
|
||||||
|
final List<WebDevice> devices;
|
||||||
|
final List<MobileConnection> mobileConnections;
|
||||||
|
final List<WebConnection> connections;
|
||||||
|
InboxDevices.fromJson(Json j)
|
||||||
|
: mobileDevices = (j['mobileDevices'] as List)
|
||||||
|
.map((v) => MobileDevice.fromJson(v as Json))
|
||||||
|
.toList(growable: false),
|
||||||
|
devices = (j['devices'] as List)
|
||||||
|
.map((v) => WebDevice.fromJson(v as Json))
|
||||||
|
.toList(growable: false),
|
||||||
|
mobileConnections = (j['mobileConnections'] as List)
|
||||||
|
.map((v) => MobileConnection.fromJson(v as Json))
|
||||||
|
.toList(growable: false),
|
||||||
|
connections = (j['connections'] as List)
|
||||||
|
.map((v) => WebConnection.fromJson(v as Json))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceResult {
|
||||||
|
final String id;
|
||||||
|
final int? revision;
|
||||||
|
final bool replayed;
|
||||||
|
DeviceResult.fromJson(Json j)
|
||||||
|
: id = j['id'] as String,
|
||||||
|
revision = j['revision'] as int?,
|
||||||
|
replayed = j['replayed'] as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InboxException implements Exception {
|
||||||
|
final String code, message;
|
||||||
|
final int status;
|
||||||
|
const InboxException(this.code, this.message, [this.status = 0]);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/models.dart';
|
||||||
|
export 'src/client.dart';
|
||||||
|
export 'src/inbox_view.dart';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: wrnexus_inbox
|
||||||
|
description: Recipient-scoped WRNexus inbox, mobile push registration and branded Flutter inbox widget.
|
||||||
|
version: 0.1.0
|
||||||
|
homepage: https://docs.wrnexus.com/sdk
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.9.0 <4.0.0'
|
||||||
|
flutter: '>=3.35.0'
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
http: ^1.5.0
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'recipient transport, token refresh, writes and nullable delivery rules',
|
||||||
|
() async {
|
||||||
|
final calls = <http.Request>[];
|
||||||
|
var tokens = 0;
|
||||||
|
final client = InboxClient(
|
||||||
|
token: () => 'recipient-${++tokens}',
|
||||||
|
transport: MockClient((r) async {
|
||||||
|
calls.add(r);
|
||||||
|
if (r.method == 'GET') {
|
||||||
|
return http.Response('{"messages":[],"unreadCount":3}', 200);
|
||||||
|
}
|
||||||
|
return http.Response('{"id":"d","revision":2,"replayed":false}', 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await client.messages(limit: 20)).unreadCount, 3);
|
||||||
|
await client.markRead('m');
|
||||||
|
await client.archive('m');
|
||||||
|
await client.savePreferences(
|
||||||
|
const PreferenceInput(
|
||||||
|
category: '*',
|
||||||
|
inAppEnabled: true,
|
||||||
|
marketingEnabled: false,
|
||||||
|
deliveryRules: DeliveryRules(timezone: 'UTC'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.registerMobilePush(
|
||||||
|
const MobileRegistration(
|
||||||
|
connectionId: 'c',
|
||||||
|
installationId: 'i',
|
||||||
|
name: 'Phone',
|
||||||
|
platform: MobilePlatform.android,
|
||||||
|
token: 'fcm',
|
||||||
|
marketingConsent: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.unregisterMobilePush('d');
|
||||||
|
await client.unregisterWebPush('w');
|
||||||
|
expect(
|
||||||
|
calls.map((r) => r.headers['authorization']),
|
||||||
|
List.generate(7, (i) => 'Bearer recipient-${i + 1}'),
|
||||||
|
);
|
||||||
|
expect(calls.first.url.path, '/api/v1/inbox/messages');
|
||||||
|
expect(calls.first.url.query, 'limit=20');
|
||||||
|
expect(jsonDecode(calls[1].body), {'id': 'm', 'action': 'read'});
|
||||||
|
expect((jsonDecode(calls[3].body) as Map)['deliveryRules'], {
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'quietHours': null,
|
||||||
|
'marketingDailyLimit': null,
|
||||||
|
});
|
||||||
|
expect((jsonDecode(calls[4].body) as Map)['action'], 'register-mobile');
|
||||||
|
client.close();
|
||||||
|
await expectLater(
|
||||||
|
client.messages(),
|
||||||
|
throwsA(isA<InboxException>().having((e) => e.code, 'code', 'closed')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('API errors stop without retry', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final client = InboxClient(
|
||||||
|
token: () => 'expired',
|
||||||
|
transport: MockClient((_) async {
|
||||||
|
calls++;
|
||||||
|
return http.Response(
|
||||||
|
'{"error":{"code":"expired-token","message":"Expired"}}',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
client.markRead('m'),
|
||||||
|
throwsA(
|
||||||
|
isA<InboxException>()
|
||||||
|
.having((e) => e.status, 'status', 401)
|
||||||
|
.having((e) => e.code, 'code', 'expired-token'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(calls, 1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
test('origin, actions and frame parsing', () {
|
||||||
|
for (final text in [
|
||||||
|
'http://example.com',
|
||||||
|
'https://u:p@example.com',
|
||||||
|
'https://example.com/a',
|
||||||
|
'https://example.com?q=x',
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
() => InboxClient(endpoint: Uri.parse(text), token: () => 'r'),
|
||||||
|
throwsA(isA<InboxException>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(InboxClient.safeActionUri('javascript:alert(1)'), isNull);
|
||||||
|
expect(InboxClient.safeActionUri('https://u:p@example.com'), isNull);
|
||||||
|
final parser = InboxEventParser();
|
||||||
|
expect(parser.line(': heartbeat'), isNull);
|
||||||
|
expect(parser.line(''), isNull);
|
||||||
|
parser.line('event: inbox');
|
||||||
|
parser.line('data: {"messages":[],"unreadCount":2}');
|
||||||
|
expect(parser.line('')!.unreadCount, 2);
|
||||||
|
parser.line('event: inbox');
|
||||||
|
parser.line('data: broken');
|
||||||
|
expect(() => parser.line(''), throwsFormatException);
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,206 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:wrnexus_inbox/wrnexus_inbox.dart';
|
||||||
|
|
||||||
|
class IdleStreamClient extends http.BaseClient {
|
||||||
|
final controller = StreamController<List<int>>();
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) async =>
|
||||||
|
http.StreamedResponse(controller.stream, 200);
|
||||||
|
@override
|
||||||
|
void close() {
|
||||||
|
unawaited(controller.close());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() async {
|
||||||
|
final loader = FontLoader('Inter');
|
||||||
|
loader.addFont(
|
||||||
|
Future.value(
|
||||||
|
ByteData.sublistView(await File('test/fonts/Inter.ttf').readAsBytes()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await loader.load();
|
||||||
|
final iconPath =
|
||||||
|
'${Platform.environment['FLUTTER_ROOT'] ?? '../../.wrnexus/tools/flutter'}/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf';
|
||||||
|
final icons = FontLoader('MaterialIcons');
|
||||||
|
icons.addFont(
|
||||||
|
Future.value(ByteData.sublistView(await File(iconPath).readAsBytes())),
|
||||||
|
);
|
||||||
|
await icons.load();
|
||||||
|
});
|
||||||
|
testWidgets('branding, actions, read/archive and recipient change', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var read = false, archived = false;
|
||||||
|
final writes = <Map<String, dynamic>>[];
|
||||||
|
final streams = <IdleStreamClient>[];
|
||||||
|
final client = InboxClient(
|
||||||
|
token: () => 'recipient',
|
||||||
|
streamTransport: () {
|
||||||
|
final c = IdleStreamClient();
|
||||||
|
streams.add(c);
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
transport: MockClient((r) async {
|
||||||
|
if (r.method == 'POST') {
|
||||||
|
final body = jsonDecode(r.body) as Map<String, dynamic>;
|
||||||
|
writes.add(body);
|
||||||
|
read = body['action'] == 'read' || read;
|
||||||
|
archived = body['action'] == 'archive' || archived;
|
||||||
|
return http.Response('{"id":"m"}', 200);
|
||||||
|
}
|
||||||
|
if (r.url.path.endsWith('preferences')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'branding': {'displayName': 'Acme', 'brandColor': '#123456'},
|
||||||
|
'deliveryRules': {
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'quietHours': null,
|
||||||
|
'marketingDailyLimit': null,
|
||||||
|
},
|
||||||
|
'preferences': [],
|
||||||
|
'channelPreferences': [],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'messages': archived
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
'id': 'm',
|
||||||
|
'recipient_id': 'r',
|
||||||
|
'title': 'Order ready',
|
||||||
|
'body': 'Your order is ready for collection.',
|
||||||
|
'purpose': 'transactional',
|
||||||
|
'category': 'orders',
|
||||||
|
'created_at': '2026-10-09T00:00:00Z',
|
||||||
|
'read_at': read ? '2026-10-09' : null,
|
||||||
|
'archived_at': null,
|
||||||
|
'url': 'javascript:alert(1)',
|
||||||
|
'actions': [
|
||||||
|
{'id': 'track', 'label': 'Track order'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'unreadCount': read || archived ? 0 : 1,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final actions = <String>[];
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData(
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
scaffoldBackgroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RepaintBoundary(
|
||||||
|
key: const Key('inbox'),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: WRNexusInboxView(
|
||||||
|
client: client,
|
||||||
|
onAction: (m, a, u) => actions.add(a!.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Acme'), findsOneWidget);
|
||||||
|
expect(find.text('Order ready'), findsOneWidget);
|
||||||
|
expect(find.text('Open'), findsNothing);
|
||||||
|
await tester.tap(find.text('Track order'));
|
||||||
|
expect(actions, ['track']);
|
||||||
|
await expectLater(
|
||||||
|
find.byKey(const Key('inbox')),
|
||||||
|
matchesGoldenFile('goldens/branded-inbox.png'),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Mark read'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(writes.last, {'id': 'm', 'action': 'read'});
|
||||||
|
expect(find.text('Mark read'), findsNothing);
|
||||||
|
await tester.tap(find.text('Archive'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('No notifications yet'), findsOneWidget);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
client.close();
|
||||||
|
expect(streams.every((s) => s.controller.isClosed), isTrue);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'replacing the client clears the previous recipient before the new request resolves',
|
||||||
|
(tester) async {
|
||||||
|
final pending = Completer<http.Response>();
|
||||||
|
String settings(String name) => jsonEncode({
|
||||||
|
'branding': {'displayName': name},
|
||||||
|
'deliveryRules': {'timezone': 'UTC'},
|
||||||
|
'preferences': [],
|
||||||
|
'channelPreferences': [],
|
||||||
|
});
|
||||||
|
InboxClient make(String name, {bool wait = false}) => InboxClient(
|
||||||
|
token: () => name,
|
||||||
|
streamTransport: IdleStreamClient.new,
|
||||||
|
transport: MockClient((request) async {
|
||||||
|
if (request.url.path.endsWith('preferences')) {
|
||||||
|
return wait ? pending.future : http.Response(settings(name), 200);
|
||||||
|
}
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'id': name,
|
||||||
|
'recipient_id': name,
|
||||||
|
'title': '$name message',
|
||||||
|
'body': 'Private content',
|
||||||
|
'purpose': 'transactional',
|
||||||
|
'category': 'orders',
|
||||||
|
'created_at': '2026-10-09',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'unreadCount': 1,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final first = make('First');
|
||||||
|
final second = make('Second', wait: true);
|
||||||
|
Widget app(InboxClient client) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: WRNexusInboxView(
|
||||||
|
key: const ValueKey('same-view'),
|
||||||
|
client: client,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(app(first));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('First message'), findsOneWidget);
|
||||||
|
await tester.pumpWidget(app(second));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('First message'), findsNothing);
|
||||||
|
expect(find.text('First'), findsNothing);
|
||||||
|
pending.complete(http.Response(settings('Second'), 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Second message'), findsOneWidget);
|
||||||
|
expect(find.text('First message'), findsNothing);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:wrnexus_inbox/src/client.dart';
|
||||||
|
import 'package:wrnexus_inbox/src/models.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
var tokens = 0, streams = 0;
|
||||||
|
final calls = <String>[];
|
||||||
|
final bodies = <Json>[];
|
||||||
|
final serving = server.listen((request) async {
|
||||||
|
calls.add('${request.method} ${request.uri}');
|
||||||
|
if (request.headers.value('authorization') !=
|
||||||
|
'Bearer recipient-${calls.length}') {
|
||||||
|
throw StateError('Recipient authorization/refresh mismatch');
|
||||||
|
}
|
||||||
|
if (request.uri.path.endsWith('stream')) {
|
||||||
|
streams++;
|
||||||
|
if (streams == 3) {
|
||||||
|
request.response.statusCode = 401;
|
||||||
|
request.response.write(
|
||||||
|
'{"error":{"code":"recipient-token-invalid","message":"Expired"}}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
request.response.headers.contentType = ContentType(
|
||||||
|
'text',
|
||||||
|
'event-stream',
|
||||||
|
);
|
||||||
|
request.response.write(
|
||||||
|
'event: inbox\r\ndata: {"messages":[],"unreadCount":$streams}\r\n\r\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (request.method == 'POST') {
|
||||||
|
final body = jsonDecode(await utf8.decoder.bind(request).join()) as Json;
|
||||||
|
bodies.add(body);
|
||||||
|
request.response.write(
|
||||||
|
'{"ok":true,"id":"d","revision":2,"replayed":false}',
|
||||||
|
);
|
||||||
|
} else if (request.uri.path.endsWith('preferences')) {
|
||||||
|
request.response.write(
|
||||||
|
'{"branding":{"displayName":"HTTP fixture"},"deliveryRules":{"timezone":"UTC","quietHours":null,"marketingDailyLimit":null},"preferences":[],"channelPreferences":[]}',
|
||||||
|
);
|
||||||
|
} else if (request.uri.path.endsWith('devices')) {
|
||||||
|
request.response.write(
|
||||||
|
'{"devices":[],"mobileDevices":[],"connections":[],"mobileConnections":[]}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
request.response.write('{"messages":[],"unreadCount":4}');
|
||||||
|
}
|
||||||
|
await request.response.close();
|
||||||
|
});
|
||||||
|
final client = InboxClient(
|
||||||
|
endpoint: Uri.parse('http://127.0.0.1:${server.port}'),
|
||||||
|
token: () => 'recipient-${++tokens}',
|
||||||
|
);
|
||||||
|
void require(bool value, String message) {
|
||||||
|
if (!value) throw StateError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
require((await client.messages(limit: 20)).unreadCount == 4, 'Snapshot');
|
||||||
|
await client.markRead('m');
|
||||||
|
await client.archive('m');
|
||||||
|
await client.preferences();
|
||||||
|
await client.devices();
|
||||||
|
await client.savePreferences(
|
||||||
|
const PreferenceInput(
|
||||||
|
category: '*',
|
||||||
|
inAppEnabled: true,
|
||||||
|
marketingEnabled: false,
|
||||||
|
deliveryRules: DeliveryRules(timezone: 'UTC'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.registerMobilePush(
|
||||||
|
const MobileRegistration(
|
||||||
|
connectionId: 'c',
|
||||||
|
installationId: 'i',
|
||||||
|
name: 'Phone',
|
||||||
|
platform: MobilePlatform.ios,
|
||||||
|
token: 'rotated',
|
||||||
|
marketingConsent: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.unregisterMobilePush('d');
|
||||||
|
await client.unregisterWebPush('w');
|
||||||
|
final received = <int>[];
|
||||||
|
try {
|
||||||
|
await for (final snapshot in client.updates()) {
|
||||||
|
received.add(snapshot.unreadCount);
|
||||||
|
}
|
||||||
|
} on InboxException catch (error) {
|
||||||
|
require(error.status == 401, 'Expected terminal auth error');
|
||||||
|
}
|
||||||
|
require(received.join(',') == '1,2', 'CRLF frames/reconnect');
|
||||||
|
require(streams == 3, 'Terminal authentication must stop retries');
|
||||||
|
require(calls.first == 'GET /api/v1/inbox/messages?limit=20', 'Query');
|
||||||
|
require(
|
||||||
|
bodies[0]['action'] == 'read' && bodies[1]['action'] == 'archive',
|
||||||
|
'Writes',
|
||||||
|
);
|
||||||
|
require(
|
||||||
|
(bodies[2]['deliveryRules'] as Json).containsKey('quietHours'),
|
||||||
|
'Explicit nullable setting',
|
||||||
|
);
|
||||||
|
require(
|
||||||
|
bodies[3]['installationId'] == 'i' && bodies[3]['token'] == 'rotated',
|
||||||
|
'Rotation',
|
||||||
|
);
|
||||||
|
stdout.writeln(
|
||||||
|
jsonEncode({
|
||||||
|
'localHttpChecks': 'passed',
|
||||||
|
'requests': calls.length,
|
||||||
|
'streamSnapshots': received,
|
||||||
|
'authorizationRefresh': true,
|
||||||
|
'providerSendsSubmitted': false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
await serving.cancel();
|
||||||
|
await server.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
swift test
|
||||||
|
xcodebuild -scheme WRNexusInbox -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build
|
||||||
Reference in new issue
Block a user