@zellio/chat-react-native 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -2
  3. package/ZellioChatReactNative.podspec +30 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatModule.kt +214 -0
  7. package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatPackage.kt +25 -0
  8. package/app.plugin.js +4 -0
  9. package/ios/RNZellioChat.h +5 -0
  10. package/ios/RNZellioChat.mm +116 -0
  11. package/ios/ZCRNBridge.swift +217 -0
  12. package/ios/vendor/resources/PrivacyInfo.xcprivacy +99 -0
  13. package/ios/vendor/swift/ZellioChat/Model/ChatModel.swift +261 -0
  14. package/ios/vendor/swift/ZellioChat/Platform/Platform.swift +124 -0
  15. package/ios/vendor/swift/ZellioChat/Platform/URLSessionTransport.swift +175 -0
  16. package/ios/vendor/swift/ZellioChat/Preview/Previews.swift +62 -0
  17. package/ios/vendor/swift/ZellioChat/Public/InAppBanner.swift +155 -0
  18. package/ios/vendor/swift/ZellioChat/Public/PublicTypes.swift +141 -0
  19. package/ios/vendor/swift/ZellioChat/Public/Runtime.swift +406 -0
  20. package/ios/vendor/swift/ZellioChat/Public/ZellioChat.swift +209 -0
  21. package/ios/vendor/swift/ZellioChat/Theme/ChatTheme.swift +185 -0
  22. package/ios/vendor/swift/ZellioChat/Thread/AttachmentPicker.swift +354 -0
  23. package/ios/vendor/swift/ZellioChat/Thread/ComposerBar.swift +355 -0
  24. package/ios/vendor/swift/ZellioChat/Thread/ThreadCells.swift +776 -0
  25. package/ios/vendor/swift/ZellioChat/Thread/ThreadViewController.swift +703 -0
  26. package/ios/vendor/swift/ZellioChat/Views/ChatRootView.swift +154 -0
  27. package/ios/vendor/swift/ZellioChat/Views/Components.swift +335 -0
  28. package/ios/vendor/swift/ZellioChat/Views/HomeView.swift +413 -0
  29. package/ios/vendor/swift/ZellioChatCore/ChatModels.swift +373 -0
  30. package/ios/vendor/swift/ZellioChatCore/ConfigModels.swift +389 -0
  31. package/ios/vendor/swift/ZellioChatCore/Engine.swift +325 -0
  32. package/ios/vendor/swift/ZellioChatCore/Errors.swift +158 -0
  33. package/ios/vendor/swift/ZellioChatCore/Generated/ChatStringTable.swift +125 -0
  34. package/ios/vendor/swift/ZellioChatCore/Outbox.swift +223 -0
  35. package/ios/vendor/swift/ZellioChatCore/Push.swift +74 -0
  36. package/ios/vendor/swift/ZellioChatCore/Realtime.swift +328 -0
  37. package/ios/vendor/swift/ZellioChatCore/Session.swift +210 -0
  38. package/ios/vendor/swift/ZellioChatCore/Storage.swift +201 -0
  39. package/ios/vendor/swift/ZellioChatCore/Strings.swift +104 -0
  40. package/ios/vendor/swift/ZellioChatCore/Theme.swift +238 -0
  41. package/ios/vendor/swift/ZellioChatCore/ThreadController.swift +319 -0
  42. package/ios/vendor/swift/ZellioChatCore/Timeline.swift +157 -0
  43. package/ios/vendor/swift/ZellioChatCore/Transport.swift +236 -0
  44. package/ios/vendor/swift/ZellioChatCore/Util.swift +137 -0
  45. package/lib/NativeZellioChat.d.ts +34 -0
  46. package/lib/NativeZellioChat.js +4 -0
  47. package/lib/index.d.ts +37 -0
  48. package/lib/index.js +241 -0
  49. package/lib/types.d.ts +54 -0
  50. package/lib/types.js +2 -0
  51. package/lib/version.d.ts +4 -0
  52. package/lib/version.js +7 -0
  53. package/package.json +75 -4
  54. package/plugin/build/index.js +73 -0
  55. package/src/NativeZellioChat.ts +30 -0
  56. package/src/index.ts +237 -0
  57. package/src/types.ts +56 -0
  58. package/src/version.ts +5 -0
