feat: WatchOS app (#5476)
|
@ -19,13 +19,13 @@ module.exports = {
|
|||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/Rocket.Chat Experimental.app',
|
||||
build:
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Debug -destination \'generic/platform=iphonesimulator\' -derivedDataPath ios/build'
|
||||
},
|
||||
'ios.release': {
|
||||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app',
|
||||
build:
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Release -destination \'generic/platform=iphonesimulator\' -derivedDataPath ios/build'
|
||||
},
|
||||
'android.debug': {
|
||||
type: 'android.apk',
|
||||
|
|
|
@ -67,6 +67,7 @@ const RCSSLPinning = Platform.select({
|
|||
certificate = persistCertificate(name, certificate.password);
|
||||
}
|
||||
UserPreferences.setMap(extractHostname(server), certificate);
|
||||
SSLPinning?.setCertificate(server, certificate.path, certificate.password);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
After Width: | Height: | Size: 424 KiB |
|
@ -149,6 +149,12 @@
|
|||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
After Width: | Height: | Size: 56 KiB |
|
@ -149,6 +149,12 @@
|
|||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import Foundation
|
||||
|
||||
protocol ErrorActionHandling {
|
||||
func handle(error: RocketChatError)
|
||||
}
|
||||
|
||||
final class ErrorActionHandler {
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var serversDB: ServersDatabase
|
||||
@Dependency private var router: AppRouting
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
private func handleOnMain(error: RocketChatError) {
|
||||
switch error {
|
||||
case .server(let response):
|
||||
router.present(error: response)
|
||||
case .unauthorized:
|
||||
router.route(to: [.loading, .serverList]) {
|
||||
self.database.remove()
|
||||
self.serversDB.remove(self.server)
|
||||
}
|
||||
case .unknown:
|
||||
print("Unexpected error on Client.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ErrorActionHandler: ErrorActionHandling {
|
||||
func handle(error: RocketChatError) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleOnMain(error: error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import Foundation
|
||||
|
||||
protocol AppRouting {
|
||||
func route(to route: Route)
|
||||
func present(error: ErrorResponse)
|
||||
func route(to routes: [Route], completion: (() -> Void)?)
|
||||
}
|
||||
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published var error: ErrorResponse?
|
||||
|
||||
@Published var server: Server? {
|
||||
didSet {
|
||||
if server != oldValue, let server {
|
||||
registerDependencies(in: server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var room: Room?
|
||||
|
||||
@Storage(.currentServer) private var currentURL: URL?
|
||||
|
||||
private func registerDependencies(in server: Server) {
|
||||
Store.register(Database.self, factory: server.database)
|
||||
Store.register(RocketChatClientProtocol.self, factory: RocketChatClient(server: server))
|
||||
Store.register(MessageSending.self, factory: MessageSender(server: server))
|
||||
Store.register(ErrorActionHandling.self, factory: ErrorActionHandler(server: server))
|
||||
Store.register(MessagesLoading.self, factory: MessagesLoader())
|
||||
Store.register(RoomsLoader.self, factory: RoomsLoader(server: server))
|
||||
}
|
||||
}
|
||||
|
||||
extension AppRouter: AppRouting {
|
||||
func route(to route: Route) {
|
||||
switch route {
|
||||
case .roomList(let selectedServer):
|
||||
currentURL = selectedServer.url
|
||||
room = nil
|
||||
server = selectedServer
|
||||
case .room(let selectedServer, let selectedRoom):
|
||||
currentURL = selectedServer.url
|
||||
server = selectedServer
|
||||
room = selectedRoom
|
||||
case .serverList:
|
||||
currentURL = nil
|
||||
room = nil
|
||||
server = nil
|
||||
case .loading:
|
||||
room = nil
|
||||
server = nil
|
||||
}
|
||||
}
|
||||
|
||||
func present(error: ErrorResponse) {
|
||||
guard self.error == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
extension AppRouter {
|
||||
func route(to routes: [Route], completion: (() -> Void)? = nil) {
|
||||
guard let routeTo = routes.first else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.route(to: routeTo)
|
||||
self.route(to: Array(routes[1..<routes.count]), completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Equatable {
|
||||
case loading
|
||||
case serverList
|
||||
case roomList(Server)
|
||||
case room(Server, Room)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AppView: View {
|
||||
@Storage(.currentServer) private var currentURL: URL?
|
||||
|
||||
@Dependency private var database: ServersDatabase
|
||||
|
||||
@StateObject private var router: AppRouter
|
||||
|
||||
init(router: AppRouter) {
|
||||
_router = StateObject(wrappedValue: router)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ServerListView()
|
||||
.environmentObject(router)
|
||||
.environment(\.managedObjectContext, database.viewContext)
|
||||
}
|
||||
.onAppear {
|
||||
loadRoute()
|
||||
}
|
||||
.sheet(item: $router.error) { error in
|
||||
Text(error.error)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRoute() {
|
||||
if let currentURL, let server = database.server(url: currentURL) {
|
||||
router.route(to: .roomList(server))
|
||||
} else if database.servers().count == 1, let server = database.servers().first {
|
||||
router.route(to: .roomList(server))
|
||||
} else {
|
||||
router.route(to: .serverList)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
12
ios/RocketChat Watch App/Assets.xcassets/channel-private.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "channel-private.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/channel-private.imageset/channel-private.png
vendored
Normal file
After Width: | Height: | Size: 371 B |
12
ios/RocketChat Watch App/Assets.xcassets/channel-public.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "channel-public.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/channel-public.imageset/channel-public.png
vendored
Normal file
After Width: | Height: | Size: 259 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "discussions.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/discussions.imageset/discussions.png
vendored
Normal file
After Width: | Height: | Size: 517 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "message.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 479 B |
12
ios/RocketChat Watch App/Assets.xcassets/teams-private.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "teams-private.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/teams-private.imageset/teams-private.png
vendored
Normal file
After Width: | Height: | Size: 509 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "teams.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 512 B |
|
@ -0,0 +1,10 @@
|
|||
import Foundation
|
||||
|
||||
struct JSONAdapter: RequestAdapter {
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest {
|
||||
var request = urlRequest
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import Foundation
|
||||
|
||||
protocol RequestAdapter {
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest
|
||||
func adapt(_ url: URL) -> URL
|
||||
}
|
||||
|
||||
extension RequestAdapter {
|
||||
func adapt(_ url: URL) -> URL {
|
||||
url
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import Foundation
|
||||
|
||||
struct TokenAdapter: RequestAdapter {
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func adapt(_ url: URL) -> URL {
|
||||
url.appending(
|
||||
queryItems: [
|
||||
URLQueryItem(name: "rc_token", value: server.loggedUser.token),
|
||||
URLQueryItem(name: "rc_uid", value: server.loggedUser.id)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest {
|
||||
var request = urlRequest
|
||||
request.addValue(server.loggedUser.id, forHTTPHeaderField: "x-user-id")
|
||||
request.addValue(server.loggedUser.token, forHTTPHeaderField: "x-auth-token")
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// https://stackoverflow.com/a/28016692
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date.ISO8601FormatStyle {
|
||||
static let iso8601withFractionalSeconds: Self = .init(includingFractionalSeconds: true)
|
||||
}
|
||||
|
||||
extension ParseStrategy where Self == Date.ISO8601FormatStyle {
|
||||
static var iso8601withFractionalSeconds: Date.ISO8601FormatStyle { .iso8601withFractionalSeconds }
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Date.ISO8601FormatStyle {
|
||||
static var iso8601withFractionalSeconds: Date.ISO8601FormatStyle { .iso8601withFractionalSeconds }
|
||||
}
|
||||
|
||||
extension Date {
|
||||
init(iso8601withFractionalSeconds parseInput: ParseStrategy.ParseInput) throws {
|
||||
try self.init(parseInput, strategy: .iso8601withFractionalSeconds)
|
||||
}
|
||||
|
||||
var iso8601withFractionalSeconds: String {
|
||||
formatted(.iso8601withFractionalSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func iso8601withFractionalSeconds() throws -> Date {
|
||||
try .init(iso8601withFractionalSeconds: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder.DateDecodingStrategy {
|
||||
static let iso8601withFractionalSeconds = custom {
|
||||
try .init(iso8601withFractionalSeconds: $0.singleValueContainer().decode(String.self))
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder.DateEncodingStrategy {
|
||||
static let iso8601withFractionalSeconds = custom {
|
||||
var container = $1.singleValueContainer()
|
||||
try container.encode($0.iso8601withFractionalSeconds)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func decode<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractionalSeconds
|
||||
return try decoder.decode(T.self, from: self)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
extension String {
|
||||
static func random(_ count: Int) -> String {
|
||||
let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return String((0..<count).compactMap { _ in letters.randomElement() })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func appending(queryItems: [URLQueryItem]) -> Self {
|
||||
var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
|
||||
|
||||
components?.queryItems = queryItems
|
||||
|
||||
return components?.url ?? self
|
||||
}
|
||||
|
||||
func appending(path: String) -> Self {
|
||||
appendingPathComponent(path)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
struct FailableDecodable<Value: Codable & Hashable>: Codable, Hashable {
|
||||
let value: Value?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
value = try? container.decode(Value.self)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import Foundation
|
||||
|
||||
protocol Request<Response> {
|
||||
associatedtype Response: Codable
|
||||
|
||||
var path: String { get }
|
||||
var method: HTTPMethod { get }
|
||||
var body: Data? { get }
|
||||
var queryItems: [URLQueryItem] { get }
|
||||
}
|
||||
|
||||
extension Request {
|
||||
var method: HTTPMethod {
|
||||
.get
|
||||
}
|
||||
|
||||
var body: Data? {
|
||||
nil
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
[]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import Foundation
|
||||
|
||||
let HISTORY_MESSAGE_COUNT = 50
|
||||
|
||||
struct HistoryRequest: Request {
|
||||
typealias Response = HistoryResponse
|
||||
|
||||
let path: String
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(roomId: String, roomType: String?, latest: Date) {
|
||||
path = "api/v1/\(RoomType.from(roomType).api).history"
|
||||
|
||||
queryItems = [
|
||||
URLQueryItem(name: "roomId", value: roomId),
|
||||
URLQueryItem(name: "count", value: String(HISTORY_MESSAGE_COUNT)),
|
||||
URLQueryItem(name: "latest", value: latest.iso8601withFractionalSeconds)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum RoomType: String {
|
||||
case direct = "d"
|
||||
case group = "p"
|
||||
case channel = "c"
|
||||
case livechat = "l"
|
||||
|
||||
static func from(_ rawValue: String?) -> Self {
|
||||
guard let rawValue, let type = RoomType(rawValue: rawValue) else {
|
||||
return .channel
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
var api: String {
|
||||
switch self {
|
||||
case .direct:
|
||||
return "im"
|
||||
case .group:
|
||||
return "groups"
|
||||
case .channel, .livechat:
|
||||
return "channels"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct MessagesRequest: Request {
|
||||
typealias Response = MessagesResponse
|
||||
|
||||
let path: String = "api/v1/chat.syncMessages"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(lastUpdate: Date?, roomId: String) {
|
||||
self.queryItems = [
|
||||
URLQueryItem(name: "roomId", value: roomId),
|
||||
URLQueryItem(name: "lastUpdate", value: lastUpdate?.ISO8601Format())
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
|
||||
struct ReadRequest: Request {
|
||||
typealias Response = ReadResponse
|
||||
|
||||
let path: String = "api/v1/subscriptions.read"
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
var body: Data? {
|
||||
try? JSONSerialization.data(withJSONObject: [
|
||||
"rid": rid
|
||||
])
|
||||
}
|
||||
|
||||
let rid: String
|
||||
|
||||
init(rid: String) {
|
||||
self.rid = rid
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import Foundation
|
||||
|
||||
struct RoomsRequest: Request {
|
||||
typealias Response = RoomsResponse
|
||||
|
||||
let path: String = "api/v1/rooms.get"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(updatedSince: Date?) {
|
||||
if let updatedSince {
|
||||
queryItems = [URLQueryItem(name: "updatedSince", value: updatedSince.iso8601withFractionalSeconds)]
|
||||
} else {
|
||||
queryItems = []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
struct SendMessageRequest: Request {
|
||||
typealias Response = SendMessageResponse
|
||||
|
||||
let path: String = "api/v1/chat.sendMessage"
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
var body: Data? {
|
||||
try? JSONSerialization.data(withJSONObject: [
|
||||
"message": [
|
||||
"_id": id,
|
||||
"rid": rid,
|
||||
"msg": msg,
|
||||
"tshow": false
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
let id: String
|
||||
let rid: String
|
||||
let msg: String
|
||||
|
||||
init(id: String, rid: String, msg: String) {
|
||||
self.id = id
|
||||
self.rid = rid
|
||||
self.msg = msg
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import Foundation
|
||||
|
||||
struct SubscriptionsRequest: Request {
|
||||
typealias Response = SubscriptionsResponse
|
||||
|
||||
let path: String = "api/v1/subscriptions.get"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(updatedSince: Date?) {
|
||||
if let updatedSince {
|
||||
queryItems = [URLQueryItem(name: "updatedSince", value: updatedSince.iso8601withFractionalSeconds)]
|
||||
} else {
|
||||
queryItems = []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import Foundation
|
||||
|
||||
struct AttachmentResponse: Codable, Hashable {
|
||||
let title: String?
|
||||
let imageURL: URL?
|
||||
let audioURL: URL?
|
||||
let description: String?
|
||||
let dimensions: DimensionsResponse?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case imageURL = "image_url"
|
||||
case audioURL = "audio_url"
|
||||
case title
|
||||
case description
|
||||
case dimensions = "image_dimensions"
|
||||
}
|
||||
}
|
||||
|
||||
struct DimensionsResponse: Codable, Hashable {
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
struct HistoryResponse: Codable {
|
||||
let messages: [MessageResponse]
|
||||
let success: Bool
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct MessageResponse: Codable, Hashable {
|
||||
let _id: String
|
||||
let rid: String
|
||||
let msg: String
|
||||
let u: UserResponse
|
||||
let ts: Date
|
||||
let attachments: [AttachmentResponse]?
|
||||
let t: String?
|
||||
let groupable: Bool?
|
||||
let editedAt: Date?
|
||||
let role: String?
|
||||
let comment: String?
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
struct MessagesResponse: Codable {
|
||||
let result: MessagesResult
|
||||
let success: Bool
|
||||
|
||||
struct MessagesResult: Codable {
|
||||
let updated: [MessageResponse]
|
||||
let deleted: [MessageResponse]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
struct ReadResponse: Codable {
|
||||
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import Foundation
|
||||
|
||||
struct RoomsResponse: Codable {
|
||||
let update: Set<Room>
|
||||
let remove: Set<Room>
|
||||
let success: Bool
|
||||
|
||||
struct Room: Codable, Hashable {
|
||||
let _id: String
|
||||
let name: String?
|
||||
let fname: String?
|
||||
let prid: String?
|
||||
let t: String?
|
||||
let ts: Date?
|
||||
let ro: Bool?
|
||||
let _updatedAt: Date?
|
||||
let encrypted: Bool?
|
||||
let usernames: [String]?
|
||||
let uids: [String]?
|
||||
let lastMessage: FailableDecodable<MessageResponse>?
|
||||
let lm: Date?
|
||||
let teamMain: Bool?
|
||||
let archived: Bool?
|
||||
let broadcast: Bool?
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
struct SendMessageResponse: Codable {
|
||||
let message: MessageResponse
|
||||
let success: Bool
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
struct SubscriptionsResponse: Codable {
|
||||
let update: Set<Subscription>
|
||||
let remove: Set<Subscription>
|
||||
let success: Bool
|
||||
|
||||
struct Subscription: Codable, Hashable {
|
||||
let _id: String
|
||||
let rid: String
|
||||
let name: String?
|
||||
let fname: String?
|
||||
let t: String
|
||||
let unread: Int
|
||||
let alert: Bool
|
||||
let lr: Date?
|
||||
let open: Bool?
|
||||
let _updatedAt: Date?
|
||||
let hideUnreadStatus: Bool?
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == SubscriptionsResponse.Subscription {
|
||||
func find(withRoomID rid: String) -> SubscriptionsResponse.Subscription? {
|
||||
first { subscription in
|
||||
subscription.rid == rid
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
struct UserResponse: Codable, Hashable {
|
||||
let _id: String
|
||||
let username: String
|
||||
let name: String?
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
|
||||
protocol RocketChatClientProtocol {
|
||||
var session: URLSession { get }
|
||||
|
||||
func authorizedURL(url: URL) -> URL
|
||||
func getRooms(updatedSince: Date?) -> AnyPublisher<RoomsResponse, RocketChatError>
|
||||
func getSubscriptions(updatedSince: Date?) -> AnyPublisher<SubscriptionsResponse, RocketChatError>
|
||||
func getHistory(rid: String, t: String, latest: Date) -> AnyPublisher<HistoryResponse, RocketChatError>
|
||||
func syncMessages(rid: String, updatedSince: Date) -> AnyPublisher<MessagesResponse, RocketChatError>
|
||||
func sendMessage(id: String, rid: String, msg: String) -> AnyPublisher<SendMessageResponse, RocketChatError>
|
||||
func sendRead(rid: String) -> AnyPublisher<ReadResponse, RocketChatError>
|
||||
}
|
||||
|
||||
final class RocketChatClient: NSObject {
|
||||
@Dependency private var errorActionHandler: ErrorActionHandling
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
lazy var session = URLSession(
|
||||
configuration: .default,
|
||||
delegate: URLSesionClientCertificateHandling(
|
||||
certificate: server.certificate,
|
||||
password: server.password
|
||||
),
|
||||
delegateQueue: nil
|
||||
)
|
||||
|
||||
private var adapters: [RequestAdapter] {
|
||||
[
|
||||
TokenAdapter(server: server),
|
||||
JSONAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
private func dataTask<T: Request>(for request: T) -> AnyPublisher<T.Response, RocketChatError> {
|
||||
let url = server.url.appending(path: request.path).appending(queryItems: request.queryItems)
|
||||
|
||||
var urlRequest = adapters.reduce(URLRequest(url: url), { $1.adapt($0) })
|
||||
urlRequest.httpMethod = request.method.rawValue
|
||||
urlRequest.httpBody = request.body
|
||||
|
||||
return session.dataTaskPublisher(for: urlRequest)
|
||||
.tryMap { data, response in
|
||||
if let response = response as? HTTPURLResponse, response.statusCode == 401 {
|
||||
throw RocketChatError.unauthorized
|
||||
}
|
||||
|
||||
if let response = try? data.decode(T.Response.self) {
|
||||
return response
|
||||
}
|
||||
|
||||
let response = try data.decode(ErrorResponse.self)
|
||||
throw RocketChatError.server(response: response)
|
||||
}
|
||||
.mapError { [weak self] error in
|
||||
guard let error = error as? RocketChatError else {
|
||||
return .unknown
|
||||
}
|
||||
|
||||
self?.errorActionHandler.handle(error: error)
|
||||
return error
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension RocketChatClient: RocketChatClientProtocol {
|
||||
func authorizedURL(url: URL) -> URL {
|
||||
adapters.reduce(server.url.appending(path: url.relativePath), { $1.adapt($0) })
|
||||
}
|
||||
|
||||
func getRooms(updatedSince: Date?) -> AnyPublisher<RoomsResponse, RocketChatError> {
|
||||
let request = RoomsRequest(updatedSince: updatedSince)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func getSubscriptions(updatedSince: Date?) -> AnyPublisher<SubscriptionsResponse, RocketChatError> {
|
||||
let request = SubscriptionsRequest(updatedSince: updatedSince)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func getHistory(rid: String, t: String, latest: Date) -> AnyPublisher<HistoryResponse, RocketChatError> {
|
||||
let request = HistoryRequest(roomId: rid, roomType: t, latest: latest)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func syncMessages(rid: String, updatedSince: Date) -> AnyPublisher<MessagesResponse, RocketChatError> {
|
||||
let request = MessagesRequest(lastUpdate: updatedSince, roomId: rid)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func sendMessage(id: String, rid: String, msg: String) -> AnyPublisher<SendMessageResponse, RocketChatError> {
|
||||
let request = SendMessageRequest(id: id, rid: rid, msg: msg)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func sendRead(rid: String) -> AnyPublisher<ReadResponse, RocketChatError> {
|
||||
let request = ReadRequest(rid: rid)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct ErrorResponse: Codable, Identifiable {
|
||||
var id: String {
|
||||
error
|
||||
}
|
||||
|
||||
let error: String
|
||||
}
|
||||
|
||||
enum RocketChatError: Error {
|
||||
case server(response: ErrorResponse)
|
||||
case unauthorized
|
||||
case unknown
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
// https://medium.com/@hamidptb/implementing-mtls-on-ios-using-urlsession-and-cloudflare-890b76aca66c
|
||||
|
||||
import Foundation
|
||||
|
||||
final class URLSesionClientCertificateHandling: NSObject, URLSessionDelegate {
|
||||
private let certificate: Data?
|
||||
private let password: String?
|
||||
|
||||
init(certificate: Data?, password: String?) {
|
||||
self.certificate = certificate
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let credential = Credentials.urlCredential(certificate: certificate, password: password) else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
challenge.sender?.use(credential, for: challenge)
|
||||
completionHandler(.useCredential, credential)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate typealias UserCertificate = (data: Data, password: String)
|
||||
|
||||
fileprivate final class Credentials {
|
||||
static func urlCredential(certificate: Data?, password: String?) -> URLCredential? {
|
||||
guard let certificate, let password else { return nil }
|
||||
|
||||
let p12Contents = PKCS12(pkcs12Data: certificate, password: password)
|
||||
|
||||
guard let identity = p12Contents.identity else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URLCredential(identity: identity, certificates: nil, persistence: .none)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct PKCS12 {
|
||||
let label: String?
|
||||
let keyID: NSData?
|
||||
let trust: SecTrust?
|
||||
let certChain: [SecTrust]?
|
||||
let identity: SecIdentity?
|
||||
|
||||
public init(pkcs12Data: Data, password: String) {
|
||||
let importPasswordOption: NSDictionary
|
||||
= [kSecImportExportPassphrase as NSString: password]
|
||||
var items: CFArray?
|
||||
let secError: OSStatus
|
||||
= SecPKCS12Import(pkcs12Data as NSData,
|
||||
importPasswordOption, &items)
|
||||
guard secError == errSecSuccess else {
|
||||
if secError == errSecAuthFailed {
|
||||
NSLog("Incorrect password?")
|
||||
}
|
||||
fatalError("Error trying to import PKCS12 data")
|
||||
}
|
||||
guard let theItemsCFArray = items else { fatalError() }
|
||||
let theItemsNSArray: NSArray = theItemsCFArray as NSArray
|
||||
guard let dictArray
|
||||
= theItemsNSArray as? [[String: AnyObject]]
|
||||
else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
label = dictArray.element(for: kSecImportItemLabel)
|
||||
keyID = dictArray.element(for: kSecImportItemKeyID)
|
||||
trust = dictArray.element(for: kSecImportItemTrust)
|
||||
certChain = dictArray.element(for: kSecImportItemCertChain)
|
||||
identity = dictArray.element(for: kSecImportItemIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Array where Element == [String: AnyObject] {
|
||||
func element<T>(for key: CFString) -> T? {
|
||||
for dictElement in self {
|
||||
if let value = dictElement[key as String] as? T {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
protocol ServersDatabase {
|
||||
var viewContext: NSManagedObjectContext { get }
|
||||
|
||||
func server(url: URL) -> Server?
|
||||
func user(id: String) -> LoggedUser?
|
||||
func servers() -> [Server]
|
||||
|
||||
func remove(_ server: Server)
|
||||
|
||||
func save()
|
||||
|
||||
func process(updatedServer: WatchMessage.Server)
|
||||
}
|
||||
|
||||
final class DefaultDatabase: ServersDatabase {
|
||||
private let container: NSPersistentContainer
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
private static let model: NSManagedObjectModel = {
|
||||
guard let url = Bundle.main.url(forResource: "Default", withExtension: "momd"),
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
|
||||
fatalError("Can't find Core Data Model")
|
||||
}
|
||||
|
||||
return managedObjectModel
|
||||
}()
|
||||
|
||||
init() {
|
||||
container = NSPersistentContainer(name: "default", managedObjectModel: Self.model)
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error { fatalError("Can't load persistent stores: \(error)") }
|
||||
}
|
||||
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard container.viewContext.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
try? container.viewContext.save()
|
||||
}
|
||||
|
||||
func server(url: URL) -> Server? {
|
||||
let request = Server.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "url == %@", url.absoluteString.removeTrailingSlash())
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func user(id: String) -> LoggedUser? {
|
||||
let request = LoggedUser.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func servers() -> [Server] {
|
||||
let request = Server.fetchRequest()
|
||||
|
||||
return (try? viewContext.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func remove(_ server: Server) {
|
||||
viewContext.delete(server)
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func process(updatedServer: WatchMessage.Server) {
|
||||
if let server = server(url: updatedServer.url) {
|
||||
server.url = updatedServer.url
|
||||
server.name = updatedServer.name
|
||||
server.iconURL = updatedServer.iconURL
|
||||
server.useRealName = updatedServer.useRealName
|
||||
server.loggedUser = user(from: updatedServer.loggedUser)
|
||||
server.certificate = updatedServer.clientSSL?.certificate
|
||||
server.password = updatedServer.clientSSL?.password
|
||||
server.version = updatedServer.version
|
||||
} else {
|
||||
Server(
|
||||
context: viewContext,
|
||||
iconURL: updatedServer.iconURL,
|
||||
name: updatedServer.name,
|
||||
url: updatedServer.url,
|
||||
useRealName: updatedServer.useRealName,
|
||||
loggedUser: user(from: updatedServer.loggedUser),
|
||||
certificate: updatedServer.clientSSL?.certificate,
|
||||
password: updatedServer.clientSSL?.password,
|
||||
version: updatedServer.version
|
||||
)
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
private func user(from updatedUser: WatchMessage.Server.LoggedUser) -> LoggedUser {
|
||||
if let user = user(id: updatedUser.id) {
|
||||
user.name = updatedUser.name
|
||||
user.username = updatedUser.username
|
||||
user.token = updatedUser.token
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
return LoggedUser(
|
||||
context: viewContext,
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
token: updatedUser.token,
|
||||
username: updatedUser.username
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func removeTrailingSlash() -> String {
|
||||
var url = self
|
||||
if (url.last == "/") {
|
||||
url.removeLast()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="23C64" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="LoggedUser" representedClassName=".LoggedUser" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
<relationship name="server" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Server" inverseName="loggedUser" inverseEntity="Server"/>
|
||||
</entity>
|
||||
<entity name="Server" representedClassName=".Server" syncable="YES">
|
||||
<attribute name="certificate" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="iconURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="password" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="useRealName" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="version" optional="YES" attributeType="String"/>
|
||||
<relationship name="loggedUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoggedUser" inverseName="server" inverseEntity="LoggedUser"/>
|
||||
</entity>
|
||||
</model>
|
|
@ -0,0 +1,7 @@
|
|||
import CoreData
|
||||
|
||||
extension Attachment {
|
||||
var aspectRatio: Double {
|
||||
return width / height
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import CoreData
|
||||
|
||||
@objc
|
||||
public final class LoggedUser: NSManagedObject {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<LoggedUser> {
|
||||
NSFetchRequest<LoggedUser>(entityName: "LoggedUser")
|
||||
}
|
||||
|
||||
@NSManaged public var id: String
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var token: String
|
||||
@NSManaged public var username: String
|
||||
|
||||
@available(*, unavailable)
|
||||
init() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
init(context: NSManagedObjectContext) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public init(
|
||||
context: NSManagedObjectContext,
|
||||
id: String,
|
||||
name: String,
|
||||
token: String,
|
||||
username: String
|
||||
) {
|
||||
let entity = NSEntityDescription.entity(forEntityName: "LoggedUser", in: context)!
|
||||
super.init(entity: entity, insertInto: context)
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.token = token
|
||||
self.username = username
|
||||
}
|
||||
}
|
||||
|
||||
extension LoggedUser: Identifiable {
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import CoreData
|
||||
|
||||
extension Room {
|
||||
var messagesRequest: NSFetchRequest<Message> {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
request.predicate = NSPredicate(format: "room == %@", self)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: true)]
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
var lastMessage: Message? {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
let thisRoomPredicate = NSPredicate(format: "room == %@", self)
|
||||
let nonInfoMessagePredicate = NSPredicate(format: "t == nil", self)
|
||||
request.predicate = NSCompoundPredicate(
|
||||
andPredicateWithSubpredicates: [thisRoomPredicate, nonInfoMessagePredicate]
|
||||
)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: false)]
|
||||
request.fetchLimit = 1
|
||||
|
||||
return try? managedObjectContext?.fetch(request).first
|
||||
}
|
||||
|
||||
var firstMessage: Message? {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
request.predicate = NSPredicate(format: "room == %@", self)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: true)]
|
||||
request.fetchLimit = 1
|
||||
|
||||
return try? managedObjectContext?.fetch(request).first
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import CoreData
|
||||
|
||||
@objc
|
||||
public final class Server: NSManagedObject {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Server> {
|
||||
NSFetchRequest<Server>(entityName: "Server")
|
||||
}
|
||||
|
||||
@NSManaged public var iconURL: URL
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var updatedSince: Date?
|
||||
@NSManaged public var url: URL
|
||||
@NSManaged public var useRealName: Bool
|
||||
@NSManaged public var loggedUser: LoggedUser
|
||||
@NSManaged public var certificate: Data?
|
||||
@NSManaged public var password: String?
|
||||
@NSManaged public var version: String
|
||||
|
||||
lazy var database: Database = RocketChatDatabase(server: self)
|
||||
|
||||
@available(*, unavailable)
|
||||
init() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
init(context: NSManagedObjectContext) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public init(
|
||||
context: NSManagedObjectContext,
|
||||
iconURL: URL,
|
||||
name: String,
|
||||
updatedSince: Date? = nil,
|
||||
url: URL,
|
||||
useRealName: Bool,
|
||||
loggedUser: LoggedUser,
|
||||
certificate: Data? = nil,
|
||||
password: String? = nil,
|
||||
version: String
|
||||
) {
|
||||
let entity = NSEntityDescription.entity(forEntityName: "Server", in: context)!
|
||||
super.init(entity: entity, insertInto: context)
|
||||
self.iconURL = iconURL
|
||||
self.name = name
|
||||
self.updatedSince = updatedSince
|
||||
self.url = url
|
||||
self.useRealName = useRealName
|
||||
self.loggedUser = loggedUser
|
||||
self.certificate = certificate
|
||||
self.password = password
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
|
||||
extension Server: Identifiable {
|
||||
|
||||
}
|
||||
|
||||
extension Server {
|
||||
var roomsRequest: NSFetchRequest<Room> {
|
||||
let request = Room.fetchRequest()
|
||||
|
||||
let nonArchived = NSPredicate(format: "archived == false")
|
||||
let open = NSPredicate(format: "open == true")
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [nonArchived, open])
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Room.ts, ascending: false)]
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import CoreData
|
||||
|
||||
final class AttachmentModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func upsert(_ newAttachment: MergedRoom.Message.Attachment) -> Attachment? {
|
||||
let identifier = newAttachment.imageURL ?? newAttachment.audioURL
|
||||
|
||||
guard let identifier = identifier?.absoluteString ?? newAttachment.title else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let attachment = attachment(id: identifier, in: context)
|
||||
|
||||
attachment.imageURL = newAttachment.imageURL
|
||||
attachment.msg = newAttachment.description
|
||||
attachment.width = newAttachment.dimensions?.width ?? 0
|
||||
attachment.height = newAttachment.dimensions?.height ?? 0
|
||||
|
||||
return attachment
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentModel {
|
||||
private func attachment(id: String, in context: NSManagedObjectContext) -> Attachment {
|
||||
let request = Attachment.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let attachment = try? context.fetch(request).first else {
|
||||
let attachment = Attachment(context: context)
|
||||
attachment.id = id
|
||||
|
||||
return attachment
|
||||
}
|
||||
|
||||
return attachment
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import CoreData
|
||||
|
||||
final class MessageModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func upsert(_ newMessage: MergedRoom.Message) -> Message {
|
||||
let attachmentDatabase = AttachmentModel(context: context)
|
||||
let userDatabase = UserModel(context: context)
|
||||
|
||||
let user = userDatabase.upsert(newMessage.u)
|
||||
let message = message(id: newMessage._id, in: context)
|
||||
|
||||
message.status = "received"
|
||||
message.id = newMessage._id
|
||||
message.msg = newMessage.msg
|
||||
message.ts = newMessage.ts
|
||||
message.t = newMessage.t
|
||||
message.groupable = newMessage.groupable ?? true
|
||||
message.editedAt = newMessage.editedAt
|
||||
message.role = newMessage.role
|
||||
message.comment = newMessage.comment
|
||||
message.user = user
|
||||
|
||||
if let messageAttachments = newMessage.attachments {
|
||||
for newAttachment in messageAttachments {
|
||||
let attachment = attachmentDatabase.upsert(newAttachment)
|
||||
|
||||
attachment?.message = message
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
func fetch(id: String) -> Message? {
|
||||
let request = Message.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageModel {
|
||||
private func message(id: String, in context: NSManagedObjectContext) -> Message {
|
||||
let request = Message.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let message = try? context.fetch(request).first else {
|
||||
let message = Message(context: context)
|
||||
message.id = id
|
||||
message.ts = Date()
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import CoreData
|
||||
|
||||
final class RoomModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsert(_ newRoom: MergedRoom) -> Room {
|
||||
let room = room(id: newRoom.id, in: context)
|
||||
|
||||
room.name = newRoom.name ?? room.name
|
||||
room.fname = newRoom.fname ?? room.fname
|
||||
room.t = newRoom.t
|
||||
room.unread = Int32(newRoom.unread)
|
||||
room.alert = newRoom.alert
|
||||
room.lr = newRoom.lr ?? room.lr
|
||||
room.open = newRoom.open ?? true
|
||||
room.rid = newRoom.rid
|
||||
room.hideUnreadStatus = newRoom.hideUnreadStatus ?? room.hideUnreadStatus
|
||||
|
||||
room.updatedAt = newRoom.updatedAt ?? room.updatedAt
|
||||
room.usernames = newRoom.usernames ?? room.usernames
|
||||
room.uids = newRoom.uids ?? room.uids
|
||||
room.prid = newRoom.prid ?? room.prid
|
||||
room.isReadOnly = newRoom.isReadOnly ?? room.isReadOnly
|
||||
room.encrypted = newRoom.encrypted ?? room.encrypted
|
||||
room.teamMain = newRoom.teamMain ?? room.teamMain
|
||||
room.archived = newRoom.archived ?? room.archived
|
||||
room.broadcast = newRoom.broadcast ?? room.broadcast
|
||||
room.ts = newRoom.ts ?? room.ts
|
||||
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
|
||||
if let lastMessage = newRoom.lastMessage {
|
||||
let message = messageDatabase.upsert(lastMessage)
|
||||
message.room = room
|
||||
}
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
func delete(_ room: Room) {
|
||||
context.delete(room)
|
||||
}
|
||||
|
||||
func fetch(ids: [String]) -> [Room] {
|
||||
let request = Room.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id IN %@", ids)
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetch(id: String) -> Room {
|
||||
room(id: id, in: context)
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomModel {
|
||||
private func room(id: String, in context: NSManagedObjectContext) -> Room {
|
||||
let request = Room.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let room = try? context.fetch(request).first else {
|
||||
let room = Room(context: context)
|
||||
room.id = id
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
private func room(rid: String, in context: NSManagedObjectContext) -> Room {
|
||||
let request = Room.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "rid == %@", rid)
|
||||
|
||||
guard let room = try? context.fetch(request).first else {
|
||||
let room = Room(context: context)
|
||||
room.rid = rid
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
return room
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import CoreData
|
||||
|
||||
final class UserModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func upsert(_ newUser: MergedRoom.Message.User) -> User {
|
||||
let user = user(id: newUser._id, in: context)
|
||||
user.name = newUser.name
|
||||
user.username = newUser.username
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func fetch(id: String) -> User {
|
||||
user(id: id, in: context)
|
||||
}
|
||||
}
|
||||
|
||||
extension UserModel {
|
||||
private func user(id: String, in context: NSManagedObjectContext) -> User {
|
||||
let request = User.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let user = try? context.fetch(request).first else {
|
||||
let user = User(context: context)
|
||||
user.id = id
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="23C64" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Attachment" representedClassName="Attachment" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="height" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="imageURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="msg" optional="YES" attributeType="String"/>
|
||||
<attribute name="width" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<relationship name="message" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Message" inverseName="attachments" inverseEntity="Message"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Message" representedClassName="Message" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="comment" optional="YES" attributeType="String"/>
|
||||
<attribute name="editedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="groupable" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="msg" optional="YES" attributeType="String"/>
|
||||
<attribute name="role" optional="YES" attributeType="String"/>
|
||||
<attribute name="status" optional="YES" attributeType="String"/>
|
||||
<attribute name="t" optional="YES" attributeType="String"/>
|
||||
<attribute name="ts" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="attachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="message" inverseEntity="Attachment"/>
|
||||
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Room" inverseName="messages" inverseEntity="Room"/>
|
||||
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="User" inverseName="messages" inverseEntity="User"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Room" representedClassName="Room" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="alert" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="archived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="broadcast" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="encrypted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="fname" optional="YES" attributeType="String"/>
|
||||
<attribute name="hasMoreMessages" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="hideUnreadStatus" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="isReadOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="lr" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="open" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="prid" optional="YES" attributeType="String"/>
|
||||
<attribute name="rid" optional="YES" attributeType="String"/>
|
||||
<attribute name="synced" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="t" optional="YES" attributeType="String"/>
|
||||
<attribute name="teamMain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="ts" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="uids" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||
<attribute name="unread" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="updatedSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="usernames" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||
<relationship name="messages" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Message" inverseName="room" inverseEntity="Message"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
<constraint value="rid"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="User" representedClassName="User" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
<relationship name="messages" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Message" inverseName="user" inverseEntity="Message"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
</model>
|
|
@ -0,0 +1,316 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
|
||||
protocol Database {
|
||||
var viewContext: NSManagedObjectContext { get }
|
||||
func has(context: NSManagedObjectContext) -> Bool
|
||||
|
||||
func room(id: String) -> Room?
|
||||
func room(rid: String) -> Room?
|
||||
func remove(_ message: Message)
|
||||
|
||||
func handleRoomsResponse(_ subscriptionsResponse: SubscriptionsResponse, _ roomsResponse: RoomsResponse)
|
||||
func handleHistoryResponse(_ historyResponse: HistoryResponse, in roomID: String)
|
||||
func handleMessagesResponse(_ messagesResponse: MessagesResponse, in roomID: String, newUpdatedSince: Date)
|
||||
func handleSendMessageResponse(_ sendMessageResponse: SendMessageResponse, in roomID: String)
|
||||
func handleSendMessageRequest(_ newMessage: MergedRoom.Message, in roomID: String)
|
||||
func handleReadResponse(_ readResponse: ReadResponse, in roomID: String)
|
||||
func handleSendMessageError(_ messageID: String)
|
||||
|
||||
func remove()
|
||||
}
|
||||
|
||||
final class RocketChatDatabase: Database {
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
func has(context: NSManagedObjectContext) -> Bool {
|
||||
context == backgroundContext
|
||||
}
|
||||
|
||||
private static let model: NSManagedObjectModel = {
|
||||
guard let url = Bundle.main.url(forResource: "RocketChat", withExtension: "momd"),
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
|
||||
fatalError("Can't find Core Data Model")
|
||||
}
|
||||
|
||||
return managedObjectModel
|
||||
}()
|
||||
|
||||
private lazy var container: NSPersistentContainer = {
|
||||
let name = server.url.host ?? "default"
|
||||
|
||||
let container = NSPersistentContainer(name: name, managedObjectModel: Self.model)
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error { fatalError("Can't load persistent stores: \(error)") }
|
||||
}
|
||||
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
|
||||
return container
|
||||
}()
|
||||
|
||||
private lazy var backgroundContext = container.newBackgroundContext()
|
||||
|
||||
func remove(_ message: Message) {
|
||||
viewContext.delete(message)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
func room(id: String) -> Room? {
|
||||
let request = Room.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func room(rid: String) -> Room? {
|
||||
let request = Room.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "rid == %@", rid)
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func remove() {
|
||||
guard let url = container.persistentStoreDescriptions.first?.url else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try container.persistentStoreCoordinator.destroyPersistentStore(at: url, type: .sqlite)
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RocketChatDatabase {
|
||||
func handleReadResponse(_ readResponse: ReadResponse, in roomID: String) {
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
|
||||
let room = roomDatabase.fetch(id: roomID)
|
||||
|
||||
room.alert = false
|
||||
room.unread = 0
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSendMessageError(_ messageID: String) {
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
|
||||
if let message = messageDatabase.fetch(id: messageID) {
|
||||
message.status = "error"
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSendMessageRequest(_ newMessage: MergedRoom.Message, in roomID: String) {
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
|
||||
let room = roomDatabase.fetch(id: roomID)
|
||||
|
||||
let message = messageDatabase.upsert(newMessage)
|
||||
|
||||
message.status = "temp"
|
||||
message.room = room
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSendMessageResponse(_ sendMessageResponse: SendMessageResponse, in roomID: String) {
|
||||
let message = sendMessageResponse.message
|
||||
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
|
||||
let room = roomDatabase.fetch(id: roomID)
|
||||
|
||||
if let newMessage = MergedRoom.Message(from: message) {
|
||||
let message = messageDatabase.upsert(newMessage)
|
||||
|
||||
message.room = room
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleMessagesResponse(_ messagesResponse: MessagesResponse, in roomID: String, newUpdatedSince: Date) {
|
||||
let messages = messagesResponse.result.updated
|
||||
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
|
||||
let room = roomDatabase.fetch(id: roomID)
|
||||
|
||||
for message in messages {
|
||||
if let newMessage = MergedRoom.Message(from: message) {
|
||||
let message = messageDatabase.upsert(newMessage)
|
||||
|
||||
message.room = room
|
||||
}
|
||||
}
|
||||
|
||||
room.updatedSince = newUpdatedSince
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleHistoryResponse(_ historyResponse: HistoryResponse, in roomID: String) {
|
||||
let messages = historyResponse.messages
|
||||
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let messageDatabase = MessageModel(context: context)
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
|
||||
let room = roomDatabase.fetch(id: roomID)
|
||||
|
||||
room.hasMoreMessages = messages.count == HISTORY_MESSAGE_COUNT
|
||||
room.synced = true
|
||||
|
||||
for message in historyResponse.messages {
|
||||
if let newMessage = MergedRoom.Message(from: message) {
|
||||
let message = messageDatabase.upsert(newMessage)
|
||||
|
||||
message.room = room
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRoomsResponse(_ subscriptionsResponse: SubscriptionsResponse, _ roomsResponse: RoomsResponse) {
|
||||
let rooms = roomsResponse.update
|
||||
let subscriptions = subscriptionsResponse.update
|
||||
|
||||
backgroundContext.performBackgroundTask { context in
|
||||
let roomDatabase = RoomModel(context: context)
|
||||
|
||||
let roomIds = rooms.filter { room in !subscriptions.contains { room._id == $0.rid } }.map { $0._id }
|
||||
|
||||
let existingSubs = roomDatabase.fetch(ids: roomIds)
|
||||
let mappedExistingSubs = subscriptions + existingSubs.compactMap { $0.response }
|
||||
|
||||
let mergedSubscriptions = mappedExistingSubs.compactMap { subscription in
|
||||
let index = rooms.firstIndex { $0._id == subscription.rid }
|
||||
|
||||
guard let index else {
|
||||
return MergedRoom(subscription, nil)
|
||||
}
|
||||
|
||||
let room = rooms[index]
|
||||
return MergedRoom(subscription, room)
|
||||
}
|
||||
|
||||
let subsIds = mergedSubscriptions.compactMap { $0.id } + subscriptionsResponse.remove.compactMap { $0._id }
|
||||
|
||||
if subsIds.count > 0 {
|
||||
let existingSubscriptions = roomDatabase.fetch(ids: subsIds)
|
||||
let subsToUpdate = existingSubscriptions.filter { subscription in mergedSubscriptions.contains { subscription.id == $0.id } }
|
||||
let subsToCreate = mergedSubscriptions.filter { subscription in !existingSubscriptions.contains { subscription.id == $0.id } }
|
||||
let subsToDelete = existingSubscriptions.filter { subscription in !mergedSubscriptions.contains { subscription.id == $0.id } }
|
||||
|
||||
subsToCreate.forEach { subscription in
|
||||
roomDatabase.upsert(subscription)
|
||||
}
|
||||
|
||||
subsToUpdate.forEach { subscription in
|
||||
if let newRoom = mergedSubscriptions.first(where: { $0.id == subscription.id }) {
|
||||
roomDatabase.upsert(newRoom)
|
||||
}
|
||||
}
|
||||
|
||||
subsToDelete.forEach { subscription in
|
||||
roomDatabase.delete(subscription)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Room {
|
||||
var response: SubscriptionsResponse.Subscription? {
|
||||
guard let id, let fname, let t, let rid else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .init(
|
||||
_id: id,
|
||||
rid: rid,
|
||||
name: name,
|
||||
fname: fname,
|
||||
t: t,
|
||||
unread: Int(unread),
|
||||
alert: alert,
|
||||
lr: lr,
|
||||
open: open,
|
||||
_updatedAt: ts,
|
||||
hideUnreadStatus: hideUnreadStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
|
||||
perform {
|
||||
block(self)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
@propertyWrapper
|
||||
struct Dependency<T> {
|
||||
private var dependency: T
|
||||
|
||||
init() {
|
||||
guard let dependency = Store.resolve(T.self) else {
|
||||
fatalError("No service of type \(T.self) registered!")
|
||||
}
|
||||
|
||||
self.dependency = dependency
|
||||
}
|
||||
|
||||
var wrappedValue: T {
|
||||
get {
|
||||
dependency
|
||||
}
|
||||
mutating set {
|
||||
dependency = newValue
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
|
||||
protocol StoreInterface {
|
||||
static func register<T>(_ type: T.Type, factory: @autoclosure @escaping () -> T)
|
||||
static func resolve<T>(_ type: T.Type) -> T?
|
||||
}
|
||||
|
||||
final class Store: StoreInterface {
|
||||
private static var factories: [ObjectIdentifier: () -> Any] = [:]
|
||||
private static var cache: [ObjectIdentifier: WeakRef<AnyObject>] = [:]
|
||||
|
||||
static func register<T>(_ type: T.Type, factory: @autoclosure @escaping () -> T) {
|
||||
let identifier = ObjectIdentifier(type)
|
||||
factories[identifier] = factory
|
||||
cache[identifier] = nil
|
||||
}
|
||||
|
||||
static func resolve<T>(_ type: T.Type) -> T? {
|
||||
let identifier = ObjectIdentifier(type)
|
||||
|
||||
if let dependency = cache[identifier]?.value {
|
||||
return dependency as? T
|
||||
} else {
|
||||
let dependency = factories[identifier]?() as? T
|
||||
|
||||
if let dependency {
|
||||
cache[identifier] = WeakRef(value: dependency as AnyObject)
|
||||
}
|
||||
|
||||
return dependency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class WeakRef<T: AnyObject> {
|
||||
private(set) weak var value: T?
|
||||
|
||||
init(value: T) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import WatchKit
|
||||
import UserNotifications
|
||||
|
||||
final class ExtensionDelegate: NSObject, WKApplicationDelegate {
|
||||
let router = AppRouter()
|
||||
let database = DefaultDatabase()
|
||||
|
||||
func applicationDidFinishLaunching() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
extension ExtensionDelegate: UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
let ejson = userInfo["ejson"] as? String
|
||||
let data = ejson?.data(using: .utf8)
|
||||
|
||||
guard let response = try? data?.decode(NotificationResponse.self) else { return }
|
||||
|
||||
deeplink(from: response)
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
extension ExtensionDelegate {
|
||||
private func deeplink(from response: NotificationResponse) {
|
||||
guard let server = database.server(url: response.host) else { return }
|
||||
guard let room = server.database.room(rid: response.rid) else { return }
|
||||
|
||||
router.route(to: [.loading, .roomList(server), .room(server, room)])
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationResponse: Codable, Hashable {
|
||||
let host: URL
|
||||
let rid: String
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Binding where Value == Bool {
|
||||
init<Wrapped>(bindingOptional: Binding<Wrapped?>) {
|
||||
self.init(
|
||||
get: {
|
||||
bindingOptional.wrappedValue != nil
|
||||
},
|
||||
set: { newValue in
|
||||
guard newValue == false else { return }
|
||||
|
||||
/// We only handle `false` booleans to set our optional to `nil`
|
||||
/// as we can't handle `true` for restoring the previous value.
|
||||
bindingOptional.wrappedValue = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Binding {
|
||||
func mappedToBool<Wrapped>() -> Binding<Bool> where Value == Wrapped? {
|
||||
Binding<Bool>(bindingOptional: self)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
static var titleLabels: Color {
|
||||
Color(hex: 0xF2F3F5)
|
||||
}
|
||||
|
||||
static var `default`: Color {
|
||||
Color(hex: 0xE4E7EA)
|
||||
}
|
||||
|
||||
static var secondaryInfo: Color {
|
||||
Color(hex: 0x9EA2A8)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
init(hex: UInt, alpha: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xff) / 255,
|
||||
green: Double((hex >> 08) & 0xff) / 255,
|
||||
blue: Double((hex >> 00) & 0xff) / 255,
|
||||
opacity: alpha
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
extension Date {
|
||||
static func - (lhs: Date, rhs: Date) -> TimeInterval {
|
||||
return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import SwiftUI
|
||||
|
||||
extension ToolbarItemPlacement {
|
||||
static var `default`: Self {
|
||||
if #available(watchOS 10.0, *) {
|
||||
return .topBarLeading
|
||||
} else {
|
||||
return .automatic
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
import SwiftUI
|
||||
|
||||
struct InfoMessage {
|
||||
let msg: String
|
||||
let username: String
|
||||
let type: String
|
||||
let role: String
|
||||
let comment: String
|
||||
}
|
||||
|
||||
func getInfoMessage(_ infoMessage: InfoMessage) -> LocalizedStringKey {
|
||||
switch infoMessage.type {
|
||||
case "rm":
|
||||
return "message removed"
|
||||
case "uj":
|
||||
return "joined the channel"
|
||||
case "ujt":
|
||||
return "joined this team"
|
||||
case "ut":
|
||||
return "joined the conversation"
|
||||
case "r":
|
||||
return "changed room name to: \(infoMessage.msg)"
|
||||
case "ru":
|
||||
return "removed \(infoMessage.msg)"
|
||||
case "au":
|
||||
return "added \(infoMessage.msg)"
|
||||
case "user-muted":
|
||||
return "muted \(infoMessage.msg)"
|
||||
case "room_changed_description":
|
||||
return "changed room description to: \(infoMessage.msg)"
|
||||
case "room_changed_announcement":
|
||||
return "changed room announcement to: \(infoMessage.msg)"
|
||||
case "room_changed_topic":
|
||||
return "changed room topic to: \(infoMessage.msg)"
|
||||
case "room_changed_privacy":
|
||||
return "changed room to \(infoMessage.msg)"
|
||||
case "room_changed_avatar":
|
||||
return "changed room avatar"
|
||||
case "message_snippeted":
|
||||
return "created a snippet"
|
||||
case "room_e2e_disabled":
|
||||
return "disabled E2E encryption for this room"
|
||||
case "room_e2e_enabled":
|
||||
return "enabled E2E encryption for this room"
|
||||
case "removed-user-from-team":
|
||||
return "removed @\(infoMessage.msg) from this team"
|
||||
case "added-user-to-team":
|
||||
return "added @\(infoMessage.msg) to this team"
|
||||
case "user-added-room-to-team":
|
||||
return "added #\(infoMessage.msg) to this team"
|
||||
case "user-converted-to-team":
|
||||
return "converted #\(infoMessage.msg) to a team"
|
||||
case "user-converted-to-channel":
|
||||
return "converted #\(infoMessage.msg) to channel"
|
||||
case "user-deleted-room-from-team":
|
||||
return "deleted #\(infoMessage.msg)"
|
||||
case "user-removed-room-from-team":
|
||||
return "removed #\(infoMessage.msg) from this team"
|
||||
case "room-disallowed-reacting":
|
||||
return "disallowed reactions"
|
||||
case "room-allowed-reacting":
|
||||
return "allowed reactions"
|
||||
case "room-set-read-only":
|
||||
return "set room to read only"
|
||||
case "room-removed-read-only":
|
||||
return "removed read only permission"
|
||||
case "user-unmuted":
|
||||
return "unmuted \(infoMessage.msg)"
|
||||
case "room-archived":
|
||||
return "archived room"
|
||||
case "room-unarchived":
|
||||
return "unarchived room"
|
||||
case "subscription-role-added":
|
||||
return "defined \(infoMessage.msg) as \(infoMessage.role)"
|
||||
case "subscription-role-removed":
|
||||
return "removed \(infoMessage.msg) as \(infoMessage.role)"
|
||||
case "message_pinned":
|
||||
return "Pinned a message:"
|
||||
case "ul":
|
||||
return "left the channel"
|
||||
case "ult":
|
||||
return "has left the team"
|
||||
case "jitsi_call_started":
|
||||
return "Call started by \(infoMessage.username)"
|
||||
case "omnichannel_placed_chat_on_hold":
|
||||
return "Chat on hold: \(infoMessage.comment)"
|
||||
case "omnichannel_on_hold_chat_resumed":
|
||||
return "On hold chat resumed: \(infoMessage.comment)"
|
||||
case "command":
|
||||
return "returned the chat to the queue"
|
||||
case "livechat-started":
|
||||
return "Chat started"
|
||||
case "livechat-close":
|
||||
return "Conversation closed"
|
||||
case "livechat_transfer_history":
|
||||
return "New chat transfer: \(infoMessage.username) returned the chat to the queue"
|
||||
default:
|
||||
return "Unsupported system message"
|
||||
}
|
||||
}
|
||||
|
||||
func messageHaveAuthorName(_ messageType: String) -> Bool {
|
||||
messagesWithAuthorName.contains(messageType)
|
||||
}
|
||||
|
||||
extension InfoMessage {
|
||||
init(from message: Message) {
|
||||
self.init(
|
||||
msg: message.msg ?? "",
|
||||
username: message.user?.username ?? "",
|
||||
type: message.t ?? "",
|
||||
role: message.role ?? "",
|
||||
comment: message.comment ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let messagesWithAuthorName: Set<String> = [
|
||||
"r",
|
||||
"ru",
|
||||
"au",
|
||||
"rm",
|
||||
"uj",
|
||||
"ujt",
|
||||
"ut",
|
||||
"ul",
|
||||
"ult",
|
||||
"message_pinned",
|
||||
"message_snippeted",
|
||||
"removed-user-from-team",
|
||||
"added-user-to-team",
|
||||
"user-added-room-to-team",
|
||||
"user-converted-to-team",
|
||||
"user-converted-to-channel",
|
||||
"user-deleted-room-from-team",
|
||||
"user-removed-room-from-team",
|
||||
"omnichannel_placed_chat_on_hold",
|
||||
"omnichannel_on_hold_chat_resumed",
|
||||
"livechat_navigation_history",
|
||||
"livechat_transcript_history",
|
||||
"command",
|
||||
"livechat-started",
|
||||
"livechat-close",
|
||||
"livechat_video_call",
|
||||
"livechat_webrtc_video_call",
|
||||
"livechat_transfer_history",
|
||||
"room-archived",
|
||||
"room-unarchived",
|
||||
"user-muted",
|
||||
"room_changed_description",
|
||||
"room_changed_announcement",
|
||||
"room_changed_topic",
|
||||
"room_changed_privacy",
|
||||
"room_changed_avatar",
|
||||
"room_e2e_disabled",
|
||||
"room_e2e_enabled",
|
||||
"room-allowed-reacting",
|
||||
"room-disallowed-reacting",
|
||||
"room-set-read-only",
|
||||
"room-removed-read-only",
|
||||
"user-unmuted",
|
||||
"room-unarchived",
|
||||
"subscription-role-added",
|
||||
"subscription-role-removed"
|
||||
]
|
|
@ -0,0 +1,88 @@
|
|||
import SwiftUI
|
||||
|
||||
final class MessageFormatter {
|
||||
private let message: Message
|
||||
private let previousMessage: Message?
|
||||
private let lastOpen: Date?
|
||||
|
||||
init(message: Message, previousMessage: Message?, lastOpen: Date?) {
|
||||
self.message = message
|
||||
self.previousMessage = previousMessage
|
||||
self.lastOpen = lastOpen
|
||||
}
|
||||
|
||||
func hasDateSeparator() -> Bool {
|
||||
if let previousMessage,
|
||||
let previousMessageTS = previousMessage.ts,
|
||||
let messageTS = message.ts,
|
||||
Calendar.current.isDate(previousMessageTS, inSameDayAs: messageTS) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUnreadSeparator() -> Bool {
|
||||
guard let messageTS = message.ts, let lastOpen else {
|
||||
return false
|
||||
}
|
||||
|
||||
if previousMessage == nil {
|
||||
return messageTS > lastOpen
|
||||
} else if let previousMessage, let previousMessageTS = previousMessage.ts {
|
||||
return messageTS >= lastOpen && previousMessageTS < lastOpen
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isHeader() -> Bool {
|
||||
if let previousMessage,
|
||||
let previousMessageTS = previousMessage.ts,
|
||||
let messageTS = message.ts,
|
||||
Calendar.current.isDate(previousMessageTS, inSameDayAs: messageTS),
|
||||
previousMessage.user?.username == message.user?.username,
|
||||
!(previousMessage.groupable == false || message.groupable == false || message.room?.broadcast == true),
|
||||
messageTS - previousMessageTS < 300,
|
||||
message.t != "rm",
|
||||
previousMessage.t != "rm" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func info() -> LocalizedStringKey? {
|
||||
switch message.t {
|
||||
case .some:
|
||||
return getInfoMessage(.init(from: message))
|
||||
case .none:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func date() -> String? {
|
||||
guard let ts = message.ts else { return nil }
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.locale = Locale.current
|
||||
dateFormatter.timeZone = TimeZone.current
|
||||
dateFormatter.timeStyle = .none
|
||||
dateFormatter.dateStyle = .long
|
||||
|
||||
return dateFormatter.string(from: ts)
|
||||
}
|
||||
|
||||
func time() -> String? {
|
||||
guard let ts = message.ts else { return nil }
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.locale = Locale.current
|
||||
dateFormatter.timeZone = TimeZone.current
|
||||
dateFormatter.timeStyle = .short
|
||||
dateFormatter.dateStyle = .none
|
||||
|
||||
return dateFormatter.string(from: ts)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import Foundation
|
||||
|
||||
final class RoomFormatter {
|
||||
private let room: Room
|
||||
private let server: Server
|
||||
|
||||
init(room: Room, server: Server) {
|
||||
self.room = room
|
||||
self.server = server
|
||||
}
|
||||
|
||||
var title: String? {
|
||||
if isGroupChat, !(room.name != nil && room.name?.isEmpty == false), let usernames = room.usernames {
|
||||
return usernames
|
||||
.filter { $0 == server.loggedUser.username }
|
||||
.sorted()
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
if room.t != "d" {
|
||||
return room.fname ?? room.name
|
||||
}
|
||||
|
||||
if room.prid != nil || server.useRealName {
|
||||
return room.fname ?? room.name
|
||||
}
|
||||
|
||||
return room.name
|
||||
}
|
||||
|
||||
var isGroupChat: Bool {
|
||||
if let uids = room.uids, uids.count > 2 {
|
||||
return true
|
||||
}
|
||||
|
||||
if let usernames = room.usernames, usernames.count > 2 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import Combine
|
||||
|
||||
typealias CancelBag = Set<AnyCancellable>
|
||||
|
||||
extension CancelBag {
|
||||
mutating func cancelAll() {
|
||||
forEach { $0.cancel() }
|
||||
removeAll()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class ImageLoader: ObservableObject {
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
|
||||
@Published private(set) var image: UIImage?
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
private let url: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancel()
|
||||
}
|
||||
|
||||
func load() {
|
||||
cancellable = client.session.dataTaskPublisher(for: url)
|
||||
.map { UIImage(data: $0.data) }
|
||||
.replaceError(with: nil)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in self?.image = $0 }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import Foundation
|
||||
|
||||
struct MergedRoom {
|
||||
let id: String
|
||||
let name: String?
|
||||
let fname: String?
|
||||
let t: String
|
||||
let unread: Int
|
||||
let alert: Bool
|
||||
let lr: Date?
|
||||
let open: Bool?
|
||||
let rid: String
|
||||
let hideUnreadStatus: Bool?
|
||||
|
||||
let archived: Bool?
|
||||
let broadcast: Bool?
|
||||
let encrypted: Bool?
|
||||
let isReadOnly: Bool?
|
||||
let prid: String?
|
||||
let teamMain: Bool?
|
||||
let ts: Date?
|
||||
let uids: [String]?
|
||||
let updatedAt: Date?
|
||||
let usernames: [String]?
|
||||
let lastMessage: Message?
|
||||
let lm: Date?
|
||||
|
||||
struct Message {
|
||||
let _id: String
|
||||
let rid: String
|
||||
let msg: String
|
||||
let u: User
|
||||
let ts: Date
|
||||
let attachments: [Attachment]?
|
||||
let t: String?
|
||||
let groupable: Bool?
|
||||
let editedAt: Date?
|
||||
let role: String?
|
||||
let comment: String?
|
||||
|
||||
struct User {
|
||||
let _id: String
|
||||
let username: String?
|
||||
let name: String?
|
||||
}
|
||||
|
||||
struct Attachment {
|
||||
let title: String?
|
||||
let imageURL: URL?
|
||||
let audioURL: URL?
|
||||
let description: String?
|
||||
let dimensions: Dimensions?
|
||||
|
||||
struct Dimensions {
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
extension MergedRoom {
|
||||
init(_ subscription: SubscriptionsResponse.Subscription, _ room: RoomsResponse.Room?) {
|
||||
id = subscription._id
|
||||
name = subscription.name ?? room?.fname
|
||||
fname = subscription.fname
|
||||
t = subscription.t
|
||||
unread = subscription.unread
|
||||
alert = subscription.alert
|
||||
lr = subscription.lr
|
||||
open = subscription.open
|
||||
rid = subscription.rid
|
||||
hideUnreadStatus = subscription.hideUnreadStatus
|
||||
|
||||
if let room {
|
||||
if room._updatedAt != nil {
|
||||
updatedAt = room._updatedAt
|
||||
lastMessage = .init(from: room.lastMessage?.value)
|
||||
archived = room.archived ?? false
|
||||
usernames = room.usernames
|
||||
uids = room.uids
|
||||
} else {
|
||||
updatedAt = nil
|
||||
lastMessage = nil
|
||||
archived = nil
|
||||
usernames = nil
|
||||
uids = nil
|
||||
}
|
||||
|
||||
let lastRoomUpdate = room.lm ?? room.ts ?? subscription._updatedAt
|
||||
|
||||
if let lr = subscription.lr, let lastRoomUpdate {
|
||||
ts = max(lr, lastRoomUpdate)
|
||||
} else {
|
||||
ts = lastRoomUpdate
|
||||
}
|
||||
|
||||
isReadOnly = room.ro ?? false
|
||||
broadcast = room.broadcast
|
||||
encrypted = room.encrypted
|
||||
teamMain = room.teamMain
|
||||
prid = room.prid
|
||||
lm = room.lm
|
||||
} else {
|
||||
updatedAt = nil
|
||||
lastMessage = nil
|
||||
archived = nil
|
||||
usernames = nil
|
||||
uids = nil
|
||||
ts = nil
|
||||
isReadOnly = nil
|
||||
broadcast = nil
|
||||
encrypted = nil
|
||||
teamMain = nil
|
||||
prid = nil
|
||||
lm = nil
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
extension MergedRoom.Message {
|
||||
init?(from newMessage: MessageResponse?) {
|
||||
guard let newMessage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
_id = newMessage._id
|
||||
rid = newMessage.rid
|
||||
msg = newMessage.msg
|
||||
u = .init(from: newMessage.u)
|
||||
ts = newMessage.ts
|
||||
attachments = newMessage.attachments?.map { .init(from: $0) }
|
||||
t = newMessage.t
|
||||
groupable = newMessage.groupable
|
||||
editedAt = newMessage.editedAt
|
||||
role = newMessage.role
|
||||
comment = newMessage.comment
|
||||
}
|
||||
}
|
||||
|
||||
extension MergedRoom.Message.User {
|
||||
init(from user: UserResponse) {
|
||||
_id = user._id
|
||||
username = user.username
|
||||
name = user.name
|
||||
}
|
||||
}
|
||||
|
||||
extension MergedRoom.Message.Attachment {
|
||||
init(from attachment: AttachmentResponse) {
|
||||
title = attachment.title
|
||||
imageURL = attachment.imageURL
|
||||
audioURL = attachment.audioURL
|
||||
description = attachment.description
|
||||
dimensions = .init(from: attachment.dimensions)
|
||||
}
|
||||
}
|
||||
|
||||
extension MergedRoom.Message.Attachment.Dimensions {
|
||||
init?(from dimensions: DimensionsResponse?) {
|
||||
guard let dimensions else {
|
||||
return nil
|
||||
}
|
||||
|
||||
width = dimensions.width
|
||||
height = dimensions.height
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
|
||||
protocol MessageSending {
|
||||
func sendMessage(_ msg: String, in room: Room)
|
||||
func resendMessage(message: Message, in room: Room)
|
||||
}
|
||||
|
||||
final class MessageSender {
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
@Dependency private var database: Database
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
private func sendMessageCall(_ message: MergedRoom.Message, in roomID: String) {
|
||||
database.handleSendMessageRequest(message, in: roomID)
|
||||
|
||||
client.sendMessage(id: message._id, rid: message.rid, msg: message.msg)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.subscribe(Subscribers.Sink { [weak self] completion in
|
||||
if case .failure = completion {
|
||||
self?.database.handleSendMessageError(message._id)
|
||||
}
|
||||
} receiveValue: { [weak self] messageResponse in
|
||||
self?.database.handleSendMessageResponse(messageResponse, in: roomID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSender: MessageSending {
|
||||
func sendMessage(_ msg: String, in room: Room) {
|
||||
guard let rid = room.rid, let roomID = room.id else { return }
|
||||
|
||||
let messageID = String.random(17)
|
||||
let loggedUser = server.loggedUser
|
||||
|
||||
let newMessage = MergedRoom.Message(
|
||||
_id: messageID,
|
||||
rid: rid,
|
||||
msg: msg,
|
||||
u: .init(
|
||||
_id: loggedUser.id,
|
||||
username: loggedUser.username,
|
||||
name: loggedUser.name
|
||||
),
|
||||
ts: Date(),
|
||||
attachments: nil,
|
||||
t: nil,
|
||||
groupable: true,
|
||||
editedAt: nil,
|
||||
role: nil,
|
||||
comment: nil
|
||||
)
|
||||
|
||||
sendMessageCall(newMessage, in: roomID)
|
||||
}
|
||||
|
||||
func resendMessage(message: Message, in room: Room) {
|
||||
guard let rid = room.rid, let roomID = room.id else { return }
|
||||
|
||||
guard let id = message.id, let msg = message.msg, let user = message.user, let userID = user.id else { return }
|
||||
|
||||
let newMessage = MergedRoom.Message(
|
||||
_id: id,
|
||||
rid: rid,
|
||||
msg: msg,
|
||||
u: MergedRoom.Message.User(
|
||||
_id: userID,
|
||||
username: user.username,
|
||||
name: user.name
|
||||
),
|
||||
ts: Date(),
|
||||
attachments: nil,
|
||||
t: nil,
|
||||
groupable: true,
|
||||
editedAt: nil,
|
||||
role: nil,
|
||||
comment: nil
|
||||
)
|
||||
|
||||
sendMessageCall(newMessage, in: roomID)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import CoreData
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
protocol MessagesLoading {
|
||||
func start(on roomID: String)
|
||||
func loadMore(from date: Date)
|
||||
|
||||
func stop()
|
||||
}
|
||||
|
||||
final class MessagesLoader {
|
||||
private var timer: Timer?
|
||||
private var cancellable = CancelBag()
|
||||
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var serversDB: ServersDatabase
|
||||
|
||||
private var roomID: String?
|
||||
|
||||
private func scheduledSyncMessages(in room: Room, from date: Date) {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
||||
self?.syncMessages(in: room, from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncMessages(in room: Room, from date: Date) {
|
||||
guard let rid = room.rid, let roomID = room.id else { return }
|
||||
|
||||
let newUpdatedSince = Date()
|
||||
|
||||
client.syncMessages(rid: rid, updatedSince: date)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
if case .failure = completion {
|
||||
self?.scheduledSyncMessages(in: room, from: newUpdatedSince)
|
||||
}
|
||||
} receiveValue: { [weak self] messagesResponse in
|
||||
self?.database.handleMessagesResponse(messagesResponse, in: roomID, newUpdatedSince: newUpdatedSince)
|
||||
|
||||
self?.scheduledSyncMessages(in: room, from: newUpdatedSince)
|
||||
|
||||
self?.markAsRead(in: room)
|
||||
}
|
||||
.store(in: &cancellable)
|
||||
}
|
||||
|
||||
private func loadMessages(in room: Room, from date: Date) {
|
||||
guard let rid = room.rid, let roomID = room.id else { return }
|
||||
|
||||
client.getHistory(rid: rid, t: room.t ?? "", latest: date)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
if case .failure(let error) = completion {
|
||||
print(error)
|
||||
}
|
||||
} receiveValue: { [weak self] messagesResponse in
|
||||
self?.database.handleHistoryResponse(messagesResponse, in: roomID)
|
||||
}
|
||||
.store(in: &cancellable)
|
||||
}
|
||||
|
||||
private func markAsRead(in room: Room) {
|
||||
guard (room.unread > 0 || room.alert), let rid = room.rid, let roomID = room.id else {
|
||||
return
|
||||
}
|
||||
|
||||
client.sendRead(rid: rid)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
if case .failure(let error) = completion {
|
||||
print(error)
|
||||
}
|
||||
} receiveValue: { [weak self] readResponse in
|
||||
self?.database.handleReadResponse(readResponse, in: roomID)
|
||||
}
|
||||
.store(in: &cancellable)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessagesLoader: MessagesLoading {
|
||||
func start(on roomID: String) {
|
||||
stop()
|
||||
|
||||
self.roomID = roomID
|
||||
|
||||
guard let room = database.room(id: roomID) else { return }
|
||||
|
||||
if let updatedSince = room.updatedSince {
|
||||
loadMessages(in: room, from: updatedSince)
|
||||
syncMessages(in: room, from: updatedSince)
|
||||
} else {
|
||||
loadMessages(in: room, from: .now)
|
||||
syncMessages(in: room, from: .now)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMore(from date: Date) {
|
||||
guard let roomID, let room = database.room(id: roomID) else { return }
|
||||
|
||||
loadMessages(in: room, from: date)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
cancellable.cancelAll()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
import CoreData
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
protocol RoomsLoading {
|
||||
func start()
|
||||
func stop()
|
||||
}
|
||||
|
||||
final class RoomsLoader: ObservableObject {
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var serversDB: ServersDatabase
|
||||
|
||||
@Published private(set) var state: State
|
||||
|
||||
private var timer: Timer?
|
||||
private var cancellable = CancelBag()
|
||||
|
||||
private let server: Server
|
||||
|
||||
private var shouldUpdatedDateOnce: Bool
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
self.state = server.updatedSince == nil ? .loading : .loaded
|
||||
|
||||
shouldUpdatedDateOnce = !(server.version >= "4")
|
||||
}
|
||||
|
||||
private func scheduledLoadRooms() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
||||
self?.loadRooms()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRooms() {
|
||||
let newUpdatedSince = Date()
|
||||
|
||||
let updatedSince = server.updatedSince
|
||||
|
||||
Publishers.Zip(
|
||||
client.getRooms(updatedSince: updatedSince),
|
||||
client.getSubscriptions(updatedSince: updatedSince)
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
if case .failure = completion {
|
||||
if self?.state == .loading { self?.state = .error }
|
||||
self?.scheduledLoadRooms()
|
||||
}
|
||||
} receiveValue: { roomsResponse, subscriptionsResponse in
|
||||
self.database.handleRoomsResponse(subscriptionsResponse, roomsResponse)
|
||||
self.updateServer(to: newUpdatedSince)
|
||||
self.scheduledLoadRooms()
|
||||
}
|
||||
.store(in: &cancellable)
|
||||
}
|
||||
|
||||
/// This method updates the updateSince timestamp only once in servers with versions below 4.
|
||||
///
|
||||
/// It is required due to missing events in the rooms and subscriptions
|
||||
/// requests in those old versions. We get extra information
|
||||
/// by passing a date that is older than the real updatedSince last timestamp.
|
||||
private func updateServer(to newUpdatedSince: Date) {
|
||||
if !(server.version >= "4") {
|
||||
if shouldUpdatedDateOnce {
|
||||
server.updatedSince = newUpdatedSince
|
||||
serversDB.save()
|
||||
shouldUpdatedDateOnce = false
|
||||
}
|
||||
} else {
|
||||
server.updatedSince = newUpdatedSince
|
||||
serversDB.save()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeContext() {
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [database] notification in
|
||||
if let context = notification.object as? NSManagedObjectContext {
|
||||
if database.has(context: context) {
|
||||
self.state = .loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellable)
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomsLoader: RoomsLoading {
|
||||
func start() {
|
||||
stop()
|
||||
|
||||
loadRooms()
|
||||
observeContext()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
cancellable.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomsLoader {
|
||||
enum State {
|
||||
case loaded
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static func >=(lhs: String, rhs: String) -> Bool {
|
||||
lhs.compare(rhs, options: .numeric) == .orderedDescending || lhs.compare(rhs, options: .numeric) == .orderedSame
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
enum ServersLoadingError: Error, Equatable {
|
||||
case unactive
|
||||
case unreachable
|
||||
case locked
|
||||
case undecodable(Error)
|
||||
|
||||
static func == (lhs: ServersLoadingError, rhs: ServersLoadingError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.unactive, .unactive), (.unreachable, .unreachable), (.locked, .locked), (.undecodable, .undecodable):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol ServersLoading {
|
||||
func loadServers() -> AnyPublisher<Void, ServersLoadingError>
|
||||
}
|
||||
|
||||
final class ServersLoader: NSObject {
|
||||
@Dependency private var database: ServersDatabase
|
||||
|
||||
private let session: WatchSessionProtocol
|
||||
|
||||
init(session: WatchSessionProtocol = RetriableWatchSession()) {
|
||||
self.session = session
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ServersLoading
|
||||
|
||||
extension ServersLoader: ServersLoading {
|
||||
func loadServers() -> AnyPublisher<Void, ServersLoadingError> {
|
||||
Future<Void, ServersLoadingError> { [self] promise in
|
||||
session.sendMessage { result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
for server in message.servers {
|
||||
DispatchQueue.main.async {
|
||||
self.database.process(updatedServer: server)
|
||||
}
|
||||
}
|
||||
|
||||
promise(.success(()))
|
||||
case .failure(let error):
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import WatchConnectivity
|
||||
|
||||
protocol WatchSessionProtocol {
|
||||
func sendMessage(completionHandler: @escaping (Result<WatchMessage, ServersLoadingError>) -> Void)
|
||||
}
|
||||
|
||||
/// Default WatchSession protocol implementation.
|
||||
final class WatchSession: NSObject, WatchSessionProtocol, WCSessionDelegate {
|
||||
private let session: WCSession
|
||||
|
||||
init(session: WCSession) {
|
||||
self.session = session
|
||||
super.init()
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func sendMessage(completionHandler: @escaping (Result<WatchMessage, ServersLoadingError>) -> Void) {
|
||||
guard session.activationState == .activated else {
|
||||
completionHandler(.failure(.unactive))
|
||||
return
|
||||
}
|
||||
|
||||
guard !session.iOSDeviceNeedsUnlockAfterRebootForReachability else {
|
||||
completionHandler(.failure(.locked))
|
||||
return
|
||||
}
|
||||
|
||||
guard session.isReachable else {
|
||||
completionHandler(.failure(.unreachable))
|
||||
return
|
||||
}
|
||||
|
||||
session.sendMessage([:]) { dictionary in
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dictionary)
|
||||
let message = try JSONDecoder().decode(WatchMessage.self, from: data)
|
||||
|
||||
completionHandler(.success(message))
|
||||
} catch {
|
||||
completionHandler(.failure(.undecodable(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry decorator for WatchSession protocol.
|
||||
final class RetriableWatchSession: WatchSessionProtocol {
|
||||
private let session: WatchSessionProtocol
|
||||
private let retries: Int
|
||||
|
||||
init(session: WatchSessionProtocol = DelayableWatchSession(session: WatchSession(session: .default)), retries: Int = 3) {
|
||||
self.session = session
|
||||
self.retries = retries
|
||||
}
|
||||
|
||||
func sendMessage(completionHandler: @escaping (Result<WatchMessage, ServersLoadingError>) -> Void) {
|
||||
session.sendMessage { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
completionHandler(.success(message))
|
||||
case .failure where self.retries > 0:
|
||||
self.session.sendMessage(completionHandler: completionHandler)
|
||||
case .failure(let error):
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay decorator for WatchSession protocol.
|
||||
final class DelayableWatchSession: WatchSessionProtocol {
|
||||
private let delay: TimeInterval
|
||||
private let session: WatchSessionProtocol
|
||||
|
||||
init(delay: TimeInterval = 1, session: WatchSessionProtocol) {
|
||||
self.delay = delay
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func sendMessage(completionHandler: @escaping (Result<WatchMessage, ServersLoadingError>) -> Void) {
|
||||
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.session.sendMessage(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
@main
|
||||
struct RocketChat_Watch_AppApp: App {
|
||||
@WKApplicationDelegateAdaptor var delegate: ExtensionDelegate
|
||||
|
||||
init() {
|
||||
registerDependencies()
|
||||
}
|
||||
|
||||
private func registerDependencies() {
|
||||
Store.register(AppRouting.self, factory: delegate.router)
|
||||
Store.register(ServersDatabase.self, factory: delegate.database)
|
||||
Store.register(ServersLoading.self, factory: ServersLoader())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppView(router: delegate.router)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import Foundation
|
||||
|
||||
enum StorageKey: String {
|
||||
case currentServer = "current_server"
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
struct Storage<T: Codable> {
|
||||
private let key: StorageKey
|
||||
private let defaultValue: T?
|
||||
|
||||
init(_ key: StorageKey, defaultValue: T? = nil) {
|
||||
self.key = key
|
||||
self.defaultValue = defaultValue
|
||||
}
|
||||
|
||||
var wrappedValue: T? {
|
||||
get {
|
||||
guard let data = UserDefaults.standard.object(forKey: key.rawValue) as? Data else {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
let value = try? JSONDecoder().decode(T.self, from: data)
|
||||
return value ?? defaultValue
|
||||
}
|
||||
set {
|
||||
let data = try? JSONEncoder().encode(newValue)
|
||||
|
||||
UserDefaults.standard.set(data, forKey: key.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import SwiftUI
|
||||
|
||||
final class MessageViewModel: ObservableObject {
|
||||
@Published private(set) var server: Server?
|
||||
@Published private(set) var message: Message
|
||||
@Published private(set) var previousMessage: Message?
|
||||
|
||||
private let messageFormatter: MessageFormatter
|
||||
|
||||
init(message: Message, previousMessage: Message? = nil, server: Server?, lastOpen: Date?) {
|
||||
self.message = message
|
||||
self.previousMessage = previousMessage
|
||||
self.messageFormatter = MessageFormatter(
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
lastOpen: lastOpen
|
||||
)
|
||||
self.server = server
|
||||
}
|
||||
|
||||
var sender: String? {
|
||||
server?.useRealName == true ? message.user?.name : message.user?.username
|
||||
}
|
||||
|
||||
var date: String? {
|
||||
messageFormatter.date()
|
||||
}
|
||||
|
||||
var time: String? {
|
||||
messageFormatter.time()
|
||||
}
|
||||
|
||||
var info: LocalizedStringKey? {
|
||||
messageFormatter.info()
|
||||
}
|
||||
|
||||
var hasDateSeparator: Bool {
|
||||
messageFormatter.hasDateSeparator()
|
||||
}
|
||||
|
||||
var hasUnreadSeparator: Bool {
|
||||
messageFormatter.hasUnreadSeparator()
|
||||
}
|
||||
|
||||
var isHeader: Bool {
|
||||
messageFormatter.isHeader() && !messageHaveAuthorName(message.t ?? "")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import SwiftUI
|
||||
|
||||
final class RoomViewModel: ObservableObject {
|
||||
@Published var room: Room
|
||||
@Published var server: Server
|
||||
|
||||
private let formatter: RoomFormatter
|
||||
|
||||
init(room: Room, server: Server) {
|
||||
self.room = room
|
||||
self.server = server
|
||||
self.formatter = RoomFormatter(room: room, server: server)
|
||||
}
|
||||
|
||||
var title: String? {
|
||||
formatter.title
|
||||
}
|
||||
|
||||
var iconName: String? {
|
||||
if room.prid != nil {
|
||||
return "discussions"
|
||||
} else if room.teamMain == true, room.t == "p" {
|
||||
return "teams-private"
|
||||
} else if room.teamMain == true {
|
||||
return "teams"
|
||||
} else if room.t == "p" {
|
||||
return "channel-private"
|
||||
} else if room.t == "c" {
|
||||
return "channel-public"
|
||||
} else if room.t == "d", formatter.isGroupChat {
|
||||
return "message"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastMessage: String {
|
||||
guard let user = room.lastMessage?.user else {
|
||||
return String(localized: "No message")
|
||||
}
|
||||
|
||||
let isLastMessageSentByMe = user.username == server.loggedUser.username
|
||||
let username = isLastMessageSentByMe ? String(localized: "You") : ((server.useRealName ? user.name : user.username) ?? "")
|
||||
let message = room.lastMessage?.msg ?? String(localized: "No message")
|
||||
|
||||
if room.lastMessage?.t == "jitsi_call_started" {
|
||||
return String(localized: "Call started by: \(username)")
|
||||
}
|
||||
|
||||
if room.lastMessage?.attachments?.allObjects.isEmpty == false {
|
||||
return String(localized: "\(username) sent an attachment")
|
||||
}
|
||||
|
||||
if room.lastMessage?.t == "e2e" {
|
||||
return String(localized: "Encrypted message")
|
||||
}
|
||||
|
||||
if room.lastMessage?.t == "videoconf" {
|
||||
return String(localized: "Call started")
|
||||
}
|
||||
|
||||
if room.t == "d", !isLastMessageSentByMe {
|
||||
return message
|
||||
}
|
||||
|
||||
return "\(username): \(message)"
|
||||
}
|
||||
|
||||
var updatedAt: String? {
|
||||
guard let ts = room.ts else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale.current
|
||||
dateFormatter.timeZone = TimeZone.current
|
||||
|
||||
if calendar.isDateInYesterday(ts) {
|
||||
return "Yesterday"
|
||||
}
|
||||
|
||||
if calendar.isDateInToday(ts) {
|
||||
dateFormatter.timeStyle = .short
|
||||
dateFormatter.dateStyle = .none
|
||||
|
||||
return dateFormatter.string(from: ts)
|
||||
}
|
||||
|
||||
if isDateFromLastWeek(ts) {
|
||||
dateFormatter.dateFormat = "EEEE"
|
||||
|
||||
return dateFormatter.string(from: ts)
|
||||
}
|
||||
|
||||
dateFormatter.timeStyle = .none
|
||||
dateFormatter.dateStyle = .short
|
||||
|
||||
return dateFormatter.string(from: ts)
|
||||
}
|
||||
|
||||
private func isDateFromLastWeek(_ date: Date) -> Bool {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
let startOfCurrentWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))!
|
||||
|
||||
guard let startOfLastWeek = calendar.date(byAdding: .day, value: -7, to: startOfCurrentWeek) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return calendar.isDate(date, inSameDayAs: startOfLastWeek) || date > startOfLastWeek
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AttachmentView: View {
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
|
||||
private let attachment: Attachment
|
||||
|
||||
init(attachment: Attachment) {
|
||||
self.attachment = attachment
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let msg = attachment.msg {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
if let rawURL = attachment.imageURL {
|
||||
RemoteImage(url: client.authorizedURL(url: rawURL)) {
|
||||
ProgressView()
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.aspectRatio(attachment.aspectRatio, contentMode: .fit)
|
||||
.cornerRadius(4)
|
||||
} else {
|
||||
Text("Attachment not supported.")
|
||||
.font(.caption.italic())
|
||||
.foregroundStyle(Color.secondaryInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import SwiftUI
|
||||
|
||||
/// We need to reverse the scroll view to make it look like a Chat list.
|
||||
/// Since we want to support older WatchOS versions, we made this wrapper to rotate the scroll view, when we can't use defaultScrollAnchor modifier.
|
||||
/// It should do the trick for older WatchOS versions and have the native implementation for newer ones.
|
||||
/// We hide the indicators for the flipped scroll view, since they appear reversed.
|
||||
struct ChatScrollView<Content: View>: View {
|
||||
private let content: () -> Content
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(watchOS 10.0, *) {
|
||||
ScrollView {
|
||||
content()
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
content()
|
||||
.rotationEffect(.degrees(180))
|
||||
}
|
||||
.rotationEffect(.degrees(180))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import SwiftUI
|
||||
|
||||
struct LazyView<Content: View>: View {
|
||||
private let build: () -> Content
|
||||
|
||||
init(_ build: @autoclosure @escaping () -> Content) {
|
||||
self.build = build
|
||||
}
|
||||
|
||||
var body: Content {
|
||||
build()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import SwiftUI
|
||||
|
||||
struct LoggedInView: View {
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var roomsLoader: RoomsLoader
|
||||
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
RoomListView(server: server, roomsLoader: roomsLoader)
|
||||
.environmentObject(router)
|
||||
.environment(\.managedObjectContext, database.viewContext)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageActionView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let action: (MessageAction) -> Void
|
||||
private let message: Message
|
||||
|
||||
init(message: Message, action: @escaping (MessageAction) -> Void) {
|
||||
self.action = action
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
|
||||
action(.resend(message))
|
||||
}, label: {
|
||||
Text("Resend")
|
||||
})
|
||||
Button(action: {
|
||||
dismiss()
|
||||
|
||||
action(.delete(message))
|
||||
}, label: {
|
||||
Text("Delete")
|
||||
.foregroundStyle(.red)
|
||||
})
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageComposerView: View {
|
||||
@State private var message = ""
|
||||
|
||||
let room: Room
|
||||
let onSend: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
if room.isReadOnly {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("This room is read only")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
TextField("Message", text: $message)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(send)
|
||||
}
|
||||
}
|
||||
|
||||
func send() {
|
||||
guard !message.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
onSend(message)
|
||||
message = ""
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageListView: View {
|
||||
private let messageComposer = "MESSAGE_COMPOSER_ID"
|
||||
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var messagesLoader: MessagesLoading
|
||||
@Dependency private var messageSender: MessageSending
|
||||
|
||||
private let formatter: RoomFormatter
|
||||
private let server: Server
|
||||
|
||||
@ObservedObject private var room: Room
|
||||
|
||||
@State private var lastOpen: Date?
|
||||
@State private var info: Room?
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@FetchRequest<Message> private var messages: FetchedResults<Message>
|
||||
|
||||
init(room: Room, server: Server) {
|
||||
self.formatter = RoomFormatter(room: room, server: server)
|
||||
self.room = room
|
||||
self.server = server
|
||||
_messages = FetchRequest(fetchRequest: room.messagesRequest, animation: .none)
|
||||
_lastOpen = State(wrappedValue: room.updatedSince)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if messages.count == 0 {
|
||||
HStack(alignment: .bottom) {
|
||||
Spacer()
|
||||
VStack {
|
||||
Text("No messages")
|
||||
.font(.caption.italic())
|
||||
.foregroundStyle(Color.secondaryInfo)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
ChatScrollView {
|
||||
VStack(spacing: 0) {
|
||||
if room.hasMoreMessages {
|
||||
Button("Load more...") {
|
||||
guard let oldestMessage = room.firstMessage?.ts else { return }
|
||||
|
||||
messagesLoader.loadMore(from: oldestMessage)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
ForEach(messages.indices, id: \.self) { index in
|
||||
let message = messages[index]
|
||||
let previousMessage = messages.indices.contains(index - 1) ? messages[index - 1] : nil
|
||||
|
||||
MessageView(
|
||||
viewModel: .init(
|
||||
message: message,
|
||||
previousMessage: previousMessage,
|
||||
server: server,
|
||||
lastOpen: lastOpen
|
||||
)
|
||||
) { action in
|
||||
switch action {
|
||||
case .resend(let message):
|
||||
messageSender.resendMessage(message: message, in: room)
|
||||
|
||||
lastOpen = nil
|
||||
case .delete(let message):
|
||||
database.remove(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageComposerView(room: room) {
|
||||
messageSender.sendMessage($0, in: room)
|
||||
|
||||
lastOpen = nil
|
||||
}
|
||||
.id(messageComposer)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
.navigationDestination(for: $info) { room in
|
||||
RoomInfoView(room: room)
|
||||
.environment(\.managedObjectContext, database.viewContext)
|
||||
}
|
||||
.navigationTitle {
|
||||
Text(formatter.title ?? "")
|
||||
.foregroundStyle(Color.titleLabels)
|
||||
.onTapGesture {
|
||||
if room.t == "d" {
|
||||
info = room
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.disabled(!room.synced)
|
||||
.onAppear {
|
||||
guard let roomID = room.id else { return }
|
||||
|
||||
messagesLoader.start(on: roomID)
|
||||
}
|
||||
.onDisappear {
|
||||
messagesLoader.stop()
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
switch phase {
|
||||
case .active:
|
||||
guard let roomID = room.id else { return }
|
||||
|
||||
messagesLoader.start(on: roomID)
|
||||
case .background, .inactive:
|
||||
messagesLoader.stop()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
import SwiftUI
|
||||
|
||||
enum MessageAction {
|
||||
case resend(Message)
|
||||
case delete(Message)
|
||||
}
|
||||
|
||||
struct MessageView: View {
|
||||
@Dependency private var client: RocketChatClientProtocol
|
||||
|
||||
@ObservedObject private var viewModel: MessageViewModel
|
||||
|
||||
@State private var message: Message?
|
||||
|
||||
private let action: (MessageAction) -> Void
|
||||
|
||||
init(viewModel: MessageViewModel, action: @escaping (MessageAction) -> Void) {
|
||||
self.action = action
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var unreadSeparator: some View {
|
||||
HStack(alignment: .center) {
|
||||
Text("Unread messages")
|
||||
.lineLimit(1)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.layoutPriority(1)
|
||||
VStack(alignment: .center) {
|
||||
Divider()
|
||||
.overlay(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dateSeparator: some View {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .center) {
|
||||
Divider()
|
||||
.overlay(.secondary)
|
||||
}
|
||||
Text(viewModel.date ?? "")
|
||||
.lineLimit(1)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.layoutPriority(1)
|
||||
VStack(alignment: .center) {
|
||||
Divider()
|
||||
.overlay(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if viewModel.hasDateSeparator {
|
||||
dateSeparator
|
||||
} else if viewModel.hasUnreadSeparator {
|
||||
unreadSeparator
|
||||
}
|
||||
if viewModel.isHeader {
|
||||
HStack(alignment: .center) {
|
||||
Text(viewModel.sender ?? "")
|
||||
.lineLimit(1)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.default)
|
||||
Text(viewModel.time ?? "")
|
||||
.lineLimit(1)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if viewModel.message.editedAt != nil {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
if let text = viewModel.info {
|
||||
(Text("\(viewModel.sender ?? "") ").font(.caption.bold().italic()) + Text(text).font(.caption.italic()))
|
||||
.foregroundStyle(Color.default)
|
||||
} else if let text = viewModel.message.msg {
|
||||
HStack(alignment: .top) {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(viewModel.message.foregroundColor)
|
||||
|
||||
if viewModel.message.status == "error" {
|
||||
Button(
|
||||
action: {
|
||||
message = viewModel.message
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
if viewModel.message.editedAt != nil && !viewModel.isHeader {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let attachments = viewModel.message.attachments?.allObjects as? Array<Attachment> {
|
||||
ForEach(attachments) { attachment in
|
||||
AttachmentView(attachment: attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.sheet(item: $message) { message in
|
||||
MessageActionView(
|
||||
message: message,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Message {
|
||||
var foregroundColor: Color {
|
||||
if status == "temp" || status == "error" {
|
||||
return Color.secondaryInfo
|
||||
}
|
||||
|
||||
return Color.default
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import SwiftUI
|
||||
|
||||
struct NavigationStackModifier<Item, Destination: View>: ViewModifier {
|
||||
let item: Binding<Item?>
|
||||
let destination: (Item) -> Destination
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.background {
|
||||
NavigationLink(isActive: item.mappedToBool()) {
|
||||
if let item = item.wrappedValue {
|
||||
destination(item)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func navigationDestination<Item, Destination: View>(
|
||||
for binding: Binding<Item?>,
|
||||
@ViewBuilder destination: @escaping (Item) -> Destination
|
||||
) -> some View {
|
||||
self.modifier(NavigationStackModifier(item: binding, destination: destination))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RemoteImage<Placeholder: View>: View {
|
||||
@StateObject private var loader: ImageLoader
|
||||
private let placeholder: Placeholder
|
||||
|
||||
init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
|
||||
self.placeholder = placeholder()
|
||||
_loader = StateObject(wrappedValue: ImageLoader(url: url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onAppear(perform: loader.load)
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
Group {
|
||||
if loader.image != nil {
|
||||
Image(uiImage: loader.image!)
|
||||
.resizable()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RetryView: View {
|
||||
private let label: LocalizedStringKey
|
||||
private let action: () -> Void
|
||||
|
||||
init(_ label: LocalizedStringKey, action: @escaping () -> Void) {
|
||||
self.label = label
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(label)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Try again", action: action)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RoomInfoView: View {
|
||||
@ObservedObject private var room: Room
|
||||
|
||||
init(room: Room) {
|
||||
self.room = room
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(room.fname ?? "")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.titleLabels)
|
||||
Text(room.name ?? "")
|
||||
.font(.caption2)
|
||||
.fontWeight(.regular)
|
||||
.foregroundStyle(Color.secondaryInfo)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|