receiving messages in the background and sending local notifications (#284)
* receiving messages in the background and sending local notifications * show notifications in foreground and background * presentation logic for notification options when app is in the foreground * background refresh works * remove async dispatch
This commit is contained in:
parent
ff7a8cade1
commit
516c8d79ad
@ -9,6 +9,7 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var showNotificationAlert = false
|
||||
|
||||
var body: some View {
|
||||
if let user = chatModel.currentUser {
|
||||
@ -20,21 +21,27 @@ struct ContentView: View {
|
||||
} catch {
|
||||
fatalError("Failed to start or load chats: \(error)")
|
||||
}
|
||||
|
||||
DispatchQueue.global().async {
|
||||
while(true) {
|
||||
do {
|
||||
try processReceivedMsg(chatModel, chatRecvMsg())
|
||||
} catch {
|
||||
print("error receiving message: ", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
showNotificationAlert = true
|
||||
})
|
||||
}
|
||||
.alert(isPresented : $showNotificationAlert){
|
||||
Alert(
|
||||
title: Text("Notification are disabled!"),
|
||||
message: Text("Please open settings to enable"),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
WelcomeView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
97
apps/ios/Shared/Model/BGManager.swift
Normal file
97
apps/ios/Shared/Model/BGManager.swift
Normal file
@ -0,0 +1,97 @@
|
||||
//
|
||||
// BGManager.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
|
||||
private let receiveTaskId = "chat.simplex.app.receive"
|
||||
|
||||
// TCP timeout + 2 sec
|
||||
private let waitForMessages: TimeInterval = 6
|
||||
|
||||
class BGManager {
|
||||
private var bgTimer: Timer?
|
||||
|
||||
static let shared = BGManager()
|
||||
|
||||
func register() {
|
||||
logger.debug("BGManager.register")
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in
|
||||
self.handleRefresh(RefreshTask(task as! BGAppRefreshTask))
|
||||
}
|
||||
}
|
||||
|
||||
func schedule() {
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 10 * 60)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
logger.error("BGManager.schedule error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRefresh(_ task: RefreshTask) {
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
schedule()
|
||||
task.expirationHandler = {
|
||||
logger.debug("BGManager.handleRefresh expirationHandler")
|
||||
ChatReceiver.shared.stop()
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
initializeChat()
|
||||
if ChatModel.shared.currentUser == nil {
|
||||
task.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh: starting chat")
|
||||
ChatReceiver.shared.start(bgTask: task)
|
||||
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
|
||||
self.bgTimer = timer
|
||||
logger.debug("BGManager.handleRefresh: timer")
|
||||
if ChatReceiver.shared.lastMsgTime.distance(to: Date.now) >= waitForMessages {
|
||||
logger.debug("BGManager.handleRefresh: timer: stopping")
|
||||
ChatReceiver.shared.stop()
|
||||
task.setTaskCompleted(success: true)
|
||||
timer.invalidate()
|
||||
self.bgTimer = nil
|
||||
}
|
||||
}, forMode: .default)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateStopTimer() {
|
||||
logger.debug("BGManager.invalidateStopTimer?")
|
||||
if let timer = bgTimer {
|
||||
timer.invalidate()
|
||||
bgTimer = nil
|
||||
logger.debug("BGManager.invalidateStopTimer: done")
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshTask {
|
||||
private let task: BGAppRefreshTask
|
||||
var completed = false
|
||||
|
||||
internal init(_ task: BGAppRefreshTask) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
var expirationHandler: (() -> Void)? {
|
||||
set { task.expirationHandler = newValue }
|
||||
get { task.expirationHandler }
|
||||
}
|
||||
|
||||
func setTaskCompleted(success: Bool) {
|
||||
if !completed { task.setTaskCompleted(success: success) }
|
||||
completed = true
|
||||
}
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var userAddress: String?
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var connectViaUrl = false
|
||||
static let shared = ChatModel()
|
||||
|
||||
func hasChat(_ id: String) -> Bool {
|
||||
chats.first(where: { $0.id == id }) != nil
|
||||
@ -84,8 +85,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
if chatId == cInfo.id {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
} else if chatId != nil {
|
||||
// meesage arrived to some other chat, show notification
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +128,7 @@ typealias ContactName = String
|
||||
|
||||
typealias GroupName = String
|
||||
|
||||
struct Profile: Codable {
|
||||
struct Profile: Codable, NamedChat {
|
||||
var displayName: String
|
||||
var fullName: String
|
||||
|
||||
@ -145,7 +144,20 @@ enum ChatType: String {
|
||||
case contactRequest = "<@"
|
||||
}
|
||||
|
||||
enum ChatInfo: Identifiable, Decodable {
|
||||
protocol NamedChat {
|
||||
var displayName: String { get }
|
||||
var fullName: String { get }
|
||||
}
|
||||
|
||||
extension NamedChat {
|
||||
var chatViewName: String {
|
||||
get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
|
||||
}
|
||||
}
|
||||
|
||||
typealias ChatId = String
|
||||
|
||||
enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
case direct(contact: Contact)
|
||||
case group(groupInfo: GroupInfo)
|
||||
case contactRequest(contactRequest: UserContactRequest)
|
||||
@ -163,9 +175,9 @@ enum ChatInfo: Identifiable, Decodable {
|
||||
var displayName: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.profile.displayName
|
||||
case let .group(groupInfo): return groupInfo.groupProfile.displayName
|
||||
case let .contactRequest(contactRequest): return contactRequest.profile.displayName
|
||||
case let .direct(contact): return contact.displayName
|
||||
case let .group(groupInfo): return groupInfo.displayName
|
||||
case let .contactRequest(contactRequest): return contactRequest.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -173,18 +185,14 @@ enum ChatInfo: Identifiable, Decodable {
|
||||
var fullName: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.profile.fullName
|
||||
case let .group(groupInfo): return groupInfo.groupProfile.fullName
|
||||
case let .contactRequest(contactRequest): return contactRequest.profile.fullName
|
||||
case let .direct(contact): return contact.fullName
|
||||
case let .group(groupInfo): return groupInfo.fullName
|
||||
case let .contactRequest(contactRequest): return contactRequest.fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chatViewName: String {
|
||||
get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
|
||||
}
|
||||
|
||||
var id: String {
|
||||
var id: ChatId {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.id
|
||||
@ -292,17 +300,17 @@ final class Chat: ObservableObject, Identifiable {
|
||||
self.chatItems = chatItems
|
||||
}
|
||||
|
||||
var id: String { get { chatInfo.id } }
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
}
|
||||
|
||||
struct ChatData: Decodable, Identifiable {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItems: [ChatItem]
|
||||
|
||||
var id: String { get { chatInfo.id } }
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
}
|
||||
|
||||
struct Contact: Identifiable, Decodable {
|
||||
struct Contact: Identifiable, Decodable, NamedChat {
|
||||
var contactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
var profile: Profile
|
||||
@ -310,9 +318,11 @@ struct Contact: Identifiable, Decodable {
|
||||
var viaGroup: Int64?
|
||||
var createdAt: Date
|
||||
|
||||
var id: String { get { "@\(contactId)" } }
|
||||
var id: ChatId { get { "@\(contactId)" } }
|
||||
var apiId: Int64 { get { contactId } }
|
||||
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
|
||||
var displayName: String { get { profile.displayName } }
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
static let sampleData = Contact(
|
||||
contactId: 1,
|
||||
@ -329,15 +339,16 @@ struct Connection: Decodable {
|
||||
static let sampleData = Connection(connStatus: "ready")
|
||||
}
|
||||
|
||||
struct UserContactRequest: Decodable {
|
||||
struct UserContactRequest: Decodable, NamedChat {
|
||||
var contactRequestId: Int64
|
||||
var localDisplayName: ContactName
|
||||
var profile: Profile
|
||||
var createdAt: Date
|
||||
|
||||
var id: String { get { "<@\(contactRequestId)" } }
|
||||
|
||||
var id: ChatId { get { "<@\(contactRequestId)" } }
|
||||
var apiId: Int64 { get { contactRequestId } }
|
||||
var displayName: String { get { profile.displayName } }
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
static let sampleData = UserContactRequest(
|
||||
contactRequestId: 1,
|
||||
@ -347,15 +358,16 @@ struct UserContactRequest: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupInfo: Identifiable, Decodable {
|
||||
struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
var groupProfile: GroupProfile
|
||||
var createdAt: Date
|
||||
|
||||
var id: String { get { "#\(groupId)" } }
|
||||
|
||||
var id: ChatId { get { "#\(groupId)" } }
|
||||
var apiId: Int64 { get { groupId } }
|
||||
var displayName: String { get { groupProfile.displayName } }
|
||||
var fullName: String { get { groupProfile.fullName } }
|
||||
|
||||
static let sampleData = GroupInfo(
|
||||
groupId: 1,
|
||||
@ -365,7 +377,7 @@ struct GroupInfo: Identifiable, Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupProfile: Codable {
|
||||
struct GroupProfile: Codable, NamedChat {
|
||||
var displayName: String
|
||||
var fullName: String
|
||||
|
||||
|
199
apps/ios/Shared/Model/NtfManager.swift
Normal file
199
apps/ios/Shared/Model/NtfManager.swift
Normal file
@ -0,0 +1,199 @@
|
||||
//
|
||||
// NtfManager.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
let ntfActionAccept = "NTF_ACT_ACCEPT"
|
||||
|
||||
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
|
||||
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
|
||||
let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
|
||||
|
||||
let appNotificationId = "chat.simplex.app.notification"
|
||||
|
||||
private let ntfTimeInterval: TimeInterval = 1
|
||||
|
||||
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NtfManager()
|
||||
|
||||
private var granted = false
|
||||
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
|
||||
|
||||
|
||||
// Handle notification when app is in background
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler handler: () -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive")
|
||||
let content = response.notification.request.content
|
||||
let chatModel = ChatModel.shared
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
|
||||
let chatId = content.userInfo["chatId"] as? String,
|
||||
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
acceptContactRequest(contactRequest)
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
// Handle notification when the app is in foreground
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler handler: (UNNotificationPresentationOptions) -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: willPresent")
|
||||
handler(presentationOptions(notification.request.content))
|
||||
}
|
||||
|
||||
private func presentationOptions(_ content: UNNotificationContent) -> UNNotificationPresentationOptions {
|
||||
let model = ChatModel.shared
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
switch content.categoryIdentifier {
|
||||
case ntfCategoryContactRequest:
|
||||
return [.sound, .banner, .list]
|
||||
case ntfCategoryContactConnected:
|
||||
return model.chatId == nil ? [.sound, .list] : [.sound, .banner, .list]
|
||||
case ntfCategoryMessageReceived:
|
||||
if model.chatId == nil {
|
||||
// in the chat list
|
||||
return recentInTheSameChat(content) ? [] : [.sound, .list]
|
||||
} else if model.chatId == content.targetContentIdentifier {
|
||||
// in the current chat
|
||||
return recentInTheSameChat(content) ? [] : [.sound, .list]
|
||||
} else {
|
||||
// in another chat
|
||||
return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list]
|
||||
}
|
||||
default: return [.sound, .banner, .list]
|
||||
}
|
||||
} else {
|
||||
return [.sound, .banner, .list]
|
||||
}
|
||||
}
|
||||
|
||||
private func recentInTheSameChat(_ content: UNNotificationContent) -> Bool {
|
||||
let now = Date.now
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
var res: Bool = false
|
||||
if let t = prevNtfTime[chatId] { res = t.distance(to: now) < 30 }
|
||||
prevNtfTime[chatId] = now
|
||||
return res
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func registerCategories() {
|
||||
logger.debug("NtfManager.registerCategories")
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactRequest,
|
||||
actions: [UNNotificationAction(
|
||||
identifier: ntfActionAccept,
|
||||
title: "Accept"
|
||||
)],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "New contact request"
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactConnected,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "Contact is connected"
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryMessageReceived,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "New message"
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
|
||||
logger.debug("NtfManager.requestAuthorization")
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .denied:
|
||||
if let handler = handler { handler() }
|
||||
return
|
||||
case .authorized:
|
||||
self.granted = true
|
||||
default:
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if let error = error {
|
||||
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
|
||||
} else {
|
||||
self.granted = granted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryContactRequest,
|
||||
title: "\(contactRequest.displayName) wants to connect!",
|
||||
body: "Accept contact request from \(contactRequest.chatViewName)?",
|
||||
targetContentIdentifier: nil,
|
||||
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
|
||||
)
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryContactConnected,
|
||||
title: "\(contact.displayName) is connected!",
|
||||
body: "You can now send messages to \(contact.chatViewName)",
|
||||
targetContentIdentifier: contact.id
|
||||
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
|
||||
)
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryMessageReceived,
|
||||
title: "\(cInfo.chatViewName):",
|
||||
body: cItem.content.text,
|
||||
targetContentIdentifier: cInfo.id
|
||||
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
|
||||
)
|
||||
}
|
||||
|
||||
private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
|
||||
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) {
|
||||
if !granted { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.categoryIdentifier = categoryIdentifier
|
||||
content.title = title
|
||||
if let s = subtitle { content.subtitle = s }
|
||||
if let s = body { content.body = s }
|
||||
content.targetContentIdentifier = targetContentIdentifier
|
||||
content.userInfo = userInfo
|
||||
content.sound = .default
|
||||
// content.interruptionLevel = .active
|
||||
// content.relevanceScore = 0.5 // 0-1
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error { logger.error("addNotification error: \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotifications(_ ids : [String]){
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
|
||||
}
|
||||
}
|
@ -8,6 +8,8 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Dispatch
|
||||
import BackgroundTasks
|
||||
|
||||
private var chatController: chat_ctrl?
|
||||
private let jsonDecoder = getJSONDecoder()
|
||||
@ -82,6 +84,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case contactSubscribed(contact: Contact)
|
||||
case contactDisconnected(contact: Contact)
|
||||
case contactSubError(contact: Contact, chatError: ChatError)
|
||||
case groupSubscribed(groupInfo: GroupInfo)
|
||||
case groupEmpty(groupInfo: GroupInfo)
|
||||
case userContactLinkSubscribed
|
||||
case newChatItem(chatItem: AChatItem)
|
||||
case chatCmdError(chatError: ChatError)
|
||||
case chatError(chatError: ChatError)
|
||||
@ -111,6 +116,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case .contactSubscribed: return "contactSubscribed"
|
||||
case .contactDisconnected: return "contactDisconnected"
|
||||
case .contactSubError: return "contactSubError"
|
||||
case .groupSubscribed: return "groupSubscribed"
|
||||
case .groupEmpty: return "groupEmpty"
|
||||
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
|
||||
case .newChatItem: return "newChatItem"
|
||||
case .chatCmdError: return "chatCmdError"
|
||||
case .chatError: return "chatError"
|
||||
@ -143,6 +151,9 @@ enum ChatResponse: Decodable, Error {
|
||||
case let .contactSubscribed(contact): return String(describing: contact)
|
||||
case let .contactDisconnected(contact): return String(describing: contact)
|
||||
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
|
||||
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
|
||||
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
|
||||
case .userContactLinkSubscribed: return noDetails
|
||||
case let .newChatItem(chatItem): return String(describing: chatItem)
|
||||
case let .chatCmdError(chatError): return String(describing: chatError)
|
||||
case let .chatError(chatError): return String(describing: chatError)
|
||||
@ -187,12 +198,12 @@ enum TerminalItem: Identifiable {
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
|
||||
// DispatchQueue.main.async {
|
||||
// termId += 1
|
||||
// chatModel.terminalItems.append(.cmd(termId, cmd))
|
||||
// }
|
||||
return chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
|
||||
ChatModel.shared.terminalItems.append(.resp(.now, resp))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func chatRecvMsg() throws -> ChatResponse {
|
||||
@ -201,7 +212,6 @@ func chatRecvMsg() throws -> ChatResponse {
|
||||
|
||||
func apiGetActiveUser() throws -> User? {
|
||||
let _ = getChatCtrl()
|
||||
sleep(1)
|
||||
let r = try chatSendCmd(.showActiveUser)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
@ -305,18 +315,98 @@ func apiRejectContactRequest(contactReqId: Int64) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) {
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||
} catch let error {
|
||||
logger.error("acceptContactRequest error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
ChatModel.shared.removeChat(contactRequest.id)
|
||||
} catch let error {
|
||||
logger.error("rejectContactRequest: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func initializeChat() {
|
||||
do {
|
||||
ChatModel.shared.currentUser = try apiGetActiveUser()
|
||||
} catch {
|
||||
fatalError("Failed to initialize chat controller or database: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
class ChatReceiver {
|
||||
private var receiveLoop: DispatchWorkItem?
|
||||
private var receiveMessages = true
|
||||
private var wasStarted = false
|
||||
private var _canStop = false
|
||||
private var _lastMsgTime = Date.now
|
||||
|
||||
static let shared = ChatReceiver()
|
||||
|
||||
var lastMsgTime: Date { get { _lastMsgTime } }
|
||||
|
||||
func start(bgTask: BGManager.RefreshTask? = nil) {
|
||||
logger.debug("ChatReceiver.start")
|
||||
wasStarted = true
|
||||
receiveMessages = true
|
||||
_canStop = true
|
||||
_lastMsgTime = .now
|
||||
if receiveLoop != nil { return }
|
||||
let loop = DispatchWorkItem(qos: .default, flags: []) {
|
||||
while self.receiveMessages {
|
||||
do {
|
||||
processReceivedMsg(try chatRecvMsg())
|
||||
self._lastMsgTime = .now
|
||||
} catch {
|
||||
logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
if let task = bgTask { task.setTaskCompleted(success: true) }
|
||||
}
|
||||
receiveLoop = loop
|
||||
DispatchQueue.global().async(execute: loop)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
logger.debug("ChatReceiver.stop?")
|
||||
if !_canStop { return }
|
||||
receiveMessages = false
|
||||
receiveLoop?.cancel()
|
||||
receiveLoop = nil
|
||||
logger.debug("ChatReceiver.stop: done")
|
||||
}
|
||||
|
||||
func restart() {
|
||||
logger.debug("ChatReceiver.restart?")
|
||||
if wasStarted && receiveLoop == nil { start() }
|
||||
_canStop = false
|
||||
}
|
||||
}
|
||||
|
||||
func processReceivedMsg(_ res: ChatResponse) {
|
||||
let chatModel = ChatModel.shared
|
||||
DispatchQueue.main.async {
|
||||
chatModel.terminalItems.append(.resp(.now, res))
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactConnected(contact):
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .connected)
|
||||
NtfManager.shared.notifyContactConnected(contact)
|
||||
case let .receivedContactRequest(contactRequest):
|
||||
chatModel.addChat(Chat(
|
||||
chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
|
||||
chatItems: []
|
||||
))
|
||||
NtfManager.shared.notifyContactRequest(contactRequest)
|
||||
case let .contactUpdated(toContact):
|
||||
let cInfo = ChatInfo.direct(contact: toContact)
|
||||
if chatModel.hasChat(toContact.id) {
|
||||
@ -338,9 +428,12 @@ func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) {
|
||||
}
|
||||
chatModel.updateNetworkStatus(contact, .error(err))
|
||||
case let .newChatItem(aChatItem):
|
||||
chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem)
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
default:
|
||||
print("unsupported response: ", res.responseType)
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -363,7 +456,7 @@ private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
|
||||
let r = try jsonDecoder.decode(APIResponse.self, from: d)
|
||||
return r.resp
|
||||
} catch {
|
||||
print (error)
|
||||
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
var type: String?
|
||||
|
@ -28,3 +28,5 @@ for match in matches {
|
||||
let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
|
||||
|
||||
print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
|
||||
|
||||
let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")
|
||||
|
@ -3,7 +3,7 @@
|
||||
version = "3.0">
|
||||
<TimelineItems>
|
||||
<LoggerValueHistoryTimelineItem
|
||||
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&CharacterRangeLoc=91&EndingColumnNumber=0&EndingLineNumber=7&StartingColumnNumber=3&StartingLineNumber=6&Timestamp=665873333.107271"
|
||||
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&CharacterRangeLoc=91&EndingColumnNumber=0&EndingLineNumber=7&StartingColumnNumber=3&StartingLineNumber=6&Timestamp=666087303.155273"
|
||||
selectedRepresentationIndex = "0"
|
||||
shouldTrackSuperviewWidth = "NO">
|
||||
</LoggerValueHistoryTimelineItem>
|
||||
|
@ -6,29 +6,39 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
let logger = Logger()
|
||||
|
||||
@main
|
||||
struct SimpleXApp: App {
|
||||
@StateObject private var chatModel = ChatModel()
|
||||
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
init() {
|
||||
hs_init(0, nil)
|
||||
BGManager.shared.register()
|
||||
NtfManager.shared.registerCategories()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
return WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
chatModel.connectViaUrl = true
|
||||
print(url)
|
||||
}
|
||||
.onAppear() {
|
||||
do {
|
||||
chatModel.currentUser = try apiGetActiveUser()
|
||||
} catch {
|
||||
fatalError("Failed to initialize chat controller or database: \(error)")
|
||||
initializeChat()
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .background {
|
||||
BGManager.shared.schedule()
|
||||
} else {
|
||||
BGManager.shared.invalidateStopTimer()
|
||||
ChatReceiver.shared.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ struct ChatInfoView: View {
|
||||
chatModel.removeChat(contact.id)
|
||||
showChatInfo = false
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
}
|
||||
alertContact = nil
|
||||
}, secondaryButton: .cancel() {
|
||||
|
@ -98,7 +98,7 @@ struct ChatView: View {
|
||||
let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ struct ChatListNavLink: View {
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatModel.chatItems = chat.chatItems
|
||||
} catch {
|
||||
print("apiGetChatItems", error)
|
||||
logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -121,7 +121,7 @@ struct ChatListNavLink: View {
|
||||
try apiDeleteChat(type: .direct, id: contact.apiId)
|
||||
chatModel.removeChat(contact.id)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
}
|
||||
alertContact = nil
|
||||
}, secondaryButton: .cancel() {
|
||||
@ -149,25 +149,6 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
chatModel.removeChat(contactRequest.id)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListNavLink_Previews: PreviewProvider {
|
||||
|
@ -42,15 +42,17 @@ struct ChatListView: View {
|
||||
NewChatButton()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $connectAlert) { connectionErrorAlert() }
|
||||
.alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() }
|
||||
}
|
||||
.alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() }
|
||||
.alert(isPresented: $connectAlert) { connectionErrorAlert() }
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaUrlAlert() -> Alert {
|
||||
logger.debug("ChatListView.connectViaUrlAlert")
|
||||
if let url = chatModel.appOpenUrl {
|
||||
var path = url.path
|
||||
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
@ -63,7 +65,7 @@ struct ChatListView: View {
|
||||
} catch {
|
||||
connectAlert = true
|
||||
connectError = error
|
||||
print(error)
|
||||
logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(error.localizedDescription)")
|
||||
}
|
||||
chatModel.appOpenUrl = nil
|
||||
}, secondaryButton: .cancel() {
|
||||
@ -71,7 +73,7 @@ struct ChatListView: View {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL not available"))
|
||||
return Alert(title: Text("Error: URL is invalid"))
|
||||
}
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL not available"))
|
||||
|
@ -37,11 +37,11 @@ struct ConnectContactView: View {
|
||||
try apiConnect(connReq: r.string)
|
||||
completed(nil)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
|
||||
completed(error)
|
||||
}
|
||||
case let .failure(e):
|
||||
print(e)
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
completed(e)
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ struct NewChatButton: View {
|
||||
} catch {
|
||||
addContactAlert = true
|
||||
addContactError = error
|
||||
print(error)
|
||||
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,17 +52,12 @@ struct TerminalView: View {
|
||||
|
||||
func sendMessage(_ cmdStr: String) {
|
||||
let cmd = ChatCommand.string(cmdStr)
|
||||
chatModel.terminalItems.append(.cmd(.now, cmd))
|
||||
|
||||
DispatchQueue.global().async {
|
||||
inProgress = true
|
||||
do {
|
||||
let r = try chatSendCmd(cmd)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.terminalItems.append(.resp(.now, r))
|
||||
}
|
||||
let _ = try chatSendCmd(cmd)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("TerminalView.sendMessage chatSendCmd error: \(error.localizedDescription)")
|
||||
}
|
||||
inProgress = false
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ struct SettingsButton: View {
|
||||
do {
|
||||
chatModel.userAddress = try apiGetUserAddress()
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -39,7 +39,7 @@ struct UserAddress: View {
|
||||
try apiDeleteUserAddress()
|
||||
chatModel.userAddress = nil
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
|
||||
}
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
@ -52,7 +52,7 @@ struct UserAddress: View {
|
||||
do {
|
||||
chatModel.userAddress = try apiCreateUserAddress()
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
|
||||
}
|
||||
} label: { Label("Create address", systemImage: "qrcode") }
|
||||
.frame(maxWidth: .infinity)
|
||||
|
@ -68,7 +68,7 @@ struct UserProfile: View {
|
||||
profile = newProfile
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)")
|
||||
}
|
||||
editProfile = false
|
||||
}
|
||||
|
@ -26,6 +26,10 @@
|
||||
5C35CF7127B031FB00FB6C6D /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6C27B031FB00FB6C6D /* libgmp.a */; };
|
||||
5C35CF7227B031FB00FB6C6D /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6D27B031FB00FB6C6D /* libffi.a */; };
|
||||
5C35CF7327B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */; };
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
|
||||
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
|
||||
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
|
||||
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; };
|
||||
@ -116,6 +120,8 @@
|
||||
5C35CF6C27B031FB00FB6C6D /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C35CF6D27B031FB00FB6C6D /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C35CF6E27B031FB00FB6C6D /* libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.0-Cr0nZom8Et1JJ5D58xT8LZ-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = "<group>"; };
|
||||
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
|
||||
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
|
||||
5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; };
|
||||
@ -251,6 +257,8 @@
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */,
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */,
|
||||
5C9FD96A27A56D4D0075386C /* JSON.swift */,
|
||||
5C35CFC727B2782E00FB6C6D /* BGManager.swift */,
|
||||
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
@ -548,6 +556,7 @@
|
||||
5C764E80279C7276000C6508 /* dummy.m in Sources */,
|
||||
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
|
||||
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
|
||||
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
|
||||
5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */,
|
||||
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */,
|
||||
@ -556,6 +565,7 @@
|
||||
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
|
||||
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */,
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */,
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */,
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
@ -585,6 +595,7 @@
|
||||
5C764E81279C7276000C6508 /* dummy.m in Sources */,
|
||||
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
|
||||
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
|
||||
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
|
||||
5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */,
|
||||
5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */,
|
||||
@ -593,6 +604,7 @@
|
||||
5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
|
||||
5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */,
|
||||
5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */,
|
||||
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */,
|
||||
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
|
Loading…
Reference in New Issue
Block a user