@@ -0,0 +1,210 @@
1
+ import Foundation
2
+
3
+ public typealias IdentityTokenProvider = @Sendable () async throws -> String
4
+
5
+ public enum JWT {
6
+ /// The `sub` claim, read without checking the signature. The server checks it. Used to see a customer switch.
7
+ public static func subject(of token: String) -> String? {
8
+ let parts = token.split(separator: ".", omittingEmptySubsequences: false)
9
+ guard parts.count >= 2 else { return nil }
10
+ var base64 = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
11
+ while base64.count % 4 != 0 { base64 += "=" }
12
+ guard let data = Data(base64Encoded: base64),
13
+ let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
14
+ let sub = object["sub"] as? String, !sub.isEmpty else { return nil }
15
+ return sub
16
+ }
17
+ }
18
+
19
+ private struct SessionMeta: Codable, Equatable {
20
+ var subject: String? // the brand user id of the identified customer
21
+ var identified: Bool
22
+ var expiresAt: String?
23
+ }
24
+
25
+ /// Owns the session: creates it, keeps the token in the secret store, and applies the error-code rules.
26
+ public actor SessionCoordinator {
27
+ public static let tokenKey = "sessionToken"
28
+
29
+ private let api: ChatAPI
30
+ private let secrets: SecretStore
31
+ private let storage: StorageBackend
32
+ private let clock: ChatClock
33
+ private let installId: String
34
+ private let locale: @Sendable () -> ChatLocale
35
+ private let allowed: @Sendable () async throws -> Void
36
+ private let onWipe: @Sendable () -> Void
37
+ private let onSessionStarted: @Sendable () -> Void
38
+
39
+ private var provider: IdentityTokenProvider?
40
+ private var creating: Task<String, Error>?
41
+
42
+ /// - Parameters:
43
+ /// - allowed: throws before any session call when consent or config forbids it.
44
+ /// - onWipe: called when local data must go (logout, customer switch).
45
+ /// - onSessionStarted: called after a new token is stored. The server forgets a push token on a new session.
46
+ public init(api: ChatAPI, secrets: SecretStore, storage: StorageBackend, clock: ChatClock = SystemClock(), installId: String,
47
+ locale: @escaping @Sendable () -> ChatLocale, allowed: @escaping @Sendable () async throws -> Void = {}, onWipe: @escaping @Sendable () -> Void = {},
48
+ onSessionStarted: @escaping @Sendable () -> Void = {}) {
49
+ self.api = api
50
+ self.secrets = secrets
51
+ self.storage = storage
52
+ self.clock = clock
53
+ self.installId = installId
54
+ self.locale = locale
55
+ self.allowed = allowed
56
+ self.onWipe = onWipe
57
+ self.onSessionStarted = onSessionStarted
58
+ }
59
+
60
+ public func setProvider(_ provider: IdentityTokenProvider?) { self.provider = provider }
61
+ public var hasProvider: Bool { provider != nil }
62
+
63
+ public var token: String? {
64
+ guard let token = secrets.read(SessionCoordinator.tokenKey), !token.isEmpty else { return nil }
65
+ // No local expiry check: the server moves expiry forward on every send and does not tell the client.
66
+ // The token stays until the server answers CHAT_SESSION_INVALID.
67
+ return token
68
+ }
69
+
70
+ /// Forget the token, for example after the stream got a 401.
71
+ public func invalidateToken() { secrets.delete(SessionCoordinator.tokenKey) }
72
+
73
+ public var isIdentified: Bool { storage.load(SessionMeta.self, StorageName.session)?.identified ?? false }
74
+
75
+ /// A valid token, creating a session when needed.
76
+ public func ensureSession() async throws -> String {
77
+ if let token = token { return token }
78
+ return try await createSession()
79
+ }
80
+
81
+ private func createSession() async throws -> String {
82
+ if let running = creating { return try await running.value }
83
+ let task = Task { try await self.performCreate() }
84
+ creating = task
85
+ defer { creating = nil }
86
+ return try await task.value
87
+ }
88
+
89
+ private func performCreate() async throws -> String {
90
+ try await allowed()
91
+ var identityToken: String?
92
+ if let provider = provider {
93
+ identityToken = try await fetchIdentity(provider)
94
+ }
95
+ let previous = storage.load(SessionMeta.self, StorageName.session)
96
+ let subject = identityToken.flatMap { JWT.subject(of: $0) }
97
+ // A different customer: the logout path first.
98
+ if let old = previous?.subject, let new = subject, old != new {
99
+ if let stale = secrets.read(SessionCoordinator.tokenKey) { try? await api.logout(token: stale) }
100
+ await wipe()
101
+ }
102
+ do {
103
+ let info = try await api.createSession(installId: installId, locale: locale(), identityToken: identityToken)
104
+ return store(info, subject: subject, identified: identityToken != nil && info.identified)
105
+ } catch let error as ChatAPIError {
106
+ if ErrorPolicy.reaction(for: error) == .refreshIdentityOnce, let provider = provider {
107
+ // Get a new token once. If that fails too, the app must log in.
108
+ guard let fresh = try? await provider() else { throw ChatError.loginNeeded }
109
+ do {
110
+ let info = try await api.createSession(installId: installId, locale: locale(), identityToken: fresh)
111
+ return store(info, subject: JWT.subject(of: fresh), identified: info.identified)
112
+ } catch {
113
+ throw ChatError.loginNeeded
114
+ }
115
+ }
116
+ throw map(error)
117
+ }
118
+ }
119
+
120
+ /// Calls the provider. A failure is tried once more; a second failure means login is needed.
121
+ private func fetchIdentity(_ provider: @escaping IdentityTokenProvider) async throws -> String {
122
+ if let token = try? await provider(), !token.isEmpty { return token }
123
+ if let token = try? await provider(), !token.isEmpty { return token }
124
+ throw ChatError.loginNeeded
125
+ }
126
+
127
+ private func map(_ error: ChatAPIError) -> Error {
128
+ switch ErrorPolicy.reaction(for: error) {
129
+ case .showLoginNeeded: return ChatError.loginNeeded
130
+ case .stopUnavailable: return ChatError.unavailable
131
+ default: return error
132
+ }
133
+ }
134
+
135
+ private func store(_ info: SessionInfo, subject: String?, identified: Bool) -> String {
136
+ secrets.write(SessionCoordinator.tokenKey, value: info.sessionToken)
137
+ storage.save(SessionMeta(subject: subject, identified: identified, expiresAt: info.expiresAt), StorageName.session)
138
+ onSessionStarted()
139
+ return info.sessionToken
140
+ }
141
+
142
+ /// Turns a guest session into a customer session. Call after `identify` when a session already exists.
143
+ public func identifyCurrentSession() async throws {
144
+ guard let provider = provider else { return }
145
+ let meta = storage.load(SessionMeta.self, StorageName.session)
146
+ guard let current = token else { return } // no session yet: it is made with the token later
147
+ let identity = try await fetchIdentity(provider)
148
+ let subject = JWT.subject(of: identity)
149
+ if let old = meta?.subject, let new = subject, old != new {
150
+ try? await api.logout(token: current)
151
+ await wipe()
152
+ _ = try await createSession()
153
+ return
154
+ }
155
+ if meta?.identified == true && meta?.subject == subject { return }
156
+ do {
157
+ let info = try await api.identify(identityToken: identity, token: current)
158
+ _ = store(info, subject: subject, identified: true)
159
+ } catch let error as ChatAPIError {
160
+ if ErrorPolicy.reaction(for: error) == .recreateSessionThenRetry {
161
+ secrets.delete(SessionCoordinator.tokenKey)
162
+ _ = try await createSession()
163
+ } else {
164
+ throw map(error)
165
+ }
166
+ }
167
+ }
168
+
169
+ /// Runs a call with the session token. 401 with CHAT_SESSION_INVALID or CHAT_SESSION_REQUIRED makes a new
170
+ /// session and retries once. Every other error is passed on as it is.
171
+ public func perform<T>(_ operation: @Sendable (String) async throws -> T) async throws -> T {
172
+ let first = try await ensureSession()
173
+ do {
174
+ return try await operation(first)
175
+ } catch let error as ChatAPIError {
176
+ switch ErrorPolicy.reaction(for: error) {
177
+ case .recreateSessionThenRetry:
178
+ // Only the refused token goes. A parallel call may have stored a new one already: that one stays.
179
+ if secrets.read(SessionCoordinator.tokenKey) == first { secrets.delete(SessionCoordinator.tokenKey) }
180
+ let second = try await ensureSession()
181
+ do { return try await operation(second) } catch let again as ChatAPIError { throw mapFinal(again) }
182
+ default:
183
+ throw mapFinal(error)
184
+ }
185
+ }
186
+ }
187
+
188
+ private func mapFinal(_ error: ChatAPIError) -> Error {
189
+ switch ErrorPolicy.reaction(for: error) {
190
+ case .stopUnavailable: return ChatError.unavailable
191
+ case .showLoginNeeded: return ChatError.loginNeeded
192
+ default: return error
193
+ }
194
+ }
195
+
196
+ /// Ends the session. Local data goes even when the call fails.
197
+ public func logout() async {
198
+ if let token = token {
199
+ try? await api.logout(token: token)
200
+ }
201
+ await wipe()
202
+ provider = nil
203
+ }
204
+
205
+ private func wipe() async {
206
+ secrets.delete(SessionCoordinator.tokenKey)
207
+ storage.removeAll(keeping: StorageName.keptOnLogout)
208
+ onWipe()
209
+ }
210
+ }
@@ -0,0 +1,201 @@
1
+ import Foundation
2
+
3
+ /// Private local storage. The app target gives a file version that is excluded from backups.
4
+ public protocol StorageBackend: Sendable {
5
+ func read(_ name: String) -> Data?
6
+ func write(_ name: String, _ data: Data)
7
+ func remove(_ name: String)
8
+ /// Removes every entry except the listed names.
9
+ func removeAll(keeping: Set<String>)
10
+ func names() -> [String]
11
+ }
12
+
13
+ public final class MemoryStorage: StorageBackend, @unchecked Sendable {
14
+ private let store = Locked<[String: Data]>([:])
15
+ public init() {}
16
+ public func read(_ name: String) -> Data? { store.with { $0[name] } }
17
+ public func write(_ name: String, _ data: Data) { store.with { $0[name] = data } }
18
+ public func remove(_ name: String) { store.with { $0[name] = nil } }
19
+ public func removeAll(keeping: Set<String>) { store.with { dict in dict = dict.filter { keeping.contains($0.key) } } }
20
+ public func names() -> [String] { store.with { Array($0.keys) } }
21
+ }
22
+
23
+ public final class FileStorage: StorageBackend, @unchecked Sendable {
24
+ public let directory: URL
25
+ private let lock = NSLock()
26
+
27
+ public init(directory: URL) {
28
+ self.directory = directory
29
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
30
+ }
31
+
32
+ private func url(_ name: String) -> URL {
33
+ let safe = String(name.map { $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" || $0 == "_" ? $0 : "_" })
34
+ return directory.appendingPathComponent(safe)
35
+ }
36
+
37
+ public func read(_ name: String) -> Data? {
38
+ lock.lock(); defer { lock.unlock() }
39
+ return try? Data(contentsOf: url(name))
40
+ }
41
+
42
+ public func write(_ name: String, _ data: Data) {
43
+ lock.lock(); defer { lock.unlock() }
44
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
45
+ try? data.write(to: url(name), options: .atomic)
46
+ }
47
+
48
+ public func remove(_ name: String) {
49
+ lock.lock(); defer { lock.unlock() }
50
+ try? FileManager.default.removeItem(at: url(name))
51
+ }
52
+
53
+ public func removeAll(keeping: Set<String>) {
54
+ lock.lock(); defer { lock.unlock() }
55
+ let items = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
56
+ for item in items where !keeping.contains(item) {
57
+ try? FileManager.default.removeItem(at: directory.appendingPathComponent(item))
58
+ }
59
+ }
60
+
61
+ public func names() -> [String] {
62
+ lock.lock(); defer { lock.unlock() }
63
+ return (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
64
+ }
65
+ }
66
+
67
+ /// The session token lives here. The app target gives a Keychain version.
68
+ public protocol SecretStore: Sendable {
69
+ func read(_ key: String) -> String?
70
+ func write(_ key: String, value: String)
71
+ func delete(_ key: String)
72
+ }
73
+
74
+ public final class MemorySecretStore: SecretStore, @unchecked Sendable {
75
+ private let store = Locked<[String: String]>([:])
76
+ public init() {}
77
+ public func read(_ key: String) -> String? { store.with { $0[key] } }
78
+ public func write(_ key: String, value: String) { store.with { $0[key] = value } }
79
+ public func delete(_ key: String) { store.with { $0[key] = nil } }
80
+ }
81
+
82
+ extension StorageBackend {
83
+ /// Reads a JSON value. A file that does not decode is removed.
84
+ public func load<T: Decodable>(_ type: T.Type, _ name: String) -> T? {
85
+ guard let data = read(name) else { return nil }
86
+ if let value = try? JSONDecoder().decode(type, from: data) { return value }
87
+ remove(name)
88
+ return nil
89
+ }
90
+
91
+ public func save<T: Encodable>(_ value: T, _ name: String) {
92
+ if let data = try? JSONEncoder().encode(value) { write(name, data) }
93
+ }
94
+ }
95
+
96
+ public enum StorageName {
97
+ public static let installId = "install.json"
98
+ public static let consent = "consent.json"
99
+ public static let config = "config.json"
100
+ public static let conversations = "conversations.json"
101
+ public static let queue = "queue.json"
102
+ public static let session = "session.json"
103
+ public static let drafts = "drafts.json"
104
+ public static let push = "push.json"
105
+ public static func messages(_ conversationId: String) -> String { "messages-\(conversationId).json" }
106
+
107
+ /// Kept when the customer logs out: these belong to the install. Consent is not kept, because another person can use the phone.
108
+ public static let keptOnLogout: Set<String> = [installId, push]
109
+ }
110
+
111
+ /// The random id of this install. Resets on reinstall because it lives in app storage.
112
+ public enum InstallId {
113
+ private struct Box: Codable { var id: String }
114
+
115
+ public static func load(_ storage: StorageBackend) -> String {
116
+ if let box = storage.load(Box.self, StorageName.installId), !box.id.isEmpty { return box.id }
117
+ let id = UUID().uuidString.lowercased()
118
+ storage.save(Box(id: id), StorageName.installId)
119
+ return id
120
+ }
121
+ }
122
+
123
+ /// Last good config, saved raw and parsed again on load.
124
+ public struct ConfigCache: Sendable {
125
+ private let storage: StorageBackend
126
+ public init(storage: StorageBackend) { self.storage = storage }
127
+
128
+ public func save(body: Data) { storage.write(StorageName.config, body) }
129
+
130
+ public func load() -> ChatConfig? {
131
+ guard let body = storage.read(StorageName.config) else { return nil }
132
+ if let config = try? ChatConfig.parse(body) { return config }
133
+ storage.remove(StorageName.config)
134
+ return nil
135
+ }
136
+ }
137
+
138
+ /// Draft text by conversation. `nil` key means a new conversation.
139
+ public struct DraftStore: Sendable {
140
+ private let storage: StorageBackend
141
+ public init(storage: StorageBackend) { self.storage = storage }
142
+
143
+ private func key(_ conversationId: String?) -> String { conversationId ?? "_new" }
144
+
145
+ public func get(_ conversationId: String?) -> String {
146
+ (storage.load([String: String].self, StorageName.drafts) ?? [:])[key(conversationId)] ?? ""
147
+ }
148
+
149
+ public func set(_ text: String, conversationId: String?) {
150
+ var all = storage.load([String: String].self, StorageName.drafts) ?? [:]
151
+ if text.isEmpty { all[key(conversationId)] = nil } else { all[key(conversationId)] = text }
152
+ storage.save(all, StorageName.drafts)
153
+ }
154
+ }
155
+
156
+ /// What a customer accepts: the consent text and the privacy link, not the rest of the published config.
157
+ public struct ConsentTerms: Codable, Equatable, Sendable {
158
+ public var en: String
159
+ public var ar: String
160
+ public var privacyUrl: String
161
+
162
+ public init(behavior: Behavior) {
163
+ en = (behavior.consentText.en ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
164
+ ar = (behavior.consentText.ar ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
165
+ privacyUrl = (behavior.privacyUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
166
+ }
167
+ }
168
+
169
+ /// What the customer accepted on this install.
170
+ public struct ConsentStore: Sendable {
171
+ private let storage: StorageBackend
172
+ public init(storage: StorageBackend) { self.storage = storage }
173
+
174
+ public func isAccepted(_ terms: ConsentTerms) -> Bool {
175
+ storage.load(ConsentTerms.self, StorageName.consent) == terms
176
+ }
177
+
178
+ public func accept(_ terms: ConsentTerms) {
179
+ storage.save(terms, StorageName.consent)
180
+ }
181
+ }
182
+
183
+ public enum ConsentState: Equatable, Sendable {
184
+ case notRequired
185
+ case required
186
+ case granted
187
+ }
188
+
189
+ public enum ConsentGate {
190
+ public static func state(config: ChatConfig, store: ConsentStore) -> ConsentState {
191
+ if !config.snapshot.behavior.consentRequired { return .notRequired }
192
+ // A publish that changes only a color or a label must not ask again.
193
+ return store.isAccepted(ConsentTerms(behavior: config.snapshot.behavior)) ? .granted : .required
194
+ }
195
+
196
+ /// No session, no history and no stream before Accept.
197
+ public static func allowsSession(config: ChatConfig?, store: ConsentStore) -> Bool {
198
+ guard let config = config else { return false }
199
+ return state(config: config, store: store) != .required
200
+ }
201
+ }
@@ -0,0 +1,104 @@
1
+ import Foundation
2
+ #if canImport(FoundationNetworking)
3
+ import FoundationNetworking
4
+ #endif
5
+
6
+ /// Looks up SDK strings. The table is generated from sdks/design/strings.json.
7
+ public struct ChatStrings: Sendable {
8
+ public let locale: ChatLocale
9
+
10
+ public init(locale: ChatLocale) { self.locale = locale }
11
+
12
+ public func string(_ key: String) -> String {
13
+ let table = locale == .ar ? ChatStringTable.ar : ChatStringTable.en
14
+ return table[key] ?? ChatStringTable.en[key] ?? key
15
+ }
16
+
17
+ public static var keys: Set<String> { Set(ChatStringTable.en.keys) }
18
+ public static var arabicKeys: Set<String> { Set(ChatStringTable.ar.keys) }
19
+
20
+ /// "{sender}, {text}, {time}, {delivery}" for VoiceOver. Empty parts are left out.
21
+ public func accessibilitySummary(sender: String, text: String, time: String, delivery: String?) -> String {
22
+ let separator = locale == .ar ? "، " : ", "
23
+ return [sender, text, time, delivery ?? ""].filter { !$0.isEmpty }.joined(separator: separator)
24
+ }
25
+ }
26
+
27
+ public enum ChatFormat {
28
+ /// Latin digits in both languages, like the web widget.
29
+ public static func formatter(_ locale: ChatLocale) -> Locale {
30
+ Locale(identifier: locale == .ar ? "ar@numbers=latn" : "en_US_POSIX")
31
+ }
32
+
33
+ public static func time(_ date: Date, locale: ChatLocale, timeZone: TimeZone = .current) -> String {
34
+ let f = DateFormatter()
35
+ f.locale = formatter(locale)
36
+ f.timeZone = timeZone
37
+ f.dateFormat = "h:mm a"
38
+ return f.string(from: date)
39
+ }
40
+
41
+ public static func day(_ date: Date, locale: ChatLocale, timeZone: TimeZone = .current) -> String {
42
+ let f = DateFormatter()
43
+ f.locale = formatter(locale)
44
+ f.timeZone = timeZone
45
+ f.dateStyle = .medium
46
+ f.timeStyle = .none
47
+ return f.string(from: date)
48
+ }
49
+
50
+ /// "Today", "Yesterday", or a date.
51
+ public static func daySeparator(_ date: Date, now: Date, locale: ChatLocale, timeZone: TimeZone = .current) -> String {
52
+ var calendar = Calendar(identifier: .gregorian)
53
+ calendar.timeZone = timeZone
54
+ let strings = ChatStrings(locale: locale)
55
+ if calendar.isDate(date, inSameDayAs: now) { return strings.string("thread.today") }
56
+ if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(date, inSameDayAs: yesterday) {
57
+ return strings.string("thread.yesterday")
58
+ }
59
+ return day(date, locale: locale, timeZone: timeZone)
60
+ }
61
+
62
+ /// Conversation list time: the time today, "Yesterday", or a date.
63
+ public static func listTime(_ date: Date, now: Date, locale: ChatLocale, timeZone: TimeZone = .current) -> String {
64
+ var calendar = Calendar(identifier: .gregorian)
65
+ calendar.timeZone = timeZone
66
+ if calendar.isDate(date, inSameDayAs: now) { return time(date, locale: locale, timeZone: timeZone) }
67
+ return daySeparator(date, now: now, locale: locale, timeZone: timeZone)
68
+ }
69
+
70
+ public static func fileSize(_ bytes: Int) -> String {
71
+ if bytes < 1024 { return "\(bytes) B" }
72
+ if bytes < 1024 * 1024 { return "\(Int((Double(bytes) / 1024).rounded())) KB" }
73
+ let mb = Double(bytes) / (1024 * 1024)
74
+ return String(format: "%.1f MB", mb)
75
+ }
76
+ }
77
+
78
+ /// Which language the chat shows.
79
+ public enum LocaleChoice {
80
+ /// Order: the code option, then the snapshot language policy.
81
+ /// `option` nil means follow the device.
82
+ public static func choose(option: ChatLocale?, policy: LanguagePolicy?, deviceLanguageCode: String?) -> ChatLocale {
83
+ let device = deviceLocale(deviceLanguageCode)
84
+ if let option = option { return option }
85
+ if let policy = policy {
86
+ if case .single(let locale) = policy { return locale }
87
+ if case .both(let defaultLocale, let startIn) = policy {
88
+ switch startIn {
89
+ case "ar": return .ar
90
+ case "en": return .en
91
+ default: return device ?? defaultLocale
92
+ }
93
+ }
94
+ }
95
+ return device ?? .en
96
+ }
97
+
98
+ /// `ar` or `en` only. Accepts "ar-EG", "ar_EG", "EN".
99
+ public static func deviceLocale(_ code: String?) -> ChatLocale? {
100
+ guard let code = code?.lowercased() else { return nil }
101
+ let language = code.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map(String.init) ?? code
102
+ return ChatLocale(rawValue: language)
103
+ }
104
+ }