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
|
||||
let from = martinMessage.from?.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