wip
This commit is contained in:
parent
c69e7e50cf
commit
6e8af91439
|
@ -48,6 +48,11 @@ extension Message {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skip for non-visible messages
|
||||||
|
if martinMessage.body == nil, martinMessage.oob == nil, martinMessage.type == .chat {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// From/To
|
// From/To
|
||||||
let from = martinMessage.from?.bareJid.stringValue ?? ""
|
let from = martinMessage.from?.bareJid.stringValue ?? ""
|
||||||
let to = martinMessage.to?.bareJid.stringValue
|
let to = martinMessage.to?.bareJid.stringValue
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum AccountsAction: Codable {
|
|
||||||
case accountsListUpdated(accounts: [Account])
|
|
||||||
|
|
||||||
case goTo(AccountNavigationState)
|
|
||||||
|
|
||||||
case tryAddAccountWithCredentials(login: String, password: String)
|
|
||||||
case addAccountError(jid: String, reason: String?)
|
|
||||||
|
|
||||||
case makeAccountPermanent(account: Account)
|
|
||||||
|
|
||||||
case clientServerFeaturesUpdated(jid: String, features: [ServerFeature])
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
enum AppAction: Codable {
|
|
||||||
case info(String)
|
|
||||||
case flushState
|
|
||||||
case changeFlow(_ flow: AppFlow)
|
|
||||||
|
|
||||||
case startAction(_ action: StartAction)
|
|
||||||
case databaseAction(_ action: DatabaseAction)
|
|
||||||
case accountsAction(_ action: AccountsAction)
|
|
||||||
case xmppAction(_ action: XMPPAction)
|
|
||||||
case rostersAction(_ action: RostersAction)
|
|
||||||
case chatsAction(_ action: ChatsAction)
|
|
||||||
case conversationAction(_ action: ConversationAction)
|
|
||||||
case sharingAction(_ action: SharingAction)
|
|
||||||
case fileAction(_ action: FileAction)
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
enum ChatsAction: Codable {
|
|
||||||
case chatsListUpdated(chats: [Chat])
|
|
||||||
|
|
||||||
case startChat(accountJid: String, participantJid: String)
|
|
||||||
case chatStarted(chat: Chat)
|
|
||||||
|
|
||||||
case createNewChat(accountJid: String, participantJid: String)
|
|
||||||
case chatCreated(chat: Chat)
|
|
||||||
case chatCreationFailed(reason: String)
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
enum ConversationAction: Codable {
|
|
||||||
case makeConversationActive(chat: Chat, roster: Roster?)
|
|
||||||
|
|
||||||
case messagesUpdated(messages: [Message])
|
|
||||||
|
|
||||||
case sendMessage(from: String, to: String, body: String)
|
|
||||||
case setReplyText(String)
|
|
||||||
|
|
||||||
case sendMediaMessages(from: String, to: String, messagesIds: [String], localFilesNames: [String])
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum DatabaseAction: Codable {
|
|
||||||
case storedAccountsLoaded(accounts: [Account])
|
|
||||||
case loadingStoredAccountsFailed
|
|
||||||
case updateAccountFailed
|
|
||||||
|
|
||||||
case storedRostersLoaded(rosters: [Roster])
|
|
||||||
case storedChatsLoaded(chats: [Chat])
|
|
||||||
|
|
||||||
case storeMessageFailed(reason: String)
|
|
||||||
|
|
||||||
case updateAttachmentFailed(id: String, reason: String)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum FileAction: Stateable {
|
|
||||||
case downloadAttachmentFile(messageId: String, attachmentRemotePath: URL)
|
|
||||||
case attachmentFileDownloaded(messageId: String, localName: String)
|
|
||||||
case downloadingAttachmentFileFailed(messageId: String, reason: String)
|
|
||||||
|
|
||||||
case createAttachmentThumbnail(messageId: String, localName: String)
|
|
||||||
case attachmentThumbnailCreated(messageId: String, thumbnailName: String)
|
|
||||||
|
|
||||||
case fetchItemsFromGallery
|
|
||||||
case itemsFromGalleryFetched(items: [SharingGalleryItem])
|
|
||||||
|
|
||||||
case copyGalleryItemsForUploading(items: [SharingGalleryItem])
|
|
||||||
case copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType)
|
|
||||||
case itemsCopiedForUploading(newMessageIds: [String], localNames: [String])
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum RostersAction: Codable {
|
|
||||||
case addRoster(ownerJID: String, contactJID: String, name: String?, groups: [String])
|
|
||||||
case addRosterDone(jid: String)
|
|
||||||
case addRosterError(reason: String)
|
|
||||||
|
|
||||||
case rostersListUpdated([Roster])
|
|
||||||
|
|
||||||
case markRosterAsLocallyDeleted(ownerJID: String, contactJID: String)
|
|
||||||
case unmarkRosterAsLocallyDeleted(ownerJID: String, contactJID: String)
|
|
||||||
case deleteRoster(ownerJID: String, contactJID: String)
|
|
||||||
case rosterDeletingFailed(reason: String)
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SharingAction: Stateable {
|
|
||||||
case showSharing(Bool)
|
|
||||||
|
|
||||||
case shareLocation(lat: Double, lon: Double)
|
|
||||||
case shareContact(jid: String)
|
|
||||||
case shareDocuments([Data], [String])
|
|
||||||
case shareMedia(ids: [String])
|
|
||||||
|
|
||||||
case checkCameraAccess
|
|
||||||
case setCameraAccess(Bool)
|
|
||||||
|
|
||||||
case checkGalleryAccess
|
|
||||||
case setGalleryAccess(Bool)
|
|
||||||
case galleryItemsUpdated(items: [SharingGalleryItem])
|
|
||||||
|
|
||||||
case cameraCaptured(media: Data, type: SharingCameraMediaType)
|
|
||||||
|
|
||||||
case retrySharing(messageId: String)
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
enum StartAction: Codable {
|
|
||||||
case loadStoredAccounts
|
|
||||||
|
|
||||||
case goTo(StartNavigationState)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum XMPPAction: Codable {
|
|
||||||
case clientConnectionChanged(jid: String, state: ConnectionStatus)
|
|
||||||
case xmppMessageReceived(Message)
|
|
||||||
|
|
||||||
case xmppMessageSent(Message)
|
|
||||||
case xmppMessageSendFailed(msgId: String)
|
|
||||||
case xmppMessageSendSuccess(msgId: String)
|
|
||||||
|
|
||||||
case xmppSharingTryUpload(Message)
|
|
||||||
case xmppSharingUploadFailed(msgId: String, reason: String)
|
|
||||||
case xmppSharingUploadSuccess(msgId: String, attachmentRemotePath: String)
|
|
||||||
case serverFeaturesLoaded(jid: String, features: [String])
|
|
||||||
|
|
||||||
case xmppLoadArchivedMessages(jid: String, to: String?, fromDate: Date)
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
// This file declare global state object for whole app
|
|
||||||
// and reducers/actions/middleware types. Core of app.
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
typealias Stateable = Codable & Equatable
|
|
||||||
typealias AppStore = Store<AppState, AppAction>
|
|
||||||
typealias Reducer<State: Stateable, Action: Codable> = (inout State, Action) -> Void
|
|
||||||
typealias Middleware<State: Stateable, Action: Codable> = (State, Action) -> AnyPublisher<Action, Never>?
|
|
||||||
|
|
||||||
final class Store<State: Stateable, Action: Codable>: ObservableObject {
|
|
||||||
@Published private(set) var state: State
|
|
||||||
|
|
||||||
// Serial queue for performing any actions sequentially
|
|
||||||
private let serialQueue = DispatchQueue(label: "im.narayana.conversations.classic.serial.queue", qos: .userInteractive)
|
|
||||||
private let middlewareQueue = DispatchQueue(label: "im.narayana.conversations.classic.middleware.queue", qos: .default, attributes: .concurrent)
|
|
||||||
|
|
||||||
private let reducer: Reducer<State, Action>
|
|
||||||
private let middlewares: [Middleware<State, Action>]
|
|
||||||
private var middlewareCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
// Init
|
|
||||||
init(
|
|
||||||
initialState: State,
|
|
||||||
reducer: @escaping Reducer<State, Action>,
|
|
||||||
middlewares: [Middleware<State, Action>] = []
|
|
||||||
) {
|
|
||||||
state = initialState
|
|
||||||
self.reducer = reducer
|
|
||||||
self.middlewares = middlewares
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run reducers/middlewares
|
|
||||||
func dispatch(_ action: Action) {
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
print("❌WARNING!: AppStore.dispatch should be called from the main thread")
|
|
||||||
}
|
|
||||||
serialQueue.sync { [weak self] in
|
|
||||||
guard let wSelf = self else { return }
|
|
||||||
let newState = wSelf.dispatch(wSelf.state, action)
|
|
||||||
wSelf.state = newState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dispatch(_ currentState: State, _ action: Action) -> State {
|
|
||||||
// Do reducing
|
|
||||||
var startTime = CFAbsoluteTimeGetCurrent()
|
|
||||||
var newState = currentState
|
|
||||||
reducer(&newState, action)
|
|
||||||
var timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
|
||||||
if timeElapsed > 0.05 {
|
|
||||||
#if DEBUG
|
|
||||||
print(
|
|
||||||
"""
|
|
||||||
--
|
|
||||||
(Ignore this warning ONLY in case, when execution is paused by your breakpoint)
|
|
||||||
🕐Execution time: \(timeElapsed)
|
|
||||||
❌WARNING! Some reducers work too long! It will lead to issues in production build!
|
|
||||||
Because of execution each action is synchronous the any stuck will reduce performance dramatically.
|
|
||||||
Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example)
|
|
||||||
--
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch all middleware functions
|
|
||||||
for middleware in middlewares {
|
|
||||||
guard let middleware = middleware(newState, action) else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
startTime = CFAbsoluteTimeGetCurrent()
|
|
||||||
middleware
|
|
||||||
.subscribe(on: middlewareQueue)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink(receiveValue: { [weak self] action in
|
|
||||||
self?.dispatch(action)
|
|
||||||
})
|
|
||||||
.store(in: &middlewareCancellables)
|
|
||||||
timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
|
||||||
if timeElapsed > 0.05 {
|
|
||||||
#if DEBUG
|
|
||||||
print(
|
|
||||||
"""
|
|
||||||
--
|
|
||||||
(Ignore this warning ONLY in case, when execution is paused by your breakpoint)
|
|
||||||
🕐Execution time: \(timeElapsed)
|
|
||||||
❌WARNING! Middleware work too long! It will lead to issues in production build!
|
|
||||||
Because of execution each action is synchronous the any stuck will reduce performance dramatically.
|
|
||||||
Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example)
|
|
||||||
--
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: MartinsManager {}
|
|
||||||
|
|
||||||
// Check specific implementation in Database+Martin* files
|
|
|
@ -1,21 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.ChannelManager {
|
|
||||||
func channels(for _: Martin.Context) -> [any Martin.ChannelProtocol] {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
func createChannel(for _: Martin.Context, with _: Martin.BareJID, participantId _: String, nick _: String?, state _: Martin.ChannelState) -> Martin.ConversationCreateResult<any Martin.ChannelProtocol> {
|
|
||||||
.none
|
|
||||||
}
|
|
||||||
|
|
||||||
func channel(for _: Martin.Context, with _: Martin.BareJID) -> (any Martin.ChannelProtocol)? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func close(channel _: any Martin.ChannelProtocol) -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.ChatManager {
|
|
||||||
func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] {
|
|
||||||
do {
|
|
||||||
let chats: [Chat] = try _db.read { db in
|
|
||||||
try Chat.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
|
|
||||||
}
|
|
||||||
return chats.map { chat in
|
|
||||||
Martin.ChatBase(context: context, jid: BareJID(chat.participant))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching chats: \(error.localizedDescription)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
|
||||||
do {
|
|
||||||
let chat: Chat? = try _db.read { db in
|
|
||||||
try Chat
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("participant") == with.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if chat != nil {
|
|
||||||
return Martin.ChatBase(context: context, jid: with)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching chat: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createChat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
|
||||||
do {
|
|
||||||
let chat: Chat? = try _db.read { db in
|
|
||||||
try Chat
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("participant") == with.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if chat != nil {
|
|
||||||
return Martin.ChatBase(context: context, jid: with)
|
|
||||||
} else {
|
|
||||||
let chat = Chat(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
account: context.userBareJid.stringValue,
|
|
||||||
participant: with.stringValue,
|
|
||||||
type: .chat
|
|
||||||
)
|
|
||||||
try _db.write { db in
|
|
||||||
try chat.save(db)
|
|
||||||
}
|
|
||||||
return Martin.ChatBase(context: context, jid: with)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching chat: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func close(chat: any Martin.ChatProtocol) -> Bool {
|
|
||||||
// not used in Martin library for now
|
|
||||||
print("Closing chat: \(chat)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.RoomManager {
|
|
||||||
func rooms(for context: Martin.Context) -> [any Martin.RoomProtocol] {
|
|
||||||
do {
|
|
||||||
let rooms: [Room] = try _db.read { db in
|
|
||||||
try Room.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
|
|
||||||
}
|
|
||||||
return rooms.map { room in
|
|
||||||
Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching channels: \(error.localizedDescription)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func room(for context: Martin.Context, with roomJid: Martin.BareJID) -> (any Martin.RoomProtocol)? {
|
|
||||||
do {
|
|
||||||
let room: Room? = try _db.read { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("id") == roomJid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let room {
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching room: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRoom(for context: Martin.Context, with roomJid: Martin.BareJID, nickname: String, password: String?) -> (any Martin.RoomProtocol)? {
|
|
||||||
do {
|
|
||||||
let room: Room? = try _db.read { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("id") == roomJid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let room {
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let room = Room(
|
|
||||||
id: roomJid.stringValue,
|
|
||||||
account: context.userBareJid.stringValue,
|
|
||||||
nickname: nickname,
|
|
||||||
password: password
|
|
||||||
)
|
|
||||||
try _db.write { db in
|
|
||||||
try room.save(db)
|
|
||||||
}
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: nickname,
|
|
||||||
password: password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching room: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func close(room: any Martin.RoomProtocol) -> Bool {
|
|
||||||
do {
|
|
||||||
try _db.write { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == room.context?.userBareJid.stringValue ?? "")
|
|
||||||
.filter(Column("id") == room.jid.stringValue)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error closing room: \(error.localizedDescription)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.RosterManager {
|
|
||||||
func clear(for context: Martin.Context) {
|
|
||||||
print("Clearing roster for context: \(context)")
|
|
||||||
do {
|
|
||||||
try _db.write { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
|
||||||
.deleteAll(db)
|
|
||||||
|
|
||||||
try RosterVersion
|
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error clearing roster: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
|
|
||||||
do {
|
|
||||||
let rosters: [Roster] = try _db.read { db in
|
|
||||||
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
|
|
||||||
}
|
|
||||||
return rosters.map { roster in
|
|
||||||
RosterItemBase(
|
|
||||||
jid: JID(roster.bareJid),
|
|
||||||
name: roster.name,
|
|
||||||
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
|
||||||
groups: roster.data.groups,
|
|
||||||
ask: roster.ask,
|
|
||||||
annotations: roster.data.annotations
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching roster items: \(error.localizedDescription)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
|
||||||
do {
|
|
||||||
let roster: Roster? = try _db.read { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("contactBareJid") == jid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let roster {
|
|
||||||
return RosterItemBase(
|
|
||||||
jid: JID(roster.bareJid),
|
|
||||||
name: roster.name,
|
|
||||||
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
|
||||||
groups: roster.data.groups,
|
|
||||||
ask: roster.ask,
|
|
||||||
annotations: roster.data.annotations
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching roster item: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateItem(for context: Martin.Context, jid: Martin.JID, name: String?, subscription: Martin.RosterItemSubscription, groups: [String], ask: Bool, annotations: [Martin.RosterItemAnnotation]) -> (any Martin.RosterItemProtocol)? {
|
|
||||||
do {
|
|
||||||
let roster = Roster(
|
|
||||||
bareJid: context.userBareJid.stringValue,
|
|
||||||
contactBareJid: jid.stringValue,
|
|
||||||
name: name,
|
|
||||||
subscription: subscription.rawValue,
|
|
||||||
ask: ask,
|
|
||||||
data: DBRosterData(
|
|
||||||
groups: groups,
|
|
||||||
annotations: annotations
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try _db.write { db in
|
|
||||||
try roster.save(db)
|
|
||||||
}
|
|
||||||
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error updating roster item: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
|
||||||
do {
|
|
||||||
let roster: Roster? = try _db.read { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("contactBareJid") == jid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let roster {
|
|
||||||
_ = try _db.write { db in
|
|
||||||
try roster.delete(db)
|
|
||||||
}
|
|
||||||
return RosterItemBase(
|
|
||||||
jid: JID(roster.bareJid),
|
|
||||||
name: roster.name,
|
|
||||||
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
|
|
||||||
groups: roster.data.groups,
|
|
||||||
ask: roster.ask,
|
|
||||||
annotations: roster.data.annotations
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func version(for context: Martin.Context) -> String? {
|
|
||||||
do {
|
|
||||||
let version: RosterVersion? = try _db.read { db in
|
|
||||||
try RosterVersion
|
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
return version?.version
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func set(version: String?, for context: Martin.Context) {
|
|
||||||
guard let version else { return }
|
|
||||||
do {
|
|
||||||
try _db.write { db in
|
|
||||||
let rosterVersion = RosterVersion(
|
|
||||||
bareJid: context.userBareJid.stringValue,
|
|
||||||
version: version
|
|
||||||
)
|
|
||||||
try rosterVersion.save(db)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error setting roster version: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initialize(context _: Martin.Context) {}
|
|
||||||
func deinitialize(context _: Martin.Context) {}
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
|
|
||||||
extension Database {
|
|
||||||
static var migrator: DatabaseMigrator = {
|
|
||||||
var migrator = DatabaseMigrator()
|
|
||||||
|
|
||||||
// flush db on schema change (only in DEV mode)
|
|
||||||
#if DEBUG
|
|
||||||
migrator.eraseDatabaseOnSchemaChange = true
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// 1st migration - basic tables
|
|
||||||
migrator.registerMigration("Add basic tables") { db in
|
|
||||||
// accounts
|
|
||||||
try db.create(table: "accounts", options: [.ifNotExists]) { table in
|
|
||||||
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("pass", .text).notNull()
|
|
||||||
table.column("isActive", .boolean).notNull().defaults(to: true)
|
|
||||||
table.column("isTemp", .boolean).notNull().defaults(to: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rosters
|
|
||||||
try db.create(table: "rosterVersions", options: [.ifNotExists]) { table in
|
|
||||||
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("version", .text).notNull()
|
|
||||||
}
|
|
||||||
try db.create(table: "rosters", options: [.ifNotExists]) { table in
|
|
||||||
table.column("bareJid", .text).notNull()
|
|
||||||
table.column("contactBareJid", .text).notNull()
|
|
||||||
table.column("name", .text)
|
|
||||||
table.column("subscription", .text).notNull()
|
|
||||||
table.column("ask", .boolean).notNull().defaults(to: false)
|
|
||||||
table.column("data", .text).notNull()
|
|
||||||
table.primaryKey(["bareJid", "contactBareJid"], onConflict: .replace)
|
|
||||||
table.column("locallyDeleted", .boolean).notNull().defaults(to: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// chats
|
|
||||||
try db.create(table: "chats", options: [.ifNotExists]) { table in
|
|
||||||
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("account", .text).notNull()
|
|
||||||
table.column("participant", .text).notNull()
|
|
||||||
table.column("type", .integer).notNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
// messages
|
|
||||||
try db.create(table: "messages", options: [.ifNotExists]) { table in
|
|
||||||
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("type", .text).notNull()
|
|
||||||
table.column("contentType", .text).notNull()
|
|
||||||
table.column("from", .text).notNull()
|
|
||||||
table.column("to", .text)
|
|
||||||
table.column("body", .text)
|
|
||||||
table.column("subject", .text)
|
|
||||||
table.column("thread", .text)
|
|
||||||
table.column("oobUrl", .text)
|
|
||||||
table.column("date", .datetime).notNull()
|
|
||||||
table.column("pending", .boolean).notNull()
|
|
||||||
table.column("sentError", .boolean).notNull()
|
|
||||||
table.column("attachmentType", .integer)
|
|
||||||
table.column("attachmentLocalName", .text)
|
|
||||||
table.column("attachmentRemotePath", .text)
|
|
||||||
table.column("attachmentThumbnailName", .text)
|
|
||||||
table.column("attachmentDownloadFailed", .boolean).notNull().defaults(to: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2nd migration - channels/rooms
|
|
||||||
migrator.registerMigration("Add channels/rooms") { db in
|
|
||||||
// rooms
|
|
||||||
try db.create(table: "rooms", options: [.ifNotExists]) { table in
|
|
||||||
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("account", .text).notNull()
|
|
||||||
table.column("nickname", .text).notNull()
|
|
||||||
table.column("password", .text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// channels
|
|
||||||
// try db.create(table: "channels", options: [.ifNotExists]) { table in
|
|
||||||
// table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
// table.column("account", .text).notNull()
|
|
||||||
// table.column("channel", .text).notNull()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// return migrator
|
|
||||||
return migrator
|
|
||||||
}()
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Models protocol
|
|
||||||
typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRecord & TableRecord
|
|
||||||
|
|
||||||
// MARK: - Database init
|
|
||||||
final class Database {
|
|
||||||
static let shared = Database()
|
|
||||||
let _db: DatabaseQueue
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
do {
|
|
||||||
// Create db folder if not exists
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let appSupportURL = try fileManager.url(
|
|
||||||
for: .applicationSupportDirectory, in: .userDomainMask,
|
|
||||||
appropriateFor: nil, create: true
|
|
||||||
)
|
|
||||||
let directoryURL = appSupportURL.appendingPathComponent("ConversationsClassic", isDirectory: true)
|
|
||||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
// Open or create the database
|
|
||||||
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
|
||||||
_db = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
|
|
||||||
|
|
||||||
// Some debug info
|
|
||||||
#if DEBUG
|
|
||||||
print("Database path: \(databaseURL.path)")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Apply migrations
|
|
||||||
try Database.migrator.migrate(_db)
|
|
||||||
} catch {
|
|
||||||
fatalError("Database initialization failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Config
|
|
||||||
private extension Database {
|
|
||||||
static let config: Configuration = {
|
|
||||||
var config = Configuration()
|
|
||||||
#if DEBUG
|
|
||||||
// verbose and debugging in DEBUG builds only.
|
|
||||||
config.publicStatementArguments = true
|
|
||||||
config.prepareDatabase { db in
|
|
||||||
db.trace { print("SQL> \($0)\n") }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return config
|
|
||||||
}()
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class DownloadManager {
|
|
||||||
static let shared = DownloadManager()
|
|
||||||
|
|
||||||
private let urlSession: URLSession
|
|
||||||
private let downloadQueue = DispatchQueue(label: "com.example.downloadQueue")
|
|
||||||
private var activeDownloads = Set<URL>()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
urlSession = URLSession(configuration: configuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueueDownload(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) {
|
|
||||||
downloadQueue.async {
|
|
||||||
if self.activeDownloads.contains(url) {
|
|
||||||
print("Download for this file is already in queue.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.activeDownloads.insert(url)
|
|
||||||
|
|
||||||
let task = self.urlSession.downloadTask(with: url) { tempLocalUrl, _, error in
|
|
||||||
self.downloadQueue.async {
|
|
||||||
self.activeDownloads.remove(url)
|
|
||||||
|
|
||||||
guard let tempLocalUrl = tempLocalUrl, error == nil else {
|
|
||||||
completion(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
if FileManager.default.fileExists(atPath: localUrl.path) {
|
|
||||||
try FileManager.default.removeItem(at: localUrl)
|
|
||||||
}
|
|
||||||
let data = try Data(contentsOf: tempLocalUrl)
|
|
||||||
try data.write(to: localUrl)
|
|
||||||
completion(nil)
|
|
||||||
} catch let writeError {
|
|
||||||
completion(writeError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,302 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Photos
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class FileProcessing {
|
|
||||||
static let shared = FileProcessing()
|
|
||||||
|
|
||||||
static var fileFolder: URL {
|
|
||||||
// swiftlint:disable:next force_unwrapping
|
|
||||||
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
||||||
let subdirectoryURL = documentsURL.appendingPathComponent(Const.fileFolder)
|
|
||||||
if !FileManager.default.fileExists(atPath: subdirectoryURL.path) {
|
|
||||||
try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
}
|
|
||||||
return subdirectoryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func createThumbnail(localName: String) -> String? {
|
|
||||||
let thumbnailFileName = "thumb_\(localName)"
|
|
||||||
let thumbnailUrl = FileProcessing.fileFolder.appendingPathComponent(thumbnailFileName)
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
|
|
||||||
// check if thumbnail already exists
|
|
||||||
if FileManager.default.fileExists(atPath: thumbnailUrl.path) {
|
|
||||||
return thumbnailFileName
|
|
||||||
}
|
|
||||||
|
|
||||||
// create thumbnail if not exists
|
|
||||||
switch localName.attachmentType {
|
|
||||||
case .image:
|
|
||||||
guard let image = UIImage(contentsOfFile: localUrl.path) else {
|
|
||||||
print("FileProcessing: Error loading image: \(localUrl)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize)
|
|
||||||
guard let thumbnail = scaleAndCropImage(image, targetSize) else {
|
|
||||||
print("FileProcessing: Error scaling image: \(localUrl)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let data = thumbnail.pngData() else {
|
|
||||||
print("FileProcessing: Error converting thumbnail of \(localUrl) to data")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try data.write(to: thumbnailUrl)
|
|
||||||
return thumbnailFileName
|
|
||||||
} catch {
|
|
||||||
print("FileProcessing: Error writing thumbnail: \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchGallery() -> [SharingGalleryItem] {
|
|
||||||
let items = syncGalleryEnumerate()
|
|
||||||
.map {
|
|
||||||
SharingGalleryItem(
|
|
||||||
id: $0.localIdentifier,
|
|
||||||
type: $0.mediaType == .image ? .photo : .video,
|
|
||||||
duration: $0.mediaType == .video ? $0.duration.minAndSec : nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func fillGalleryItemsThumbnails(items: [SharingGalleryItem]) -> [SharingGalleryItem] {
|
|
||||||
let ids = items
|
|
||||||
.filter { $0.thumbnail == nil }
|
|
||||||
.map { $0.id }
|
|
||||||
|
|
||||||
let assets = syncGalleryEnumerate(ids)
|
|
||||||
return assets.compactMap { asset in
|
|
||||||
if asset.mediaType == .image {
|
|
||||||
return syncGalleryProcessImage(asset) { [weak self] image in
|
|
||||||
if let thumbnail = self?.scaleAndCropImage(image, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) {
|
|
||||||
let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data()
|
|
||||||
return SharingGalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: data)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if asset.mediaType == .video {
|
|
||||||
return syncGalleryProcessVideo(asset) { [weak self] avAsset in
|
|
||||||
// swiftlint:disable:next force_cast
|
|
||||||
let assetURL = avAsset as! AVURLAsset
|
|
||||||
let url = assetURL.url
|
|
||||||
if let thumbnail = self?.generateVideoThumbnail(url, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) {
|
|
||||||
let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data()
|
|
||||||
return SharingGalleryItem(
|
|
||||||
id: asset.localIdentifier,
|
|
||||||
type: .video,
|
|
||||||
thumbnail: data,
|
|
||||||
duration: asset.duration.minAndSec
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function also creates new ids for messages for each new attachment
|
|
||||||
func copyGalleryItemsForUploading(items: [SharingGalleryItem]) -> [(String, String)] {
|
|
||||||
let assets = syncGalleryEnumerate(items.map { $0.id })
|
|
||||||
return assets
|
|
||||||
.compactMap { asset in
|
|
||||||
let newMessageId = UUID().uuidString
|
|
||||||
let fileId = UUID().uuidString
|
|
||||||
if asset.mediaType == .image {
|
|
||||||
return syncGalleryProcessImage(asset) { image in
|
|
||||||
let localName = "\(newMessageId)_\(fileId).jpg"
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
if let data = image.jpegData(compressionQuality: 1.0) {
|
|
||||||
do {
|
|
||||||
try data.write(to: localUrl)
|
|
||||||
return (newMessageId, localName)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if asset.mediaType == .video {
|
|
||||||
return syncGalleryProcessVideo(asset) { avAsset in
|
|
||||||
// swiftlint:disable:next force_cast
|
|
||||||
let assetURL = avAsset as! AVURLAsset
|
|
||||||
let url = assetURL.url
|
|
||||||
let localName = "\(newMessageId)_\(fileId).mov"
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
do {
|
|
||||||
try FileManager.default.copyItem(at: url, to: localUrl)
|
|
||||||
return (newMessageId, localName)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function also creates new id for file from camera capturing
|
|
||||||
func copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType) -> (String, String)? {
|
|
||||||
let newMessageId = UUID().uuidString
|
|
||||||
let fileId = UUID().uuidString
|
|
||||||
let localName: String
|
|
||||||
switch type {
|
|
||||||
case .photo:
|
|
||||||
localName = "\(newMessageId)_\(fileId).jpg"
|
|
||||||
|
|
||||||
case .video:
|
|
||||||
localName = "\(newMessageId)_\(fileId).mov"
|
|
||||||
}
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
do {
|
|
||||||
try media.write(to: localUrl)
|
|
||||||
return (newMessageId, localName)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function also creates new id for file from document sharing
|
|
||||||
func copyDocumentsForUploading(data: [Data], extensions: [String]) -> [(String, String)] {
|
|
||||||
data.enumerated().compactMap { index, data in
|
|
||||||
let newMessageId = UUID().uuidString
|
|
||||||
let fileId = UUID().uuidString
|
|
||||||
let localName = "\(newMessageId)_\(fileId).\(extensions[index])"
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
do {
|
|
||||||
try data.write(to: localUrl)
|
|
||||||
return (newMessageId, localName)
|
|
||||||
} catch {
|
|
||||||
print("FileProcessing: Error writing document: \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension FileProcessing {
|
|
||||||
func scaleAndCropImage(_ img: UIImage, _ size: CGSize) -> UIImage? {
|
|
||||||
let aspect = img.size.width / img.size.height
|
|
||||||
let targetAspect = size.width / size.height
|
|
||||||
var newWidth: CGFloat
|
|
||||||
var newHeight: CGFloat
|
|
||||||
if aspect < targetAspect {
|
|
||||||
newWidth = size.width
|
|
||||||
newHeight = size.width / aspect
|
|
||||||
} else {
|
|
||||||
newHeight = size.height
|
|
||||||
newWidth = size.height * aspect
|
|
||||||
}
|
|
||||||
|
|
||||||
UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
|
|
||||||
img.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight))
|
|
||||||
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
||||||
UIGraphicsEndImageContext()
|
|
||||||
|
|
||||||
return newImage
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncGalleryEnumerate(_ ids: [String]? = nil) -> [PHAsset] {
|
|
||||||
var result: [PHAsset] = []
|
|
||||||
|
|
||||||
let group = DispatchGroup()
|
|
||||||
DispatchQueue.global(qos: .userInitiated).sync {
|
|
||||||
let fetchOptions = PHFetchOptions()
|
|
||||||
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
|
||||||
if let ids {
|
|
||||||
fetchOptions.predicate = NSPredicate(format: "localIdentifier IN %@", ids)
|
|
||||||
}
|
|
||||||
let assets = PHAsset.fetchAssets(with: fetchOptions)
|
|
||||||
assets.enumerateObjects { asset, _, _ in
|
|
||||||
group.enter()
|
|
||||||
result.append(asset)
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.wait()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncGalleryProcess<T>(_ assets: [PHAsset], _ block: @escaping (PHAsset) -> T) -> [T] {
|
|
||||||
var result: [T] = []
|
|
||||||
let group = DispatchGroup()
|
|
||||||
DispatchQueue.global(qos: .userInitiated).sync {
|
|
||||||
for asset in assets {
|
|
||||||
group.enter()
|
|
||||||
let res = block(asset)
|
|
||||||
result.append(res)
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.wait()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncGalleryProcessImage<T>(_ asset: PHAsset, _ block: @escaping (UIImage) -> T?) -> T? {
|
|
||||||
var result: T?
|
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
|
||||||
DispatchQueue.global(qos: .userInitiated).sync {
|
|
||||||
let options = PHImageRequestOptions()
|
|
||||||
options.version = .original
|
|
||||||
options.isSynchronous = true
|
|
||||||
PHImageManager.default().requestImage(
|
|
||||||
for: asset,
|
|
||||||
targetSize: PHImageManagerMaximumSize,
|
|
||||||
contentMode: .aspectFill,
|
|
||||||
options: options
|
|
||||||
) { image, _ in
|
|
||||||
if let image {
|
|
||||||
result = block(image)
|
|
||||||
} else {
|
|
||||||
result = nil
|
|
||||||
}
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
semaphore.wait()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncGalleryProcessVideo<T>(_ asset: PHAsset, _ block: @escaping (AVAsset) -> T?) -> T? {
|
|
||||||
var result: T?
|
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
|
||||||
_ = DispatchQueue.global(qos: .userInitiated).sync {
|
|
||||||
PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in
|
|
||||||
if let avAsset {
|
|
||||||
result = block(avAsset)
|
|
||||||
} else {
|
|
||||||
result = nil
|
|
||||||
}
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
semaphore.wait()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateVideoThumbnail(_ url: URL, _ size: CGSize) -> UIImage? {
|
|
||||||
let asset = AVAsset(url: url)
|
|
||||||
let assetImgGenerate = AVAssetImageGenerator(asset: asset)
|
|
||||||
assetImgGenerate.appliesPreferredTrackTransform = true
|
|
||||||
let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600)
|
|
||||||
do {
|
|
||||||
let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil)
|
|
||||||
let image = UIImage(cgImage: cgImage)
|
|
||||||
return scaleAndCropImage(image, size)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class AccountsMiddleware {
|
|
||||||
static let shared = AccountsMiddleware()
|
|
||||||
|
|
||||||
private lazy var allFeatures: [ServerFeature] = {
|
|
||||||
guard
|
|
||||||
let url = Bundle.main.url(forResource: "server_features", withExtension: "plist"),
|
|
||||||
let data = try? Data(contentsOf: url),
|
|
||||||
let loaded = try? PropertyListDecoder().decode([ServerFeature].self, from: data)
|
|
||||||
else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return loaded
|
|
||||||
}()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedAccountsLoaded(let accounts)):
|
|
||||||
return Just(.accountsAction(.accountsListUpdated(accounts: accounts)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.clientConnectionChanged(let jid, let connectionStatus)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
guard let account = state.accountsState.accounts.first(where: { $0.bareJid == jid }) else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account not found for jid \(jid)")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account.isTemp {
|
|
||||||
switch connectionStatus {
|
|
||||||
case .connected:
|
|
||||||
promise(.success(.accountsAction(.makeAccountPermanent(account: account))))
|
|
||||||
|
|
||||||
case .disconnected(let reason):
|
|
||||||
if reason != "No error!" {
|
|
||||||
promise(.success(.accountsAction(.addAccountError(jid: jid, reason: reason))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) disconnected with no error")))
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) connection status changed to \(connectionStatus)")))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) is not temporary, ignoring")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.serverFeaturesLoaded(let jid, let features)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
let serverFeatures = features
|
|
||||||
.compactMap { featureId in
|
|
||||||
self?.allFeatures.first(where: { $0.xmppId == featureId })
|
|
||||||
}
|
|
||||||
promise(.success(.accountsAction(.clientServerFeaturesUpdated(jid: jid, features: serverFeatures))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class ArchivedMessagesMiddleware {
|
|
||||||
static let shared = ArchivedMessagesMiddleware()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// case .conversationAction(.messagesUpdated(let messages)):
|
|
||||||
// if state.conversationsState.archivedMessagesRequested {
|
|
||||||
// return Empty().eraseToAnyPublisher()
|
|
||||||
// } else {
|
|
||||||
// guard let chat = state.conversationsState.currentChat else {
|
|
||||||
// return Empty().eraseToAnyPublisher()
|
|
||||||
// }
|
|
||||||
// return Deferred {
|
|
||||||
// Future<AppAction, Never> { promise in
|
|
||||||
// if let currentClient = state.accountsState.accounts.first(where: { $0.bareJid == chat.account }) {
|
|
||||||
// let features = state.accountsState.discoFeatures[currentClient.bareJid] ?? []
|
|
||||||
// if features.map({ $0.xep }).contains("XEP-0313") {
|
|
||||||
// let roster = state.conversationsState.currentRoster
|
|
||||||
// let date = self.archivesRequestDate(from: messages)
|
|
||||||
// promise(.success(.xmppAction(.xmppLoadArchivedMessages(jid: currentClient.bareJid, to: roster?.bareJid, fromDate: date))))
|
|
||||||
// } else {
|
|
||||||
// promise(.success(.info("MessageMiddleware: XEP-0313 not supported for client \(currentClient.bareJid)")))
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// promise(.success(.info("MessageMiddleware: No client found for account \(chat.account), probably some error here")))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// .eraseToAnyPublisher()
|
|
||||||
// }
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension ArchivedMessagesMiddleware {
|
|
||||||
func archivesRequestDate(from messages: [Message]) -> Date {
|
|
||||||
if let lastDate = messages.first?.date {
|
|
||||||
return lastDate
|
|
||||||
} else {
|
|
||||||
return Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: Date()) ?? Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class ChatsMiddleware {
|
|
||||||
static let shared = ChatsMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedChatsLoaded(let chats)):
|
|
||||||
return Just(.chatsAction(.chatsListUpdated(chats: chats)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .chatsAction(.startChat(accountJid: let accountJid, participantJid: let participantJid)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
if let exist = state.chatsState.chats.first(where: { $0.account == accountJid && $0.participant == participantJid }) {
|
|
||||||
// open existing chat
|
|
||||||
promise(.success(.chatsAction(.chatStarted(chat: exist))))
|
|
||||||
} else {
|
|
||||||
// create new chat
|
|
||||||
promise(.success(.chatsAction(.createNewChat(accountJid: accountJid, participantJid: participantJid))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .chatsAction(.chatCreated(let chat)):
|
|
||||||
return Just(.chatsAction(.chatStarted(chat: chat)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class ConversationMiddleware {
|
|
||||||
static let shared = ConversationMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .chatsAction(.chatStarted(let chat)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let roster = state.rostersState.rosters
|
|
||||||
.first { $0.bareJid == chat.account && $0.contactBareJid == chat.participant }
|
|
||||||
promise(.success(.conversationAction(.makeConversationActive(chat: chat, roster: roster))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .conversationAction(.makeConversationActive):
|
|
||||||
return Just(AppAction.changeFlow(.conversation)).eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,530 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
|
|
||||||
// swiftlint:disable:next type_body_length
|
|
||||||
final class DatabaseMiddleware {
|
|
||||||
static let shared = DatabaseMiddleware()
|
|
||||||
private let database = Database.shared
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
private var conversationCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
// Database changes
|
|
||||||
ValueObservation
|
|
||||||
.tracking(Roster.fetchAll)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
// Handle completion
|
|
||||||
} receiveValue: { rosters in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.databaseAction(.storedRostersLoaded(rosters: rosters)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
ValueObservation
|
|
||||||
.tracking(Chat.fetchAll)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
// Handle completion
|
|
||||||
} receiveValue: { chats in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.databaseAction(.storedChatsLoaded(chats: chats)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: Accounts
|
|
||||||
case .startAction(.loadStoredAccounts):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.loadingStoredAccountsFailed)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.read { db in
|
|
||||||
let accounts = try Account.fetchAll(db)
|
|
||||||
promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.loadingStoredAccountsFailed)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .accountsAction(.makeAccountPermanent(let account)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAccountFailed)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
// make permanent and store to database
|
|
||||||
var acc = account
|
|
||||||
acc.isTemp = false
|
|
||||||
try acc.insert(db)
|
|
||||||
|
|
||||||
// Re-Fetch all accounts
|
|
||||||
let accounts = try Account.fetchAll(db)
|
|
||||||
|
|
||||||
// Use the accounts
|
|
||||||
promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAccountFailed)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Rosters
|
|
||||||
case .rostersAction(.markRosterAsLocallyDeleted(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == ownerJID)
|
|
||||||
.filter(Column("contactBareJid") == contactJID)
|
|
||||||
.updateAll(db, Column("locallyDeleted").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) marked as locally deleted")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.unmarkRosterAsLocallyDeleted(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == ownerJID)
|
|
||||||
.filter(Column("contactBareJid") == contactJID)
|
|
||||||
.updateAll(db, Column("locallyDeleted").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) unmarked as locally deleted")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Chats
|
|
||||||
case .chatsAction(.createNewChat(let accountJid, let participantJid)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
let chat = Chat(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
account: accountJid,
|
|
||||||
participant: participantJid,
|
|
||||||
type: .chat
|
|
||||||
)
|
|
||||||
try chat.insert(db)
|
|
||||||
promise(.success(.chatsAction(.chatCreated(chat: chat))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Conversation and messages
|
|
||||||
case .conversationAction(.makeConversationActive(let chat, _)):
|
|
||||||
subscribeToMessages(chat: chat)
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageReceived(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard message.contentType != .typing, message.body != nil else {
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(message.id) received as 'typing...' or message body is nil")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(message.id) stored in db")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .conversationAction(.sendMessage(let from, let to, let body)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let message = Message(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
type: .chat,
|
|
||||||
contentType: .text,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: body,
|
|
||||||
subject: nil,
|
|
||||||
thread: nil,
|
|
||||||
oobUrl: nil,
|
|
||||||
date: Date(),
|
|
||||||
pending: true,
|
|
||||||
sentError: false
|
|
||||||
)
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSent(message))))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSendSuccess(let msgId)):
|
|
||||||
// mark message as pending false and sentError false
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == msgId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as sent")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSendFailed(let msgId)):
|
|
||||||
// mark message as pending false and sentError true
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == msgId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as failed to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Attachments
|
|
||||||
case .fileAction(.downloadAttachmentFile(let id, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentDownloadFailed").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as starting downloading attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.downloadingAttachmentFileFailed(let id, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentDownloadFailed").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as failed to download attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentFileDownloaded(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentLocalName").set(to: localName), Column("attachmentDownloadFailed").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as downloaded attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentThumbnailCreated(let id, let thumbnailName)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentThumbnailName").set(to: thumbnailName))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as thumbnail created")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Sharing
|
|
||||||
case .conversationAction(.sendMediaMessages(let from, let to, let messageIds, let localFilesNames)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
for (index, id) in messageIds.enumerated() {
|
|
||||||
let message = Message(
|
|
||||||
id: id,
|
|
||||||
type: .chat,
|
|
||||||
contentType: .attachment,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: nil,
|
|
||||||
subject: nil,
|
|
||||||
thread: nil,
|
|
||||||
oobUrl: nil,
|
|
||||||
date: Date(),
|
|
||||||
pending: true,
|
|
||||||
sentError: false,
|
|
||||||
attachmentType: localFilesNames[index].attachmentType,
|
|
||||||
attachmentLocalName: localFilesNames[index]
|
|
||||||
)
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: messages with sharings stored in db")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.retrySharing(let id)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("pending").set(to: true), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) with shares marked in db as pending to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingUploadSuccess(let messageId, let remotePath)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == messageId)
|
|
||||||
.updateAll(db, Column("attachmentRemotePath").set(to: remotePath), Column("pending").set(to: false), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: shared file uploaded and message \(messageId) marked in db as sent")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingUploadFailed(let messageId, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == messageId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: shared file upload failed and message \(messageId) marked in db as failed to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension DatabaseMiddleware {
|
|
||||||
func subscribeToMessages(chat: Chat) {
|
|
||||||
conversationCancellables = []
|
|
||||||
ValueObservation
|
|
||||||
.tracking(
|
|
||||||
Message
|
|
||||||
.filter(
|
|
||||||
(Column("to") == chat.account && Column("from") == chat.participant) ||
|
|
||||||
(Column("from") == chat.account && Column("to") == chat.participant)
|
|
||||||
)
|
|
||||||
.order(Column("date").desc)
|
|
||||||
.fetchAll
|
|
||||||
)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
} receiveValue: { messages in
|
|
||||||
// messages
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.conversationAction(.messagesUpdated(messages: messages)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &conversationCancellables)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class FileMiddleware {
|
|
||||||
static let shared = FileMiddleware()
|
|
||||||
private var downloadingMessageIDs = ThreadSafeSet<String>()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: - For incomig attachments
|
|
||||||
case .conversationAction(.messagesUpdated(let messages)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
guard let wSelf = self else {
|
|
||||||
promise(.success(.info("FileMiddleware: on checking attachments/shares messages, middleware self is nil")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// for incoming messages with attachments
|
|
||||||
for message in messages where message.attachmentRemotePath != nil && message.attachmentLocalPath == nil {
|
|
||||||
if wSelf.downloadingMessageIDs.contains(message.id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wSelf.downloadingMessageIDs.insert(message.id)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// swiftlint:disable:next force_unwrapping
|
|
||||||
store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: message.attachmentRemotePath!)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for outgoing messages with shared attachments
|
|
||||||
for message in messages where message.attachmentLocalPath != nil && message.attachmentRemotePath == nil && message.pending {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.xmppSharingTryUpload(message)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for outgoing messages with shared attachments which are already uploaded
|
|
||||||
// but have no thumbnail (only for images)
|
|
||||||
for message in messages where !message.pending && !message.sentError && message.attachmentType == .image {
|
|
||||||
if message.attachmentLocalName != nil && message.attachmentRemotePath != nil && message.attachmentThumbnailName == nil {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// swiftlint:disable:next force_unwrapping
|
|
||||||
store.dispatch(.fileAction(.createAttachmentThumbnail(messageId: message.id, localName: message.attachmentLocalName!)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
promise(.success(.info("FileMiddleware: attachments/shares messages processed")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.downloadAttachmentFile(let id, let attachmentRemotePath)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let localName = "\(id)_\(UUID().uuidString)\(attachmentRemotePath.lastPathComponent)"
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
DownloadManager.shared.enqueueDownload(from: attachmentRemotePath, to: localUrl) { error in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let error {
|
|
||||||
store.dispatch(.fileAction(.downloadingAttachmentFileFailed(messageId: id, reason: error.localizedDescription)))
|
|
||||||
} else {
|
|
||||||
store.dispatch(.fileAction(.attachmentFileDownloaded(messageId: id, localName: localName)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
promise(.success(.info("FileMiddleware: started downloading attachment for message \(id)")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentFileDownloaded(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.fileAction(.createAttachmentThumbnail(messageId: id, localName: localName))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.createAttachmentThumbnail(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
if let thumbnailName = FileProcessing.shared.createThumbnail(localName: localName) {
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.fileAction(.attachmentThumbnailCreated(messageId: id, thumbnailName: thumbnailName))))
|
|
||||||
} else {
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.info("FileMiddleware: failed to create thumbnail from \(localName) for message \(id)")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: - For outgoing sharing
|
|
||||||
case .fileAction(.fetchItemsFromGallery):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let items = FileProcessing.shared.fetchGallery()
|
|
||||||
promise(.success(.fileAction(.itemsFromGalleryFetched(items: items))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsFromGalleryFetched(let items)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let newItems = FileProcessing.shared.fillGalleryItemsThumbnails(items: items)
|
|
||||||
promise(.success(.sharingAction(.galleryItemsUpdated(items: newItems))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.copyGalleryItemsForUploading(let items)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let ids = FileProcessing.shared.copyGalleryItemsForUploading(items: items)
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 }))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.copyCameraCapturedForUploading(let media, let type)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) {
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName]))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("FileMiddleware: failed to copy camera captured media for uploading")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
let isConsoleLoggingEnabled = false
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
let prefixLength = 400
|
|
||||||
func loggerMiddleware() -> Middleware<AppState, AppAction> {
|
|
||||||
{ state, action in
|
|
||||||
let timeStr = dateFormatter.string(from: Date())
|
|
||||||
var actionStr = "\(action)"
|
|
||||||
actionStr = String(actionStr.prefix(prefixLength)) + " ..."
|
|
||||||
var stateStr = "\(state)"
|
|
||||||
stateStr = String(stateStr.prefix(prefixLength)) + " ..."
|
|
||||||
|
|
||||||
let str = "\(timeStr) \u{EA86} \(actionStr)\n\(timeStr) \u{F129} \(stateStr)\n"
|
|
||||||
|
|
||||||
print(str)
|
|
||||||
if isConsoleLoggingEnabled {
|
|
||||||
NSLog(str)
|
|
||||||
}
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
func loggerMiddleware() -> Middleware<AppState, AppAction> {
|
|
||||||
{ _, _ in
|
|
||||||
Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum LogLevels: String {
|
|
||||||
case info = "\u{F449}"
|
|
||||||
case warning = "\u{F071}"
|
|
||||||
case error = "\u{EA76}"
|
|
||||||
}
|
|
||||||
|
|
||||||
// For database errors logging
|
|
||||||
func logIt(_ level: LogLevels, _ message: String) {
|
|
||||||
#if DEBUG
|
|
||||||
let timeStr = dateFormatter.string(from: Date())
|
|
||||||
let str = "\(timeStr) \(level.rawValue) \(message)"
|
|
||||||
print(str)
|
|
||||||
if isConsoleLoggingEnabled {
|
|
||||||
NSLog(str)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dateFormatter: DateFormatter {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
|
|
||||||
formatter.dateFormat = "MM-dd HH:mm:ss.SSS"
|
|
||||||
return formatter
|
|
||||||
}
|
|
||||||
|
|
||||||
// For thread debugging
|
|
||||||
func ptInfo(_ message: String) {
|
|
||||||
#if DEBUG
|
|
||||||
let timeStr = dateFormatter.string(from: Date())
|
|
||||||
let str = "\(timeStr) \(message) -> \(Thread.current), \(String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "no queue label")"
|
|
||||||
print(str)
|
|
||||||
if isConsoleLoggingEnabled {
|
|
||||||
NSLog(str)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class RostersMiddleware {
|
|
||||||
static let shared = RostersMiddleware()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedRostersLoaded(let rosters)):
|
|
||||||
return Just(.rostersAction(.rostersListUpdated(rosters)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
import AVFoundation
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Photos
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class SharingMiddleware {
|
|
||||||
static let shared = SharingMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: - Camera and Gallery Access
|
|
||||||
case .sharingAction(.checkCameraAccess):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
|
||||||
switch status {
|
|
||||||
case .authorized:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(true))))
|
|
||||||
|
|
||||||
case .notDetermined:
|
|
||||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(granted))))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied, .restricted:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(false))))
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(false))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.checkGalleryAccess):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let status = PHPhotoLibrary.authorizationStatus()
|
|
||||||
switch status {
|
|
||||||
case .authorized, .limited:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(true))))
|
|
||||||
|
|
||||||
case .notDetermined:
|
|
||||||
PHPhotoLibrary.requestAuthorization { status in
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(status == .authorized))))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied, .restricted:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(false))))
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(false))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsFromGalleryFetched(let items)):
|
|
||||||
return Just(.sharingAction(.galleryItemsUpdated(items: items)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: - Sharing
|
|
||||||
case .sharingAction(.shareMedia(let ids)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let items = state.sharingState.galleryItems.filter { ids.contains($0.id) }
|
|
||||||
promise(.success(.fileAction(.copyGalleryItemsForUploading(items: items))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsCopiedForUploading(let newMessageIds, let localNames)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
return Just(.conversationAction(.sendMediaMessages(
|
|
||||||
from: chat.account,
|
|
||||||
to: chat.participant,
|
|
||||||
messagesIds: newMessageIds,
|
|
||||||
localFilesNames: localNames
|
|
||||||
)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .sharingAction(.cameraCaptured(let media, let type)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) {
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName])))
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("SharingMiddleware: camera's captured file didn't copied")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.shareLocation(let lat, let lon)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
let msg = "geo:\(lat),\(lon)"
|
|
||||||
return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .sharingAction(.shareDocuments(let data, let extensions)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let ids = FileProcessing.shared.copyDocumentsForUploading(data: data, extensions: extensions)
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 })))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.shareContact(let jid)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
let msg = "contact:\(jid)"
|
|
||||||
return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class StartMiddleware {
|
|
||||||
static let shared = StartMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .accountsAction(.accountsListUpdated(let accounts)):
|
|
||||||
if accounts.isEmpty {
|
|
||||||
if state.currentFlow == .start {
|
|
||||||
return Just(.startAction(.goTo(.welcomeScreen)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if state.currentFlow == .accounts, state.accountsState.navigation == .addAccount {
|
|
||||||
return Just(.changeFlow(.chats))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else if state.currentFlow == .start {
|
|
||||||
return Just(.changeFlow(.chats))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,160 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
final class XMPPMiddleware {
|
|
||||||
static let shared = XMPPMiddleware()
|
|
||||||
private let service = XMPPService(manager: Database.shared)
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
private var uploadingMessageIDs = ThreadSafeSet<String>()
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
service.clientState.sink { client, state in
|
|
||||||
let jid = client.userBareJid.stringValue
|
|
||||||
let status = ConnectionStatus.from(state)
|
|
||||||
let action = AppAction.xmppAction(.clientConnectionChanged(jid: jid, state: status))
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
service.clientMessages.sink { _, martinMessage in
|
|
||||||
guard let message = Message.map(martinMessage) else { return }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.xmppMessageReceived(message)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
service.clientFeatures.sink { client, features in
|
|
||||||
let jid = client.userBareJid.stringValue
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.serverFeaturesLoaded(jid: jid, features: features)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .accountsAction(.tryAddAccountWithCredentials):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: state.accountsState.accounts)
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .accountsAction(.addAccountError):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: state.accountsState.accounts)
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .databaseAction(.storedAccountsLoaded(let accounts)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: accounts.filter { $0.isActive && !$0.isTemp })
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.addRoster(let ownerJID, let contactJID, let name, let groups)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else {
|
|
||||||
return promise(.success(.rostersAction(.addRosterError(reason: XMPPError.item_not_found.localizedDescription))))
|
|
||||||
}
|
|
||||||
let module = client.modulesManager.module(RosterModule.self)
|
|
||||||
module.addItem(jid: JID(contactJID), name: name, groups: groups, completionHandler: { result in
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
promise(.success(.rostersAction(.addRosterDone(jid: contactJID))))
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
promise(.success(.rostersAction(.addRosterError(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.deleteRoster(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else {
|
|
||||||
return promise(.success(.rostersAction(.rosterDeletingFailed(reason: XMPPError.item_not_found.localizedDescription))))
|
|
||||||
}
|
|
||||||
let module = client.modulesManager.module(RosterModule.self)
|
|
||||||
module.removeItem(jid: JID(contactJID), completionHandler: { result in
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
promise(.success(.info("XMPPMiddleware: roster \(contactJID) deleted from \(ownerJID)")))
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSent(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self?.service.sendMessage(message: message) { done in
|
|
||||||
if done {
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSendSuccess(msgId: message.id))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSendFailed(msgId: message.id))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingTryUpload(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
if self?.uploadingMessageIDs.contains(message.id) ?? false {
|
|
||||||
return promise(.success(.info("XMPPMiddleware: attachment in message \(message.id) is already in uploading process")))
|
|
||||||
} else {
|
|
||||||
self?.uploadingMessageIDs.insert(message.id)
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self?.service.uploadAttachment(message: message) { error, remotePath in
|
|
||||||
self?.uploadingMessageIDs.remove(message.id)
|
|
||||||
if let error {
|
|
||||||
promise(.success(.xmppAction(.xmppSharingUploadFailed(msgId: message.id, reason: error.localizedDescription))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.xmppAction(.xmppSharingUploadSuccess(msgId: message.id, attachmentRemotePath: remotePath))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppLoadArchivedMessages(let jid, let to, let fromDate)):
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
// return Deferred {
|
|
||||||
// Future<AppAction, Never> { [weak self] promise in
|
|
||||||
// self?.service.requestArchivedMessages(jid: jid, to: to, fromDate: fromDate)
|
|
||||||
// promise(.success(.conversationAction(.setArchivedMessagesRequested)))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// .eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Account: DBStorable {
|
|
||||||
var bareJid: String
|
|
||||||
var pass: String
|
|
||||||
var isActive: Bool
|
|
||||||
var isTemp: Bool // account which is added by user, but not yet logged in
|
|
||||||
var id: String { bareJid }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Account: UniversalInputSelectionElement {
|
|
||||||
var text: String? { bareJid }
|
|
||||||
var icon: Image? { nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Account {
|
|
||||||
static let databaseTableName = "accounts"
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Channel: DBStorable {
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var channel: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Channel {
|
|
||||||
static let channelTableName = "channels"
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
|
|
||||||
enum ConversationType: Int, Codable, DatabaseValueConvertible {
|
|
||||||
case chat = 0
|
|
||||||
case room = 1
|
|
||||||
case channel = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Chat: DBStorable {
|
|
||||||
static let databaseTableName = "chats"
|
|
||||||
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var participant: String
|
|
||||||
var type: ConversationType
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Chat: Equatable {}
|
|
|
@ -1,27 +0,0 @@
|
||||||
// This struct is simpliest variant of Martin's Client State.
|
|
||||||
// Just for more comfortable using in App State
|
|
||||||
import Foundation
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
enum ConnectionStatus: Stateable {
|
|
||||||
case connecting
|
|
||||||
case connected(resumed: Bool = false)
|
|
||||||
case disconnecting
|
|
||||||
case disconnected(reason: String)
|
|
||||||
|
|
||||||
static func from(_ state: XMPPClient.State) -> ConnectionStatus {
|
|
||||||
switch state {
|
|
||||||
case .connecting:
|
|
||||||
return .connecting
|
|
||||||
|
|
||||||
case .connected(let resumed):
|
|
||||||
return .connected(resumed: resumed)
|
|
||||||
|
|
||||||
case .disconnecting:
|
|
||||||
return .disconnecting
|
|
||||||
|
|
||||||
case .disconnected(let reason):
|
|
||||||
return .disconnected(reason: reason.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,128 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
enum MessageType: String, Codable, DatabaseValueConvertible {
|
|
||||||
case chat
|
|
||||||
case groupchat
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageAttachmentType: Int, Stateable, DatabaseValueConvertible {
|
|
||||||
case movie = 0
|
|
||||||
case image = 1
|
|
||||||
case audio = 2
|
|
||||||
case file = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageContentType: String, Codable, DatabaseValueConvertible {
|
|
||||||
case text
|
|
||||||
case typing
|
|
||||||
case invite
|
|
||||||
case attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Message: DBStorable, Equatable {
|
|
||||||
static let databaseTableName = "messages"
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let type: MessageType
|
|
||||||
let contentType: MessageContentType
|
|
||||||
|
|
||||||
let from: String
|
|
||||||
let to: String?
|
|
||||||
|
|
||||||
let body: String?
|
|
||||||
let subject: String?
|
|
||||||
let thread: String?
|
|
||||||
let oobUrl: String?
|
|
||||||
|
|
||||||
let date: Date
|
|
||||||
let pending: Bool
|
|
||||||
let sentError: Bool
|
|
||||||
|
|
||||||
var attachmentType: MessageAttachmentType?
|
|
||||||
var attachmentLocalName: String?
|
|
||||||
var attachmentRemotePath: URL?
|
|
||||||
var attachmentThumbnailName: String?
|
|
||||||
var attachmentDownloadFailed: Bool = false
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Message {
|
|
||||||
// Universal mapping from Martin's Message to App Message
|
|
||||||
static func map(_ martinMessage: Martin.Message) -> Message? {
|
|
||||||
#if DEBUG
|
|
||||||
print("---")
|
|
||||||
print("Message received: \(martinMessage)")
|
|
||||||
print("---")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Check that the message type is supported
|
|
||||||
let chatTypes: [StanzaType] = [.chat, .groupchat]
|
|
||||||
guard let mType = martinMessage.type, chatTypes.contains(mType) else {
|
|
||||||
#if DEBUG
|
|
||||||
print("Unsupported message type: \(martinMessage.type?.rawValue ?? "nil")")
|
|
||||||
#endif
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type
|
|
||||||
let type = MessageType(rawValue: martinMessage.type?.rawValue ?? "") ?? .chat
|
|
||||||
|
|
||||||
// Content type
|
|
||||||
var contentType: MessageContentType = .text
|
|
||||||
if martinMessage.oob != nil {
|
|
||||||
contentType = .attachment
|
|
||||||
} else if martinMessage.hints.contains(.noStore) {
|
|
||||||
contentType = .typing
|
|
||||||
}
|
|
||||||
|
|
||||||
// From/To
|
|
||||||
let from = martinMessage.from?.bareJid.stringValue ?? ""
|
|
||||||
let to = martinMessage.to?.bareJid.stringValue
|
|
||||||
|
|
||||||
// Extract date or set current
|
|
||||||
var date = Date()
|
|
||||||
if let timestampStr = martinMessage.attribute("archived_date"), let timeInterval = TimeInterval(timestampStr) {
|
|
||||||
date = Date(timeIntervalSince1970: timeInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Msg
|
|
||||||
var msg = Message(
|
|
||||||
id: martinMessage.id ?? UUID().uuidString,
|
|
||||||
type: type,
|
|
||||||
contentType: contentType,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: martinMessage.body,
|
|
||||||
subject: martinMessage.subject,
|
|
||||||
thread: martinMessage.thread,
|
|
||||||
oobUrl: martinMessage.oob,
|
|
||||||
date: date,
|
|
||||||
pending: false,
|
|
||||||
sentError: false,
|
|
||||||
attachmentType: nil,
|
|
||||||
attachmentLocalName: nil,
|
|
||||||
attachmentRemotePath: nil,
|
|
||||||
attachmentThumbnailName: nil,
|
|
||||||
attachmentDownloadFailed: false
|
|
||||||
)
|
|
||||||
if let oob = martinMessage.oob {
|
|
||||||
msg.attachmentType = oob.attachmentType
|
|
||||||
msg.attachmentRemotePath = URL(string: oob)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Message {
|
|
||||||
var attachmentLocalPath: URL? {
|
|
||||||
guard let attachmentLocalName = attachmentLocalName else { return nil }
|
|
||||||
return FileProcessing.fileFolder.appendingPathComponent(attachmentLocalName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachmentThumbnailPath: URL? {
|
|
||||||
guard let attachmentThumbnailName = attachmentThumbnailName else { return nil }
|
|
||||||
return FileProcessing.fileFolder.appendingPathComponent(attachmentThumbnailName)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Room: DBStorable {
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var nickname: String
|
|
||||||
var password: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Room {
|
|
||||||
static let roomTableName = "rooms"
|
|
||||||
}
|
|
|
@ -1,62 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
struct RosterVersion: DBStorable {
|
|
||||||
static let databaseTableName = "rosterVersions"
|
|
||||||
|
|
||||||
var bareJid: String
|
|
||||||
var version: String
|
|
||||||
var id: String { bareJid }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Roster: DBStorable {
|
|
||||||
static let databaseTableName = "rosters"
|
|
||||||
|
|
||||||
var bareJid: String = ""
|
|
||||||
var contactBareJid: String
|
|
||||||
var name: String?
|
|
||||||
var subscription: String
|
|
||||||
var ask: Bool
|
|
||||||
var data: DBRosterData
|
|
||||||
var locallyDeleted: Bool = false
|
|
||||||
|
|
||||||
var id: String { "\(bareJid)-\(contactBareJid)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DBRosterData: Codable, DatabaseValueConvertible {
|
|
||||||
let groups: [String]
|
|
||||||
let annotations: [RosterItemAnnotation]
|
|
||||||
|
|
||||||
public var databaseValue: DatabaseValue {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
// swiftlint:disable:next force_try
|
|
||||||
let data = try! encoder.encode(self)
|
|
||||||
return data.databaseValue
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? {
|
|
||||||
guard let data = Data.fromDatabaseValue(dbValue) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
// swiftlint:disable:next force_try
|
|
||||||
return try! decoder.decode(Self.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: DBRosterData, rhs: DBRosterData) -> Bool {
|
|
||||||
lhs.groups == rhs.groups && lhs.annotations == rhs.annotations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RosterItemAnnotation: Equatable {
|
|
||||||
public static func == (lhs: RosterItemAnnotation, rhs: RosterItemAnnotation) -> Bool {
|
|
||||||
lhs.type == rhs.type && lhs.values == rhs.values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Roster: Equatable {
|
|
||||||
static func == (lhs: Roster, rhs: Roster) -> Bool {
|
|
||||||
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct ServerFeature: Stateable, Identifiable {
|
|
||||||
let xep: String
|
|
||||||
let name: String
|
|
||||||
let xmppId: String?
|
|
||||||
let description: String?
|
|
||||||
|
|
||||||
var id: String { xep }
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
extension AccountsState {
|
|
||||||
static func reducer(state: inout AccountsState, action: AccountsAction) {
|
|
||||||
switch action {
|
|
||||||
case .accountsListUpdated(let accounts):
|
|
||||||
state.accounts = accounts
|
|
||||||
|
|
||||||
case .goTo(let navigation):
|
|
||||||
state.navigation = navigation
|
|
||||||
|
|
||||||
case .tryAddAccountWithCredentials(let login, let password):
|
|
||||||
let account = Account(bareJid: login, pass: password, isActive: true, isTemp: true)
|
|
||||||
state.accounts.append(account)
|
|
||||||
|
|
||||||
case .addAccountError(let jid, let reason):
|
|
||||||
state.accounts = state.accounts.filter { $0.bareJid != jid }
|
|
||||||
state.addAccountError = reason
|
|
||||||
|
|
||||||
case .clientServerFeaturesUpdated(let jid, let features):
|
|
||||||
state.discoFeatures[jid] = features
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension AppState {
|
|
||||||
static func reducer(state: inout AppState, action: AppAction) {
|
|
||||||
switch action {
|
|
||||||
case .flushState:
|
|
||||||
state = AppState()
|
|
||||||
|
|
||||||
case .changeFlow(let flow):
|
|
||||||
state.previousFlow = state.currentFlow
|
|
||||||
state.currentFlow = flow
|
|
||||||
|
|
||||||
case .startAction(let action):
|
|
||||||
StartState.reducer(state: &state.startState, action: action)
|
|
||||||
|
|
||||||
case .databaseAction, .xmppAction, .fileAction, .info:
|
|
||||||
break // this actions are processed by other middlewares
|
|
||||||
|
|
||||||
case .accountsAction(let action):
|
|
||||||
AccountsState.reducer(state: &state.accountsState, action: action)
|
|
||||||
|
|
||||||
case .rostersAction(let action):
|
|
||||||
RostersState.reducer(state: &state.rostersState, action: action)
|
|
||||||
|
|
||||||
case .chatsAction(let action):
|
|
||||||
ChatsState.reducer(state: &state.chatsState, action: action)
|
|
||||||
|
|
||||||
case .conversationAction(let action):
|
|
||||||
ConversationState.reducer(state: &state.conversationsState, action: action)
|
|
||||||
|
|
||||||
case .sharingAction(let action):
|
|
||||||
SharingState.reducer(state: &state.sharingState, action: action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
extension ChatsState {
|
|
||||||
static func reducer(state: inout ChatsState, action: ChatsAction) {
|
|
||||||
switch action {
|
|
||||||
case .chatsListUpdated(let chats):
|
|
||||||
state.chats = chats
|
|
||||||
|
|
||||||
case .chatStarted(let chat):
|
|
||||||
state.currentChat = chat
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension ConversationState {
|
|
||||||
static func reducer(state: inout ConversationState, action: ConversationAction) {
|
|
||||||
switch action {
|
|
||||||
case .makeConversationActive(let chat, let roster):
|
|
||||||
state.currentChat = chat
|
|
||||||
state.currentRoster = roster
|
|
||||||
|
|
||||||
case .messagesUpdated(let messages):
|
|
||||||
state.currentMessages = messages
|
|
||||||
|
|
||||||
case .setReplyText(let text):
|
|
||||||
if text.isEmpty {
|
|
||||||
state.replyText = ""
|
|
||||||
} else {
|
|
||||||
state.replyText = text.makeReply
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
extension RostersState {
|
|
||||||
static func reducer(state: inout RostersState, action: RostersAction) {
|
|
||||||
switch action {
|
|
||||||
case .addRosterDone(let jid):
|
|
||||||
state.newAddedRosterJid = jid
|
|
||||||
state.newAddedRosterError = nil
|
|
||||||
|
|
||||||
case .addRosterError(let reason):
|
|
||||||
state.newAddedRosterJid = nil
|
|
||||||
state.newAddedRosterError = reason
|
|
||||||
|
|
||||||
case .rostersListUpdated(let rosters):
|
|
||||||
state.rosters = rosters
|
|
||||||
|
|
||||||
case .markRosterAsLocallyDeleted, .deleteRoster:
|
|
||||||
state.deleteRosterError = nil
|
|
||||||
|
|
||||||
case .rosterDeletingFailed(let reson):
|
|
||||||
state.deleteRosterError = reson
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension SharingState {
|
|
||||||
static func reducer(state: inout SharingState, action: SharingAction) {
|
|
||||||
switch action {
|
|
||||||
case .showSharing(let shown):
|
|
||||||
state.sharingShown = shown
|
|
||||||
|
|
||||||
case .setCameraAccess(let granted):
|
|
||||||
state.isCameraAccessGranted = granted
|
|
||||||
|
|
||||||
case .setGalleryAccess(let granted):
|
|
||||||
state.isGalleryAccessGranted = granted
|
|
||||||
|
|
||||||
case .galleryItemsUpdated(let items):
|
|
||||||
state.galleryItems = items
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
extension StartState {
|
|
||||||
static func reducer(state: inout StartState, action: StartAction) {
|
|
||||||
switch action {
|
|
||||||
case .loadStoredAccounts:
|
|
||||||
break
|
|
||||||
|
|
||||||
case .goTo(let navigation):
|
|
||||||
state.navigation = navigation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
enum AccountNavigationState: Stateable {
|
|
||||||
case addAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AccountsState: Stateable {
|
|
||||||
var navigation: AccountNavigationState
|
|
||||||
var accounts: [Account]
|
|
||||||
var discoFeatures: [String: [ServerFeature]]
|
|
||||||
|
|
||||||
var addAccountError: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension AccountsState {
|
|
||||||
init() {
|
|
||||||
navigation = .addAccount
|
|
||||||
accounts = []
|
|
||||||
discoFeatures = [:]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum AppFlow: Codable {
|
|
||||||
case start
|
|
||||||
case accounts
|
|
||||||
case chats
|
|
||||||
case contacts
|
|
||||||
case settings
|
|
||||||
case conversation
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppState: Stateable {
|
|
||||||
var appVersion: String
|
|
||||||
var previousFlow: AppFlow
|
|
||||||
var currentFlow: AppFlow
|
|
||||||
|
|
||||||
var startState: StartState
|
|
||||||
var accountsState: AccountsState
|
|
||||||
var rostersState: RostersState
|
|
||||||
var chatsState: ChatsState
|
|
||||||
var conversationsState: ConversationState
|
|
||||||
var sharingState: SharingState
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension AppState {
|
|
||||||
init() {
|
|
||||||
appVersion = Const.appVersion
|
|
||||||
previousFlow = .start
|
|
||||||
currentFlow = .start
|
|
||||||
|
|
||||||
startState = StartState()
|
|
||||||
accountsState = AccountsState()
|
|
||||||
rostersState = RostersState()
|
|
||||||
chatsState = ChatsState()
|
|
||||||
conversationsState = ConversationState()
|
|
||||||
sharingState = SharingState()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
struct ChatsState: Stateable {
|
|
||||||
var chats: [Chat]
|
|
||||||
var currentChat: Chat?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension ChatsState {
|
|
||||||
init() {
|
|
||||||
chats = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
struct ConversationState: Stateable {
|
|
||||||
var currentChat: Chat?
|
|
||||||
var currentRoster: Roster?
|
|
||||||
var currentMessages: [Message]
|
|
||||||
|
|
||||||
var replyText: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension ConversationState {
|
|
||||||
init() {
|
|
||||||
currentMessages = []
|
|
||||||
replyText = ""
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
struct RostersState: Stateable {
|
|
||||||
var rosters: [Roster]
|
|
||||||
|
|
||||||
var newAddedRosterJid: String?
|
|
||||||
var newAddedRosterError: String?
|
|
||||||
|
|
||||||
var deleteRosterError: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension RostersState {
|
|
||||||
init() {
|
|
||||||
rosters = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SharingCameraMediaType: Stateable {
|
|
||||||
case video
|
|
||||||
case photo
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SharingGalleryItem: Stateable, Identifiable {
|
|
||||||
var id: String
|
|
||||||
var type: SharingCameraMediaType
|
|
||||||
var thumbnail: Data?
|
|
||||||
var duration: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SharingState: Stateable {
|
|
||||||
var sharingShown: Bool
|
|
||||||
var isCameraAccessGranted: Bool
|
|
||||||
var isGalleryAccessGranted: Bool
|
|
||||||
|
|
||||||
var galleryItems: [SharingGalleryItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension SharingState {
|
|
||||||
init() {
|
|
||||||
sharingShown = false
|
|
||||||
isCameraAccessGranted = false
|
|
||||||
isGalleryAccessGranted = false
|
|
||||||
|
|
||||||
galleryItems = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
enum StartNavigationState: Stateable {
|
|
||||||
case startScreen
|
|
||||||
case welcomeScreen
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StartState: Stateable {
|
|
||||||
var navigation: StartNavigationState
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension StartState {
|
|
||||||
init() {
|
|
||||||
navigation = .startScreen
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,266 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
protocol MartinsManager: Martin.RosterManager & Martin.ChatManager & Martin.ChannelManager & Martin.RoomManager {}
|
|
||||||
|
|
||||||
final class XMPPService: ObservableObject {
|
|
||||||
private let manager: MartinsManager
|
|
||||||
|
|
||||||
private let clientStatePublisher = PassthroughSubject<(XMPPClient, XMPPClient.State), Never>()
|
|
||||||
private var clientStateCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private let clientMessagesPublisher = PassthroughSubject<(XMPPClient, Martin.Message), Never>()
|
|
||||||
private var clientMessagesCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private let clientFeaturesPublisher = PassthroughSubject<(XMPPClient, [String]), Never>()
|
|
||||||
private var clientFeaturesCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
@Published private(set) var clients: [XMPPClient] = []
|
|
||||||
var clientState: AnyPublisher<(XMPPClient, XMPPClient.State), Never> {
|
|
||||||
clientStatePublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientMessages: AnyPublisher<(XMPPClient, Martin.Message), Never> {
|
|
||||||
clientMessagesPublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientFeatures: AnyPublisher<(XMPPClient, [String]), Never> {
|
|
||||||
clientFeaturesPublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(manager: MartinsManager) {
|
|
||||||
self.manager = manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateClients(for accounts: [Account]) {
|
|
||||||
// get simple diff
|
|
||||||
let forAdd = accounts
|
|
||||||
.filter { !self.clients.map { $0.connectionConfiguration.userJid.stringValue }.contains($0.bareJid) }
|
|
||||||
let forRemove = clients
|
|
||||||
.map { $0.connectionConfiguration.userJid.stringValue }
|
|
||||||
.filter { !accounts.map { $0.bareJid }.contains($0) }
|
|
||||||
|
|
||||||
// init and add clients
|
|
||||||
for account in forAdd {
|
|
||||||
// add client
|
|
||||||
let client = makeClient(for: account, with: manager)
|
|
||||||
clients.append(client)
|
|
||||||
|
|
||||||
// subscribe to client state
|
|
||||||
client.$state
|
|
||||||
.sink { [weak self] state in
|
|
||||||
self?.clientStatePublisher.send((client, state))
|
|
||||||
}
|
|
||||||
.store(in: &clientStateCancellables)
|
|
||||||
|
|
||||||
// subscribe to client server features
|
|
||||||
client.module(DiscoveryModule.self).$serverDiscoResult
|
|
||||||
.sink { [weak self] disco in
|
|
||||||
self?.clientFeaturesPublisher.send((client, disco.features))
|
|
||||||
}
|
|
||||||
.store(in: &clientFeaturesCancellables)
|
|
||||||
|
|
||||||
// subscribe to client messages
|
|
||||||
client.module(MessageModule.self).messagesPublisher
|
|
||||||
.sink { [weak self] message in
|
|
||||||
self?.clientMessagesPublisher.send((client, message.message))
|
|
||||||
}
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// subscribe to carbons
|
|
||||||
client.module(MessageCarbonsModule.self).carbonsPublisher
|
|
||||||
.sink { [weak self] carbon in
|
|
||||||
self?.clientMessagesPublisher.send((client, carbon.message))
|
|
||||||
}
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// subscribe to archived messages
|
|
||||||
// client.module(.mam).archivedMessagesPublisher
|
|
||||||
// .sink(receiveValue: { [weak self] archived in
|
|
||||||
// let message = archived.message
|
|
||||||
// message.attribute("archived_date", newValue: "\(archived.timestamp.timeIntervalSince1970)")
|
|
||||||
// self?.clientMessagesPublisher.send((client, message))
|
|
||||||
// })
|
|
||||||
// .store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// enable carbons if available
|
|
||||||
client.module(.messageCarbons).$isAvailable.filter { $0 }
|
|
||||||
.sink(receiveValue: { [weak client] _ in
|
|
||||||
client?.module(.messageCarbons).enable()
|
|
||||||
})
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// finally, do login
|
|
||||||
client.login()
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove clients
|
|
||||||
for jid in forRemove {
|
|
||||||
deinitClient(jid: jid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeClient(for account: Account, with manager: MartinsManager) -> XMPPClient {
|
|
||||||
let client = XMPPClient()
|
|
||||||
|
|
||||||
// register modules
|
|
||||||
// core modules RFC 6120
|
|
||||||
client.modulesManager.register(StreamFeaturesModule())
|
|
||||||
client.modulesManager.register(SaslModule())
|
|
||||||
client.modulesManager.register(AuthModule())
|
|
||||||
client.modulesManager.register(SessionEstablishmentModule())
|
|
||||||
client.modulesManager.register(ResourceBinderModule())
|
|
||||||
client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName)))
|
|
||||||
|
|
||||||
// messaging modules RFC 6121
|
|
||||||
client.modulesManager.register(RosterModule(rosterManager: manager))
|
|
||||||
client.modulesManager.register(PresenceModule())
|
|
||||||
|
|
||||||
client.modulesManager.register(PubSubModule())
|
|
||||||
client.modulesManager.register(MessageModule(chatManager: manager))
|
|
||||||
client.modulesManager.register(MessageArchiveManagementModule())
|
|
||||||
|
|
||||||
client.modulesManager.register(MessageCarbonsModule())
|
|
||||||
|
|
||||||
// file transfer modules
|
|
||||||
client.modulesManager.register(HttpFileUploadModule())
|
|
||||||
|
|
||||||
// extensions
|
|
||||||
client.modulesManager.register(SoftwareVersionModule())
|
|
||||||
client.modulesManager.register(PingModule())
|
|
||||||
client.connectionConfiguration.userJid = .init(account.bareJid)
|
|
||||||
client.connectionConfiguration.credentials = .password(password: account.pass)
|
|
||||||
|
|
||||||
// group chats
|
|
||||||
client.modulesManager.register(MucModule(roomManager: manager))
|
|
||||||
|
|
||||||
// channels
|
|
||||||
// client.modulesManager.register(MixModule(channelManager: manager))
|
|
||||||
|
|
||||||
// add client to clients
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func deinitClient(jid: String) {
|
|
||||||
if let index = clients.firstIndex(where: { $0.connectionConfiguration.userJid.stringValue == jid }) {
|
|
||||||
let client = clients.remove(at: index)
|
|
||||||
_ = client.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClient(for jid: String) -> XMPPClient? {
|
|
||||||
clients.first { $0.connectionConfiguration.userJid.stringValue == jid }
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMessage(message: Message, completion: @escaping (Bool) -> Void) {
|
|
||||||
guard let client = getClient(for: message.from), let to = message.to else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = chat.createMessage(text: message.body ?? "??", id: message.id)
|
|
||||||
chat.send(message: msg) { res in
|
|
||||||
switch res {
|
|
||||||
case .success:
|
|
||||||
completion(true)
|
|
||||||
|
|
||||||
case .failure:
|
|
||||||
completion(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadAttachment(message: Message, completion: @escaping (Error?, String) -> Void) {
|
|
||||||
guard let client = getClient(for: message.from), let to = message.to else {
|
|
||||||
completion(XMPPError.bad_request("No such client"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let fileName = message.attachmentLocalName else {
|
|
||||||
completion(XMPPError.bad_request("No such file"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let url = FileProcessing.fileFolder.appendingPathComponent(fileName)
|
|
||||||
guard let data = try? Data(contentsOf: url) else {
|
|
||||||
completion(XMPPError.bad_request("No such file"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else {
|
|
||||||
completion(XMPPError.bad_request("No such chat"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpModule = client.module(HttpFileUploadModule.self)
|
|
||||||
httpModule.findHttpUploadComponent { res in
|
|
||||||
switch res {
|
|
||||||
case .success(let components):
|
|
||||||
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
|
||||||
completion(XMPPError.bad_request("File too big"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
httpModule.requestUploadSlot(componentJid: component.jid, filename: fileName, size: data.count, contentType: url.mimeType) { res in
|
|
||||||
switch res {
|
|
||||||
case .success(let slot):
|
|
||||||
var request = URLRequest(url: slot.putUri)
|
|
||||||
for (key, value) in slot.putHeaders {
|
|
||||||
request.addValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
request.httpMethod = "PUT"
|
|
||||||
request.httpBody = data
|
|
||||||
request.addValue(String(data.count), forHTTPHeaderField: "Content-Length")
|
|
||||||
request.addValue(url.mimeType, forHTTPHeaderField: "Content-Type")
|
|
||||||
let session = URLSession(configuration: URLSessionConfiguration.default)
|
|
||||||
session.dataTask(with: request) { _, response, error in
|
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 500
|
|
||||||
guard error == nil, code == 200 || code == 201 else {
|
|
||||||
completion(XMPPError.bad_request("Upload failed"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if code == 200 {
|
|
||||||
completion(XMPPError.bad_request("Invalid response code"), "")
|
|
||||||
} else {
|
|
||||||
let mesg = chat.createMessage(text: slot.getUri.absoluteString, id: message.id)
|
|
||||||
mesg.oob = slot.getUri.absoluteString
|
|
||||||
chat.send(message: mesg) { res in
|
|
||||||
switch res {
|
|
||||||
case .success:
|
|
||||||
completion(nil, slot.getUri.absoluteString)
|
|
||||||
|
|
||||||
case .failure:
|
|
||||||
completion(XMPPError.bad_request("File uploaded, but message sent failed"), slot.getUri.absoluteString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.resume()
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
completion(error, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
completion(error, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestArchivedMessages(jid: String, to: String?, fromDate: Date) {
|
|
||||||
guard let client = getClient(for: jid) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client.module(.mam).queryItems(componentJid: JID(jid), with: JID(to), start: fromDate, end: Date(), queryId: UUID().uuidString) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
print("MAM response: \(response)")
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
print("MAM error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
2
old/Generated/.gitignore
vendored
2
old/Generated/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
|
@ -1,7 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Bool {
|
|
||||||
var intValue: Int {
|
|
||||||
self ? 1 : 0
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
enum Const {
|
|
||||||
// // Network
|
|
||||||
// #if DEBUG
|
|
||||||
// static let baseUrl = "staging.some.com/api"
|
|
||||||
// #else
|
|
||||||
// static let baseUrl = "prod.some.com/api"
|
|
||||||
// #endif
|
|
||||||
// static let requestTimeout = 15.0
|
|
||||||
// static let networkLogging = true
|
|
||||||
|
|
||||||
// App
|
|
||||||
static var appVersion: String {
|
|
||||||
let info = Bundle.main.infoDictionary
|
|
||||||
let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
|
||||||
let appBuild = info?[kCFBundleVersionKey as String] as? String ?? "Unknown"
|
|
||||||
return "v \(appVersion)(\(appBuild))"
|
|
||||||
}
|
|
||||||
|
|
||||||
static var appName: String {
|
|
||||||
Bundle.main.bundleIdentifier ?? "Conversations Classic iOS"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trusted servers
|
|
||||||
enum TrustedServers: String {
|
|
||||||
case narayana = "narayana.im"
|
|
||||||
case conversations = "conversations.im"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit for video for sharing
|
|
||||||
static let videoDurationLimit = 60.0
|
|
||||||
|
|
||||||
// Upload/download file folder
|
|
||||||
static let fileFolder = "Downloads"
|
|
||||||
|
|
||||||
// Grid size for gallery preview (3 in a row)
|
|
||||||
static let galleryGridSize = UIScreen.main.bounds.width / 3
|
|
||||||
|
|
||||||
// Size for map preview for location messages
|
|
||||||
static let mapPreviewSize = UIScreen.main.bounds.width * 0.5
|
|
||||||
|
|
||||||
// Size for attachment preview
|
|
||||||
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
|
|
||||||
|
|
||||||
// Lenght in days for MAM request
|
|
||||||
static let mamRequestDaysLength = 30
|
|
||||||
|
|
||||||
// Limits for messages pagination
|
|
||||||
static let messagesPageMin = 20
|
|
||||||
static let messagesPageMax = 100
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
import MapKit
|
|
||||||
|
|
||||||
extension MKCoordinateRegion: Equatable {
|
|
||||||
public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool {
|
|
||||||
lhs.center.latitude == rhs.center.latitude &&
|
|
||||||
lhs.center.longitude == rhs.center.longitude &&
|
|
||||||
lhs.span.latitudeDelta == rhs.span.latitudeDelta &&
|
|
||||||
lhs.span.longitudeDelta == rhs.span.longitudeDelta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CLLocationCoordinate2D: Identifiable {
|
|
||||||
public var id: String {
|
|
||||||
"\(latitude)-\(longitude)"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class ThreadSafeSet<T: Hashable> {
|
|
||||||
private var set: Set<T> = []
|
|
||||||
private let accessQueue = DispatchQueue(label: "com.example.ThreadSafeSet")
|
|
||||||
|
|
||||||
func insert(_ newElement: T) {
|
|
||||||
_ = accessQueue.sync {
|
|
||||||
set.insert(newElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(_ element: T) {
|
|
||||||
_ = accessQueue.sync {
|
|
||||||
set.remove(element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var elements: Set<T> {
|
|
||||||
accessQueue.sync { set }
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ element: T) -> Bool {
|
|
||||||
accessQueue.sync { set.contains(element) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
import CoreLocation
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
var firstLetter: String {
|
|
||||||
String(prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
var makeReply: String {
|
|
||||||
let allLines = components(separatedBy: .newlines)
|
|
||||||
let nonBlankLines = allLines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
||||||
var result = nonBlankLines.joined(separator: "\n")
|
|
||||||
result = "> \(result)"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLocation: Bool {
|
|
||||||
hasPrefix("geo:")
|
|
||||||
}
|
|
||||||
|
|
||||||
var getLatLon: CLLocationCoordinate2D {
|
|
||||||
let geo = components(separatedBy: ":")[1]
|
|
||||||
let parts = geo.components(separatedBy: ",")
|
|
||||||
let lat = Double(parts[0]) ?? 0.0
|
|
||||||
let lon = Double(parts[1]) ?? 0.0
|
|
||||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isContact: Bool {
|
|
||||||
hasPrefix("contact:")
|
|
||||||
}
|
|
||||||
|
|
||||||
var getContactJid: String {
|
|
||||||
components(separatedBy: ":")[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
var attachmentType: MessageAttachmentType {
|
|
||||||
let ext = (self as NSString).pathExtension.lowercased()
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case "mov", "mp4", "avi":
|
|
||||||
return .movie
|
|
||||||
|
|
||||||
case "jpg", "png", "gif":
|
|
||||||
return .image
|
|
||||||
|
|
||||||
case "mp3", "wav", "m4a":
|
|
||||||
return .audio
|
|
||||||
|
|
||||||
case "txt", "doc", "pdf":
|
|
||||||
return .file
|
|
||||||
|
|
||||||
default:
|
|
||||||
return .file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
var firstLetterColor: Color {
|
|
||||||
let firstLetter = self.firstLetter
|
|
||||||
switch firstLetter {
|
|
||||||
case "A", "M", "Y":
|
|
||||||
return Color.Rainbow.tortoiseLight500
|
|
||||||
|
|
||||||
case "B", "N", "Z":
|
|
||||||
return Color.Rainbow.orangeLight500
|
|
||||||
|
|
||||||
case "C", "O":
|
|
||||||
return Color.Rainbow.yellowLight500
|
|
||||||
|
|
||||||
case "D", "P":
|
|
||||||
return Color.Rainbow.greenLight500
|
|
||||||
|
|
||||||
case "E", "Q":
|
|
||||||
return Color.Rainbow.blueLight500
|
|
||||||
|
|
||||||
case "F", "R":
|
|
||||||
return Color.Rainbow.magentaLight500
|
|
||||||
|
|
||||||
case "G", "S":
|
|
||||||
return Color.Rainbow.tortoiseDark500
|
|
||||||
|
|
||||||
case "H", "T":
|
|
||||||
return Color.Rainbow.orangeDark500
|
|
||||||
|
|
||||||
case "I", "U":
|
|
||||||
return Color.Rainbow.yellowDark500
|
|
||||||
|
|
||||||
case "J", "V":
|
|
||||||
return Color.Rainbow.greenDark500
|
|
||||||
|
|
||||||
case "K", "W":
|
|
||||||
return Color.Rainbow.blueDark500
|
|
||||||
|
|
||||||
case "L", "X":
|
|
||||||
return Color.Rainbow.magentaDark500
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Color.Rainbow.tortoiseLight500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension TimeInterval {
|
|
||||||
var minAndSec: String {
|
|
||||||
let minutes = Int(self) / 60
|
|
||||||
let seconds = Int(self) % 60
|
|
||||||
return String(format: "%02d:%02d", minutes, seconds)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
import UIKit
|
|
||||||
|
|
||||||
func openAppSettings() {
|
|
||||||
if
|
|
||||||
let appSettingsUrl = URL(string: UIApplication.openSettingsURLString),
|
|
||||||
UIApplication.shared.canOpenURL(appSettingsUrl)
|
|
||||||
{
|
|
||||||
UIApplication.shared.open(appSettingsUrl, completionHandler: nil)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
extension URL {
|
|
||||||
var mimeType: String {
|
|
||||||
let pathExtension = self.pathExtension
|
|
||||||
|
|
||||||
if let uti = UTType(filenameExtension: pathExtension) {
|
|
||||||
return uti.preferredMIMEType ?? "application/octet-stream"
|
|
||||||
} else {
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// Wrapper
|
|
||||||
@propertyWrapper
|
|
||||||
struct Storage<T> {
|
|
||||||
private let key: String
|
|
||||||
private let defaultValue: T
|
|
||||||
|
|
||||||
init(key: String, defaultValue: T) {
|
|
||||||
self.key = key
|
|
||||||
self.defaultValue = defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var wrappedValue: T {
|
|
||||||
get {
|
|
||||||
// Read value from UserDefaults
|
|
||||||
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
// Set value to UserDefaults
|
|
||||||
UserDefaults.standard.set(newValue, forKey: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
private let keyLocalizationSelected = "conversations.classic.user.defaults.localizationSelected"
|
|
||||||
|
|
||||||
enum UserSettings {
|
|
||||||
@Storage(key: keyLocalizationSelected, defaultValue: false)
|
|
||||||
static var localizationSelectedByUser: Bool
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xE4",
|
|
||||||
"green" : "0xE4",
|
|
||||||
"red" : "0xE4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "235",
|
|
||||||
"green" : "235",
|
|
||||||
"red" : "235"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x4D",
|
|
||||||
"green" : "0x46",
|
|
||||||
"red" : "0x3C"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xAC",
|
|
||||||
"green" : "0xA3",
|
|
||||||
"red" : "0x95"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "201",
|
|
||||||
"green" : "227",
|
|
||||||
"red" : "199"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x62",
|
|
||||||
"green" : "0x59",
|
|
||||||
"red" : "0x4A"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "189",
|
|
||||||
"green" : "189",
|
|
||||||
"red" : "189"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xFF",
|
|
||||||
"green" : "0xFF",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x36",
|
|
||||||
"green" : "0x31",
|
|
||||||
"red" : "0x2A"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x78",
|
|
||||||
"green" : "0x6D",
|
|
||||||
"red" : "0x5A"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xEF",
|
|
||||||
"green" : "0xEF",
|
|
||||||
"red" : "0xEF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xD9",
|
|
||||||
"green" : "0xD7",
|
|
||||||
"red" : "0xD3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xC2",
|
|
||||||
"green" : "0xBD",
|
|
||||||
"red" : "0xB5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x9A",
|
|
||||||
"green" : "0x8F",
|
|
||||||
"red" : "0x7D"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xFF",
|
|
||||||
"green" : "0xFF",
|
|
||||||
"red" : "0xFE"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x89",
|
|
||||||
"green" : "0x7C",
|
|
||||||
"red" : "0x66"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"provides-namespace" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xB4",
|
|
||||||
"green" : "0xEC",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x83",
|
|
||||||
"green" : "0xF0",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x52",
|
|
||||||
"green" : "0xD5",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x2D",
|
|
||||||
"green" : "0xCA",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0xE1",
|
|
||||||
"green" : "0xF8",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x14",
|
|
||||||
"green" : "0xC1",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x0F",
|
|
||||||
"green" : "0xB3",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x0D",
|
|
||||||
"green" : "0xA0",
|
|
||||||
"red" : "0xFF"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"color" : {
|
|
||||||
"color-space" : "srgb",
|
|
||||||
"components" : {
|
|
||||||
"alpha" : "1.000",
|
|
||||||
"blue" : "0x0C",
|
|
||||||
"green" : "0x8F",
|
|
||||||
"red" : "0xFE"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue