diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift index 7933f2fb6a..cb7c5530eb 100644 --- a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+Get.swift @@ -1,7 +1,7 @@ import Foundation extension ImageCache { - func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data? { + func image(for userId: String, updatedAt: Double, forServer serverUrl: String) -> Data? { lock.lock(); defer { lock.unlock() } let key = "\(serverUrl)-\(userId)-\(updatedAt)" as NSString if let image = imageCache.object(forKey: key) as? Data { diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift index 78e96df953..c8e1b60af0 100644 --- a/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCache+InsertRemove.swift @@ -6,9 +6,9 @@ extension ImageCache { keysCache.removeAllObjects() } - func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String ) { + func insertImage(_ data: Data?, for userId: String, updatedAt: Double, forServer serverUrl: String ) { guard let data = data else { - return removeImage(for: userId, onServer: serverUrl) + return removeImage(for: userId, forServer: serverUrl) } lock.lock(); defer { lock.unlock() } @@ -18,7 +18,7 @@ extension ImageCache { keysCache.setObject(imageKey, forKey: cacheKey) } - func removeImage(for userId: String, onServer serverUrl: String) { + func removeImage(for userId: String, forServer serverUrl: String) { lock.lock(); defer { lock.unlock() } let cacheKey = "\(serverUrl)-\(userId)" as NSString if let key = keysCache.object(forKey: cacheKey) { diff --git a/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift b/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift index 2d4f875442..2cc9fda809 100644 --- a/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift +++ b/ios/Gekidou/Sources/Gekidou/Cache/ImageCacheType.swift @@ -1,11 +1,11 @@ import Foundation -protocol ImageCacheType: class { - func image(for userId: String, updatedAt: Double, onServer serverUrl: String) -> Data? +protocol ImageCacheType: AnyObject { + func image(for userId: String, updatedAt: Double, forServer serverUrl: String) -> Data? - func insertImage(_ data: Data?, for userId: String, updatedAt: Double, onServer serverUrl: String ) + func insertImage(_ data: Data?, for userId: String, updatedAt: Double, forServer serverUrl: String ) - func removeImage(for userId: String, onServer serverUrl: String) + func removeImage(for userId: String, forServer serverUrl: String) func removeAllImages() } diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift new file mode 100644 index 0000000000..151116afeb --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Category.swift @@ -0,0 +1,109 @@ +import Foundation + +public struct CategoriesWithOrder: Codable { + let order: [String] + let categories: [Category] + + public enum CategoriesWithOrderKeys: String, CodingKey { + case order, categories + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CategoriesWithOrderKeys.self) + order = values.decodeIfPresent(forKey: .order, defaultValue: [String]()) + categories = (try? values.decode([Category].self, forKey: .categories)) ?? [Category]() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CategoriesWithOrderKeys.self) + try container.encode(self.order, forKey: .order) + try container.encode(self.categories, forKey: .categories) + } +} + +public struct Category: Codable { + let id: String + let channelIds: [String] + let collapsed: Bool + let displayName: String + let muted: Bool + let sortOrder: Int + let sorting: String + let teamId: String + let type: String + let userId: String + + public enum CategoryKeys: String, CodingKey { + case id, collapsed, muted, sorting, type + case channelIds = "channel_ids" + case displayName = "display_name" + case sortOrder = "sort_order" + case teamId = "team_id" + case userId = "user_id" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CategoryKeys.self) + id = try values.decode(String.self, forKey: .id) + teamId = try values.decode(String.self, forKey: .teamId) + userId = try values.decode(String.self, forKey: .userId) + channelIds = values.decodeIfPresent(forKey: .channelIds, defaultValue: [String]()) + collapsed = false + displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "") + muted = values.decodeIfPresent(forKey: .muted, defaultValue: false) + sortOrder = values.decodeIfPresent(forKey: .sortOrder, defaultValue: 0) + sorting = values.decodeIfPresent(forKey: .sorting, defaultValue: "recent") + type = values.decodeIfPresent(forKey: .type, defaultValue: "custom") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CategoryKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.channelIds, forKey: .channelIds) + try container.encode(self.collapsed, forKey: .collapsed) + try container.encode(self.displayName, forKey: .displayName) + try container.encode(self.muted, forKey: .muted) + try container.encode(self.sortOrder, forKey: .sortOrder) + try container.encode(self.sorting, forKey: .sorting) + try container.encode(self.teamId, forKey: .teamId) + try container.encode(self.type, forKey: .type) + try container.encode(self.userId, forKey: .userId) + } +} + +public struct CategoryChannel: Codable { + let id: String + let categoryId: String + let channelId: String + let sortOrder: Int + + public enum CategoryChannelKeys: String, CodingKey { + case id + case channelId = "channel_id" + case categoryId = "category_id" + case sortOrder = "sort_order" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CategoryChannelKeys.self) + id = try values.decode(String.self, forKey: .id) + channelId = try values.decode(String.self, forKey: .channelId) + categoryId = try values.decode(String.self, forKey: .categoryId) + sortOrder = values.decodeIfPresent(forKey: .sortOrder, defaultValue: 0) + } + + public init(id: String, categoryId: String, channelId: String, sortOrder: Int = 0) { + self.id = id + self.categoryId = categoryId + self.channelId = channelId + self.sortOrder = sortOrder + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CategoryChannelKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.channelId, forKey: .channelId) + try container.encode(self.categoryId, forKey: .categoryId) + try container.encode(self.sortOrder, forKey: .sortOrder) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift new file mode 100644 index 0000000000..8329a9b367 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Channel.swift @@ -0,0 +1,100 @@ +import Foundation + +public struct Channel: Codable { + let id: String + let createAt: Double + let creatorId: String + let deleteAt: Double + var displayName: String = "" + let extraUpdateAt: Double + let groupConstrained: Bool + let header: String + let lastPostAt: Double + let lastRootPostAt: Double + let name: String + let policyId: String + let props: String + let purpose: String + let schemeId: String + let shared: Bool + let teamId: String + let totalMsgCount: Int + let totalMsgCountRoot: Int + let type: String + let updateAt: Double + + public enum ChannelKeys: String, CodingKey { + case id + case createAt = "create_at" + case creatorId = "creator_id" + case deleteAt = "delete_at" + case displayName = "display_name" + case extraUpdateAt = "extra_update_at" + case groupConstrained = "group_constrained" + case header + case lastPostAt = "last_post_at" + case lastRootPostAt = "last_root_post_at" + case name + case policyId = "policy_id" + case props + case purpose + case schemeId = "scheme_id" + case shared + case teamId = "team_id" + case totalMsgCount = "total_msg_count" + case totalMsgCountRoot = "total_msg_count_root" + case type + case updateAt = "update_at" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: ChannelKeys.self) + id = try values.decode(String.self, forKey: .id) + creatorId = values.decodeIfPresent(forKey: .creatorId, defaultValue: "") + createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0) + deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) + displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "") + extraUpdateAt = values.decodeIfPresent(forKey: .extraUpdateAt, defaultValue: 0) + groupConstrained = values.decodeIfPresent(forKey: .groupConstrained, defaultValue: false) + header = values.decodeIfPresent(forKey: .header, defaultValue: "") + lastPostAt = values.decodeIfPresent(forKey: .lastPostAt, defaultValue: 0) + lastRootPostAt = values.decodeIfPresent(forKey: .lastRootPostAt, defaultValue: 0) + name = values.decodeIfPresent(forKey: .name, defaultValue: "") + policyId = values.decodeIfPresent(forKey: .policyId, defaultValue: "") + let propsData = try? values.decode([String:Any].self, forKey: .props) + props = Database.default.json(from: propsData) ?? "{}" + purpose = values.decodeIfPresent(forKey: .purpose, defaultValue: "") + schemeId = values.decodeIfPresent(forKey: .schemeId, defaultValue: "") + shared = values.decodeIfPresent(forKey: .shared, defaultValue: false) + teamId = values.decodeIfPresent(forKey: .teamId, defaultValue: "") + totalMsgCount = values.decodeIfPresent(forKey: .totalMsgCount, defaultValue: 0) + totalMsgCountRoot = values.decodeIfPresent(forKey: .totalMsgCountRoot, defaultValue: 0) + type = values.decodeIfPresent(forKey: .type, defaultValue: "O") + updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ChannelKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.creatorId, forKey: .creatorId) + try container.encode(self.createAt, forKey: .createAt) + try container.encode(self.deleteAt, forKey: .deleteAt) + try container.encode(self.displayName, forKey: .displayName) + try container.encode(self.extraUpdateAt, forKey: .extraUpdateAt) + try container.encode(self.groupConstrained, forKey: .groupConstrained) + try container.encode(self.header, forKey: .header) + try container.encode(self.lastPostAt, forKey: .lastPostAt) + try container.encode(self.lastRootPostAt, forKey: .lastRootPostAt) + try container.encode(self.name, forKey: .name) + try container.encode(self.policyId, forKey: .policyId) + try container.encode(self.props, forKey: .props) + try container.encode(self.purpose, forKey: .purpose) + try container.encode(self.schemeId, forKey: .schemeId) + try container.encode(self.shared, forKey: .shared) + try container.encode(self.teamId, forKey: .teamId) + try container.encode(self.totalMsgCount, forKey: .totalMsgCount) + try container.encode(self.totalMsgCountRoot, forKey: .totalMsgCountRoot) + try container.encode(self.type, forKey: .type) + try container.encode(self.updateAt, forKey: .updateAt) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift new file mode 100644 index 0000000000..cbfc46ef87 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/ChannelMember.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct ChannelMember: Codable { + let id: String + let explicitRoles: String + let lastUpdateAt: Double + let lastViewedAt: Double + let mentionCount: Int + let mentionCountRoot: Int + let msgCount: Int + let msgCountRoot: Int + let notifyProps: String + let roles: String + let schemeAdmin: Bool + let schemeGuest: Bool + let schemeUser: Bool + let urgentMentionCount: Int + let userId: String + var internalMsgCount: Int + var internalMsgCountRoot: Int + + + public enum ChannelMemberKeys: String, CodingKey { + case internalMsgCount, internalMsgCountRoot + case id = "channel_id" + case explicitRoles = "explicit_roles" + case lastUpdateAt = "last_update_at" + case lastViewedAt = "last_viewed_at" + case mentionCount = "mention_count" + case mentionCountRoot = "mention_count_root" + case msgCount = "msg_count" + case msgCountRoot = "msg_count_root" + case notifyProps = "notify_props" + case roles + case schemeAdmin = "scheme_admin" + case schemeGuest = "scheme_guest" + case schemeUser = "scheme_user" + case urgentMentionCount = "urgent_mention_count" + case userId = "user_id" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: ChannelMemberKeys.self) + id = try values.decode(String.self, forKey: .id) + userId = try values.decode(String.self, forKey: .userId) + explicitRoles = values.decodeIfPresent(forKey: .explicitRoles, defaultValue: "") + lastUpdateAt = values.decodeIfPresent(forKey: .lastUpdateAt, defaultValue: 0) + lastViewedAt = values.decodeIfPresent(forKey: .lastViewedAt, defaultValue: 0) + mentionCount = values.decodeIfPresent(forKey: .mentionCount, defaultValue: 0) + mentionCountRoot = values.decodeIfPresent(forKey: .mentionCountRoot, defaultValue: 0) + msgCount = values.decodeIfPresent(forKey: .msgCount, defaultValue: 0) + msgCountRoot = values.decodeIfPresent(forKey: .msgCountRoot, defaultValue: 0) + let propsData = try values.decode([String:Any].self, forKey: .notifyProps) + notifyProps = Database.default.json(from: propsData) ?? "{}" + roles = values.decodeIfPresent(forKey: .roles, defaultValue: "") + schemeAdmin = values.decodeIfPresent(forKey: .schemeAdmin, defaultValue: false) + schemeGuest = values.decodeIfPresent(forKey: .schemeGuest, defaultValue: false) + schemeUser = values.decodeIfPresent(forKey: .schemeUser, defaultValue: true) + urgentMentionCount = values.decodeIfPresent(forKey: .urgentMentionCount, defaultValue: 0) + internalMsgCount = 0 + internalMsgCountRoot = 0 + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ChannelMemberKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.explicitRoles, forKey: .explicitRoles) + try container.encode(self.lastUpdateAt, forKey: .lastUpdateAt) + try container.encode(self.lastViewedAt, forKey: .lastViewedAt) + try container.encode(self.mentionCount, forKey: .mentionCount) + try container.encode(self.mentionCountRoot, forKey: .mentionCountRoot) + try container.encode(self.msgCount, forKey: .msgCount) + try container.encode(self.msgCountRoot, forKey: .msgCountRoot) + try container.encode(self.notifyProps, forKey: .notifyProps) + try container.encode(self.roles, forKey: .roles) + try container.encode(self.schemeAdmin, forKey: .schemeAdmin) + try container.encode(self.schemeGuest, forKey: .schemeGuest) + try container.encode(self.schemeUser, forKey: .schemeUser) + try container.encode(self.urgentMentionCount, forKey: .urgentMentionCount) + try container.encode(self.userId, forKey: .userId) + try container.encode(self.internalMsgCount, forKey: .internalMsgCount) + try container.encode(self.internalMsgCountRoot, forKey: .internalMsgCountRoot) + } +} + diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift new file mode 100644 index 0000000000..9175adb402 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Post.swift @@ -0,0 +1,103 @@ +import Foundation + +public struct Post: Codable { + let id: String + let createAt: Double + let updateAt: Double + let editAt: Double + let deleteAt: Double + let isPinned: Bool + let userId: String + let channelId: String + let rootId: String + let originalId: String + let message: String + let type: String + let props: String + let pendingPostId: String + let metadata: String + var prevPostId: String + // CRT + let participants: [User]? + let lastReplyAt: Double + let replyCount: Int + let isFollowing: Bool + + public enum PostKeys: String, CodingKey { + case id, message, type, props, metadata, participants + case createAt = "create_at" + case updateAt = "update_at" + case deleteAt = "delete_at" + case editAt = "edit_at" + case isPinned = "is_pinned" + case userId = "user_id" + case channelId = "channel_id" + case rootId = "root_id" + case originalId = "original_id" + case pendingPostId = "pending_post_id" + case prevPostId = "previous_post_id" + // CRT + case lastReplyAt = "last_reply_at" + case replyCount = "reply_count" + case isFollowing = "is_following" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: PostKeys.self) + prevPostId = "" + id = try values.decode(String.self, forKey: .id) + channelId = try values.decode(String.self, forKey: .channelId) + userId = try values.decode(String.self, forKey: .userId) + createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0) + updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) + deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) + editAt = values.decodeIfPresent(forKey: .editAt, defaultValue: 0) + isPinned = values.decodeIfPresent(forKey: .isPinned, defaultValue: false) + rootId = values.decodeIfPresent(forKey: .rootId, defaultValue: "") + originalId = values.decodeIfPresent(forKey: .originalId, defaultValue: "") + message = values.decodeIfPresent(forKey: .message, defaultValue: "") + type = values.decodeIfPresent(forKey: .type, defaultValue: "") + pendingPostId = values.decodeIfPresent(forKey: .pendingPostId, defaultValue: "") + lastReplyAt = values.decodeIfPresent(forKey: .lastReplyAt, defaultValue: 0) + replyCount = values.decodeIfPresent(forKey: .replyCount, defaultValue: 0) + isFollowing = values.decodeIfPresent(forKey: .isFollowing, defaultValue: false) + + participants = (try? values.decodeIfPresent([User].self, forKey: .participants)) ?? nil + + if let meta = try? values.decode([String:Any].self, forKey: .metadata) { + metadata = Database.default.json(from: meta) ?? "{}" + } else { + metadata = "{}" + } + + if let propsData = try? values.decode([String:Any].self, forKey: .props) { + props = Database.default.json(from: propsData) ?? "{}" + } else { + props = "{}" + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: PostKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.createAt, forKey: .createAt) + try container.encode(self.updateAt, forKey: .updateAt) + try container.encode(self.editAt, forKey: .editAt) + try container.encode(self.deleteAt, forKey: .deleteAt) + try container.encode(self.isPinned, forKey: .isPinned) + try container.encode(self.userId, forKey: .userId) + try container.encode(self.channelId, forKey: .channelId) + try container.encode(self.rootId, forKey: .rootId) + try container.encode(self.originalId, forKey: .originalId) + try container.encode(self.message, forKey: .message) + try container.encode(self.type, forKey: .type) + try container.encode(self.props, forKey: .props) + try container.encode(self.pendingPostId, forKey: .pendingPostId) + try container.encode(self.metadata, forKey: .metadata) + try container.encode(self.prevPostId, forKey: .prevPostId) + try container.encodeIfPresent(self.participants, forKey: .participants) + try container.encode(self.lastReplyAt, forKey: .lastReplyAt) + try container.encode(self.replyCount, forKey: .replyCount) + try container.encode(self.isFollowing, forKey: .isFollowing) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/PostResponse.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/PostResponse.swift new file mode 100644 index 0000000000..9909ee65d9 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/PostResponse.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct PostResponse: Codable { + let order: [String] + let posts: [String:Post] + let nextPostId: String + let prevPostId: String + + public enum PostResponseKeys: String, CodingKey { + case order, posts + case nextPostId = "next_post_id" + case prevPostId = "prev_post_id" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: PostResponseKeys.self) + order = values.decodeIfPresent(forKey: .order, defaultValue: [String]()) + nextPostId = values.decodeIfPresent(forKey: .nextPostId, defaultValue: "") + prevPostId = values.decodeIfPresent(forKey: .prevPostId, defaultValue: "") + posts = (try? values.decode([String:Post].self, forKey: .posts)) ?? [String:Post]() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: PostResponseKeys.self) + try container.encode(self.order, forKey: .order) + try container.encode(self.posts, forKey: .posts) + try container.encode(self.nextPostId, forKey: .nextPostId) + try container.encode(self.prevPostId, forKey: .prevPostId) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift new file mode 100644 index 0000000000..cb87cb4774 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/PostThread.swift @@ -0,0 +1,66 @@ +import Foundation + +public struct PostThread: Codable { + let id: String + var lastReplyAt: Double + var lastViewedAt: Double + let replyCount: Int + var unreadReplies: Int + var unreadMentions: Int + let post: Post? + let participants: [User] + let isFollowing: Bool + let deleteAt: Double + + public enum PostThreadKeys: String, CodingKey { + case id, post, participants + case lastReplyAt = "last_reply_at" + case lastViewedAt = "last_viewed_at" + case replyCount = "reply_count" + case unreadReplies = "unread_replies" + case unreadMentions = "unread_mentions" + case isFollowing = "is_following" + case deleteAt = "delete_at" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: PostThreadKeys.self) + id = try values.decode(String.self, forKey: .id) + post = values.decodeIfPresent(forKey: .post, defaultValue: nil) + participants = values.decodeIfPresent(forKey: .participants, defaultValue: [User]()) + lastReplyAt = values.decodeIfPresent(forKey: .lastReplyAt, defaultValue: 0) + lastViewedAt = values.decodeIfPresent(forKey: .lastViewedAt, defaultValue: 0) + replyCount = values.decodeIfPresent(forKey: .replyCount, defaultValue: 0) + unreadReplies = values.decodeIfPresent(forKey: .unreadReplies, defaultValue: 0) + unreadMentions = values.decodeIfPresent(forKey: .unreadMentions, defaultValue: 0) + isFollowing = values.decodeIfPresent(forKey: .isFollowing, defaultValue: false) + deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) + } + + public init(from post: Post) { + id = post.id + replyCount = post.replyCount + participants = post.participants ?? [User]() + isFollowing = post.isFollowing + deleteAt = post.deleteAt + lastReplyAt = 0 + lastViewedAt = 0 + unreadReplies = 0 + unreadMentions = 0 + self.post = post + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: PostThreadKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.lastReplyAt, forKey: .lastReplyAt) + try container.encode(self.lastViewedAt, forKey: .lastViewedAt) + try container.encode(self.replyCount, forKey: .replyCount) + try container.encode(self.unreadReplies, forKey: .unreadReplies) + try container.encode(self.unreadMentions, forKey: .unreadMentions) + try container.encodeIfPresent(self.post, forKey: .post) + try container.encode(self.participants, forKey: .participants) + try container.encode(self.isFollowing, forKey: .isFollowing) + try container.encode(self.deleteAt, forKey: .deleteAt) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift new file mode 100644 index 0000000000..6d656637f9 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/Team.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct Team: Codable { + let id: String + let allowOpenInvite: Bool + let allowedDomains: String + let cloudLimitsArchived: Bool + let companyName: String + let createAt: Double + let deleteAt: Double + let description: String + let displayName: String + let email: String + let groupConstrained: Bool + let inviteId: String + let lastTeamIconUpdate: Double + let name: String + let policyId: String + let schemeId: String? + let type: String + let updateAt: Double + + + public enum TeamKeys: String, CodingKey { + case id + case allowOpenInvite = "allow_open_invite" + case allowedDomains = "allowed_domains" + case cloudLimitsArchived = "cloud_limits_archive" + case companyName = "company_name" + case createAt = "create_at" + case deleteAt = "delete_at" + case description + case displayName = "display_name" + case email + case groupConstrained = "group_constrained" + case inviteId = "invite_id" + case lastTeamIconUpdate = "last_team_icon_update" + case name + case policyId = "policy_id" + case schemeId = "scheme_id" + case type + case updateAt = "update_at" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: TeamKeys.self) + id = try values.decode(String.self, forKey: .id) + allowOpenInvite = values.decodeIfPresent(forKey: .allowOpenInvite, defaultValue: true) + allowedDomains = values.decodeIfPresent(forKey: .allowedDomains, defaultValue: "") + cloudLimitsArchived = values.decodeIfPresent(forKey: .cloudLimitsArchived, defaultValue: false) + companyName = values.decodeIfPresent(forKey: .companyName, defaultValue: "") + createAt = values.decodeIfPresent(forKey: .createAt, defaultValue: 0) + deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) + description = values.decodeIfPresent(forKey: .description, defaultValue: "") + displayName = values.decodeIfPresent(forKey: .displayName, defaultValue: "") + email = values.decodeIfPresent(forKey: .email, defaultValue: "") + groupConstrained = values.decodeIfPresent(forKey: .groupConstrained, defaultValue: false) + inviteId = values.decodeIfPresent(forKey: .inviteId, defaultValue: "") + lastTeamIconUpdate = values.decodeIfPresent(forKey: .lastTeamIconUpdate, defaultValue: 0) + name = values.decodeIfPresent(forKey: .name, defaultValue: "") + policyId = values.decodeIfPresent(forKey: .policyId, defaultValue: "") + schemeId = values.decodeIfPresent(forKey: .schemeId, defaultValue: "") + type = values.decodeIfPresent(forKey: .type, defaultValue: "O") + updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: TeamKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.allowOpenInvite, forKey: .allowOpenInvite) + try container.encode(self.allowedDomains, forKey: .allowedDomains) + try container.encode(self.cloudLimitsArchived, forKey: .cloudLimitsArchived) + try container.encode(self.companyName, forKey: .companyName) + try container.encode(self.createAt, forKey: .createAt) + try container.encode(self.deleteAt, forKey: .deleteAt) + try container.encode(self.description, forKey: .description) + try container.encode(self.displayName, forKey: .displayName) + try container.encode(self.email, forKey: .email) + try container.encode(self.groupConstrained, forKey: .groupConstrained) + try container.encode(self.inviteId, forKey: .inviteId) + try container.encode(self.lastTeamIconUpdate, forKey: .lastTeamIconUpdate) + try container.encode(self.name, forKey: .name) + try container.encode(self.policyId, forKey: .policyId) + try container.encodeIfPresent(self.schemeId, forKey: .schemeId) + try container.encode(self.type, forKey: .type) + try container.encode(self.updateAt, forKey: .updateAt) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift new file mode 100644 index 0000000000..132cc7e6e6 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/TeamMember.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct TeamMember: Codable { + let id: String + let explicitRoles: String + let roles: String + let schemeAdmin: Bool + let schemeGuest: Bool + let schemeUser: Bool + let userId: String + + public enum TeamMemberKeys: String, CodingKey { + case id = "team_id" + case explicitRoles = "explicit_roles" + case roles + case schemeAdmin = "scheme_admin" + case schemeGuest = "scheme_guest" + case schemeUser = "scheme_user" + case userId = "user_id" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: TeamMemberKeys.self) + id = try values.decode(String.self, forKey: .id) + userId = try values.decode(String.self, forKey: .userId) + explicitRoles = values.decodeIfPresent(forKey: .explicitRoles, defaultValue: "") + roles = values.decodeIfPresent(forKey: .roles, defaultValue: "") + schemeAdmin = values.decodeIfPresent(forKey: .schemeAdmin, defaultValue: false) + schemeGuest = values.decodeIfPresent(forKey: .schemeGuest, defaultValue: false) + schemeUser = values.decodeIfPresent(forKey: .schemeUser, defaultValue: true) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: TeamMemberKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.explicitRoles, forKey: .explicitRoles) + try container.encode(self.roles, forKey: .roles) + try container.encode(self.schemeAdmin, forKey: .schemeAdmin) + try container.encode(self.schemeGuest, forKey: .schemeGuest) + try container.encode(self.schemeUser, forKey: .schemeUser) + try container.encode(self.userId, forKey: .userId) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift b/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift new file mode 100644 index 0000000000..9b85bdfbf4 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/DataTypes/User.swift @@ -0,0 +1,98 @@ +import Foundation + +public struct User: Codable, Hashable { + let id: String + let authService: String + let updateAt: Double + let deleteAt: Double + let email: String + let firstName: String + let isBot: Bool + let isGuest: Bool + let lastName: String + let lastPictureUpdate: Double + let locale: String + let nickname: String + let position: String + let roles: String + let status: String + let username: String + let notifyProps: String + let props: String + let timezone: String + + public enum UserKeys: String, CodingKey { + case id, email, locale, nickname, position, roles, username, props, timezone, status + case authService = "auth_service" + case updateAt = "update_at" + case deleteAt = "delete_at" + case firstName = "first_name" + case isBot = "is_bot" + case lastName = "last_name" + case lastPictureUpdate = "last_picture_update" + case notifyProps = "notify_props" + case isGuest = "is_guest" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: UserKeys.self) + id = try values.decode(String.self, forKey: .id) + username = try values.decode(String.self, forKey: .username) + authService = values.decodeIfPresent(forKey: .authService, defaultValue: "") + updateAt = values.decodeIfPresent(forKey: .updateAt, defaultValue: 0) + deleteAt = values.decodeIfPresent(forKey: .deleteAt, defaultValue: 0) + email = values.decodeIfPresent(forKey: .email, defaultValue: "") + firstName = values.decodeIfPresent(forKey: .firstName, defaultValue: "") + isBot = values.decodeIfPresent(forKey: .isBot, defaultValue: false) + roles = values.decodeIfPresent(forKey: .roles, defaultValue: "") + lastName = values.decodeIfPresent(forKey: .lastName, defaultValue: "") + lastPictureUpdate = values.decodeIfPresent(forKey: .lastPictureUpdate, defaultValue: 0) + locale = values.decodeIfPresent(forKey: .locale, defaultValue: "en") + nickname = values.decodeIfPresent(forKey: .nickname, defaultValue: "") + position = values.decodeIfPresent(forKey: .position, defaultValue: "") + + isGuest = roles.contains("system_guest") + status = "offline" + + if let notifyPropsData = try? values.decodeIfPresent([String: String].self, forKey: .notifyProps) { + notifyProps = Database.default.json(from: notifyPropsData) ?? "{}" + } else { + notifyProps = "{}" + } + + if let propsData = try? values.decodeIfPresent([String: String].self, forKey: .props) { + props = Database.default.json(from: propsData) ?? "{}" + } else { + props = "{}" + } + + if let timezoneData = try? values.decodeIfPresent([String: String].self, forKey: .timezone) { + timezone = Database.default.json(from: timezoneData) ?? "{}" + } else { + timezone = "{}" + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: UserKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.authService, forKey: .authService) + try container.encode(self.updateAt, forKey: .updateAt) + try container.encode(self.deleteAt, forKey: .deleteAt) + try container.encode(self.email, forKey: .email) + try container.encode(self.firstName, forKey: .firstName) + try container.encode(self.isBot, forKey: .isBot) + try container.encode(self.isGuest, forKey: .isGuest) + try container.encode(self.lastName, forKey: .lastName) + try container.encode(self.lastPictureUpdate, forKey: .lastPictureUpdate) + try container.encode(self.locale, forKey: .locale) + try container.encode(self.nickname, forKey: .nickname) + try container.encode(self.position, forKey: .position) + try container.encode(self.roles, forKey: .roles) + try container.encode(self.status, forKey: .status) + try container.encode(self.username, forKey: .username) + try container.encode(self.notifyProps, forKey: .notifyProps) + try container.encode(self.props, forKey: .props) + try container.encode(self.timezone, forKey: .timezone) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Date+Extensions.swift b/ios/Gekidou/Sources/Gekidou/Date+Extensions.swift index 7c260c825e..2ccb60db2b 100644 --- a/ios/Gekidou/Sources/Gekidou/Date+Extensions.swift +++ b/ios/Gekidou/Sources/Gekidou/Date+Extensions.swift @@ -16,9 +16,3 @@ extension Date { self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) } } - -extension StringProtocol { - public subscript(offset: Int) -> Character { - self[index(startIndex, offsetBy: offset)] - } -} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift new file mode 100644 index 0000000000..ebb0f1e664 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Category.swift @@ -0,0 +1,16 @@ +import Foundation + +public typealias CategoriesHandler = (_ categoriesWithOrder: CategoriesWithOrder?) -> Void + +extension Network { + public func fetchCategories(withTeamId teamId: String, forServerUrl serverUrl: String, completionHandler: @escaping CategoriesHandler) { + var categoriesWithOrder: CategoriesWithOrder? + let channelUrl = buildApiUrl(serverUrl, "/users/me/teams/\(teamId)/channels/categories") + request(channelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in + if let data = data { + categoriesWithOrder = try? JSONDecoder().decode(CategoriesWithOrder.self, from: data) + } + completionHandler(categoriesWithOrder) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift new file mode 100644 index 0000000000..f1fc7b4b09 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Channel.swift @@ -0,0 +1,92 @@ +import Foundation + +extension Network { + public func fetchMyChannel(withId channelId: String, forServerUrl serverUrl: String, completionHandler: @escaping ((_ channel: Channel?, _ myChannel: ChannelMember?, _ profiles: [User]?) -> Void)) { + let group = DispatchGroup() + var channel: Channel? = nil + var tempChannel: Channel? = nil + var myChannel: ChannelMember? = nil + var profiles: [User]? = nil + + group.enter() + let channelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)") + request(channelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in + if let data = data { + tempChannel = try? JSONDecoder().decode(Channel.self, from: data) + } + group.leave() + } + + group.enter() + let myChannelUrl = buildApiUrl(serverUrl, "/channels/\(channelId)/members/me") + request(myChannelUrl, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in + if let data = data { + myChannel = try? JSONDecoder().decode(ChannelMember.self, from: data) + } + group.leave() + } + + group.notify(queue: .main) { + if let tempChannel = tempChannel, + (tempChannel.type == "D" || tempChannel.type == "G") + && !Database.default.queryChannelExists(withId: channelId, forServerUrl: serverUrl) { + let displayNameSetting = Database.default.getTeammateDisplayNameSetting(serverUrl) + Network.default.fetchProfiles(inChannelId: channelId, forServerUrl: serverUrl) {[weak self] data, response, error in + if let data = data, + let currentUserId = try? Database.default.queryCurrentUserId(serverUrl), + let users = try? JSONDecoder().decode([User].self, from: data) { + if !users.isEmpty { + profiles = users.filter{ $0.id != currentUserId} + if tempChannel.type == "D", + let profiles = profiles, + let user = profiles.first, + let displayName = self?.displayUsername(user, displayNameSetting) { + var chan = tempChannel + chan.displayName = displayName + channel = chan + } else if let profiles = profiles { + let locale = Database.default.getCurrentUserLocale(serverUrl) + if let displayName = self?.displayGroupMessageName(profiles, locale: locale, displayNameSetting: displayNameSetting) { + var chan = tempChannel + chan.displayName = displayName + channel = chan + } + } + } + } + completionHandler(channel, myChannel, profiles) + } + } else { + channel = tempChannel + completionHandler(channel, myChannel, profiles) + } + } + } + + private func displayUsername(_ user: User, _ displayNameSetting: String) -> String { + switch (displayNameSetting) { + case "nickname_full_name": + if !user.nickname.isEmpty { + return user.nickname + } + return "\(user.firstName) \(user.lastName)".trimmingCharacters(in: .whitespacesAndNewlines) + case "full_name": + return "\(user.firstName) \(user.lastName)".trimmingCharacters(in: .whitespacesAndNewlines) + default: + return user.username + } + } + + private func displayGroupMessageName(_ users: [User], locale: String, displayNameSetting: String) -> String { + var names = [String]() + for user in users { + names.append(displayUsername(user, displayNameSetting)) + } + + let sorted = names.sorted { (lhs: String, rhs: String) -> Bool in + return lhs.compare(rhs, options: [.caseInsensitive], locale: Locale(identifier: locale)) == .orderedAscending + } + + return sorted.joined(separator: ", ").trimmingCharacters(in: .whitespaces) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift new file mode 100644 index 0000000000..413da51e88 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Delegate.swift @@ -0,0 +1,24 @@ +import Foundation + +extension Network: URLSessionDelegate, URLSessionTaskDelegate { + public func urlSession(_ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + var credential: URLCredential? = nil + var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling + + let authMethod = challenge.protectionSpace.authenticationMethod + if authMethod == NSURLAuthenticationMethodClientCertificate { + let host = task.currentRequest!.url!.host! + if let (identity, certificate) = try? Keychain.default.getClientIdentityAndCertificate(for: host) { + credential = URLCredential(identity: identity, + certificates: [certificate], + persistence: URLCredential.Persistence.permanent) + } + disposition = .useCredential + } + + completionHandler(disposition, credential) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift deleted file mode 100644 index 8dde16f76a..0000000000 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Mentions.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation - -public struct ChannelMemberData: Codable { - let channel_id: String - let mention_count: Int - let mention_count_root: Int - let user_id: String - let roles: String - let last_viewed_at: Int64 - let last_update_at: Int64 - - public enum ChannelMemberKeys: String, CodingKey { - case channel_id, mention_count, mention_count_root, user_id, roles, last_viewed_at, last_update_at - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ChannelMemberKeys.self) - channel_id = try container.decode(String.self, forKey: .channel_id) - mention_count = try container.decode(Int.self, forKey: .mention_count) - let mentions_root = try? container.decode(Int?.self, forKey: .mention_count_root) - mention_count_root = mentions_root ?? 0 - user_id = try container.decode(String.self, forKey: .user_id) - roles = try container.decode(String.self, forKey: .roles) - last_update_at = try container.decode(Int64.self, forKey: .last_update_at) - last_viewed_at = try container.decode(Int64.self, forKey: .last_viewed_at) - } -} - -public struct ThreadData: Codable { - let id: String - let last_reply_at: Int64 - let last_viewed_at: Int64 - let reply_count: Int - let unread_replies: Int - let unread_mentions: Int - - public enum ThreadKeys: String, CodingKey { - case id, last_reply_at, last_viewed_at, reply_count, unread_replies, unread_mentions - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: ThreadKeys.self) - id = try container.decode(String.self, forKey: .id) - last_viewed_at = try container.decode(Int64.self, forKey: .last_viewed_at) - last_reply_at = try container.decode(Int64.self, forKey: .last_reply_at) - reply_count = try container.decode(Int.self, forKey: .reply_count) - unread_replies = try container.decode(Int.self, forKey: .unread_replies) - unread_mentions = try container.decode(Int.self, forKey: .unread_mentions) - } -} - -extension Network { - public func fetchChannelMentions(channelId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { - let endpoint = "/channels/\(channelId)/members/me" - let url = buildApiUrl(serverUrl, endpoint) - return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) - } - - public func fetchThreadMentions(teamId: String, threadId: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { - let endpoint = "/users/me/teams/\(teamId)/threads/\(threadId)" - let url = buildApiUrl(serverUrl, endpoint) - return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) - } -} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift index 09b55925c4..84f663e01f 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Posts.swift @@ -7,49 +7,9 @@ import Foundation -let POST_CHUNK_SIZE = 60 - -public struct PostData: Codable { - let order: [String] - let posts: [Post] - let next_post_id: String - let prev_post_id: String - - public enum PostDataKeys: String, CodingKey { - case order, posts, next_post_id, prev_post_id - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: PostDataKeys.self) - order = try container.decode([String].self, forKey: .order) - next_post_id = try container.decode(String.self, forKey: .next_post_id) - prev_post_id = try container.decode(String.self, forKey: .prev_post_id) - - let decodedPosts = try container.decode([String:Post].self, forKey: .posts) - posts = Array(decodedPosts.values) - } -} +public typealias PostsHandler = (_ postResponse: PostResponse?, _ threads: [PostThread]?, _ users: [User]?) -> Void extension Network { - public func fetchPostsForChannel(withId channelId: String, withSince since: Int64?, withServerUrl serverUrl: String, withIsCRTEnabled isCRTEnabled: Bool, withRootId rootId: String, completionHandler: @escaping ResponseHandler) { - - let additionalParams = isCRTEnabled ? "&collapsedThreads=true&collapsedThreadsExtended=true" : "" - - let endpoint: String - if (isCRTEnabled && !rootId.isEmpty) { - let queryParams = "?skipFetchThreads=false&perPage=60&fromCreatedAt=0&direction=up" - endpoint = "/posts/\(rootId)/thread\(queryParams)\(additionalParams)" - } else { - let queryParams = since == nil ? - "?page=0&per_page=\(POST_CHUNK_SIZE)" : - "?since=\(since!)" - endpoint = "/channels/\(channelId)/posts\(queryParams)\(additionalParams)" - } - let url = buildApiUrl(serverUrl, endpoint) - - return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) - } - public func createPost(serverUrl: String, channelId: String, message: String, fileIds: [String], completionHandler: @escaping ResponseHandler) { do { if !message.isEmpty || !fileIds.isEmpty { @@ -66,8 +26,8 @@ extension Network { url, withMethod: "POST", withBody: data, - withHeaders: headers, - withServerUrl: serverUrl, + andHeaders: headers, + forServerUrl: serverUrl, completionHandler: completionHandler ) } @@ -75,4 +35,131 @@ extension Network { } } + + public func fetchPosts(forChannelId channelId: String, andRootId rootId: String, havingCRTEnabled isCRTEnabled: Bool, withAlreadyLoadedProfiles loadedProfiles: [User], forServerUrl serverUrl: String, completionHandler: @escaping PostsHandler) { + let additionalParams = isCRTEnabled ? "&collapsedThreads=true&collapsedThreadsExtended=true" : "" + let receivingThreads = isCRTEnabled && !rootId.isEmpty + let endpoint: String + + let alreadyLoadedUserIds = loadedProfiles.map { $0.id } + var postResponse: PostResponse? = nil + + if receivingThreads { + let since = rootId.isEmpty ? nil : Database.default.queryLastPostInThread(withRootId: rootId, forServerUrl: serverUrl) + let queryParams = since == nil ? "?perPage=60&fromCreateAt=0&direction=up" : "?fromCreateAt=\(Int64(since!))&direction=down" + endpoint = "/posts/\(rootId)/thread\(queryParams)\(additionalParams)" + } else { + let since = Database.default.queryPostsSinceForChannel(withId: channelId, forServerUrl: serverUrl) + let queryParams = since == nil ? "?page=0&per_page=60" : "?since=\(Int64(since!))" + endpoint = "/channels/\(channelId)/posts\(queryParams)\(additionalParams)" + } + + let url = buildApiUrl(serverUrl, endpoint) + request(url, usingMethod: "GET", forServerUrl: serverUrl) {data, response, error in + if let data = data { + postResponse = try? JSONDecoder().decode(PostResponse.self, from: data) + } + + DispatchQueue.main.async { + self.processPostsFetched(postResponse, andAlreadyLoadedProfilesIds: alreadyLoadedUserIds, + usingCRT: isCRTEnabled, forServerUrl: serverUrl, + completionHandler: completionHandler) + } + } + } + + private func processPostsFetched(_ postResponse: PostResponse?, andAlreadyLoadedProfilesIds alreadyLoadedUserIds: [String], + usingCRT isCRTEnabled: Bool, forServerUrl serverUrl: String, completionHandler: @escaping PostsHandler) { + guard let currentUserRow = try? Database.default.queryCurrentUser(serverUrl), + let currentUser = Database.default.getUserFromRow(currentUserRow) + else { + completionHandler(nil, nil, nil) + return + } + + var users: [User]? = nil + var threads: [PostThread]? = nil + var threadParticipantUserIds: Set = Set() // Used to exclude the "userIds" present in the thread participants + var threadParticipantUsernames: Set = Set() // Used to exclude the "usernames" present in the thread participants + var threadParticipantUsers = [String: User]() // All unique users from thread participants are stored here + var userIdsToLoad: Set = Set() + var usernamesToLoad: Set = Set() + + if let postsWithKeys = postResponse?.posts { + let posts = Array(postsWithKeys.values) + for post in posts { + let authorId = post.userId + let message = post.message + + if isCRTEnabled && post.rootId.isEmpty { + if threads == nil { + threads = [PostThread]() + } + threads?.append(PostThread(from: post)) + } + + if let participants = post.participants { + for participant in participants { + let userId = participant.id + let username = participant.username + if userId != currentUser.id && !alreadyLoadedUserIds.contains(userId) && !threadParticipantUserIds.contains(userId) { + threadParticipantUserIds.insert(userId) + if threadParticipantUsers[userId] == nil { + threadParticipantUsers[userId] = participant + } + } + + if !username.isEmpty && username != currentUser.username && !threadParticipantUsernames.contains(username) { + threadParticipantUsernames.insert(username) + } + } + } + + if (authorId != currentUser.id && !alreadyLoadedUserIds.contains(authorId) && !threadParticipantUserIds.contains(authorId) && !userIdsToLoad.contains(authorId)) { + userIdsToLoad.insert(authorId) + } + + if !message.isEmpty { + for username in self.matchUsername(in: message) { + if username != currentUser.username && !threadParticipantUsernames.contains(username) && !usernamesToLoad.contains(username) { + usernamesToLoad.insert(username) + } + } + } + } + + if !threadParticipantUsers.isEmpty || !usernamesToLoad.isEmpty || !userIdsToLoad.isEmpty { + users = [User]() + } + + if !usernamesToLoad.isEmpty || !userIdsToLoad.isEmpty, + let profiles = Network.default.fetchNeededUsers(userIds: userIdsToLoad, usernames: usernamesToLoad, forServerUrl: serverUrl), + !profiles.isEmpty { + users?.append(contentsOf: profiles) + } + + if !threadParticipantUsers.isEmpty { + let storedParticipantsById = Database.default.queryUsers(byIds: threadParticipantUserIds, forServerUrl: serverUrl) + let storedParticipantsByUsername = Database.default.queryUsers(byUsernames: threadParticipantUsernames, forServerUrl: serverUrl) + let participantUsers = Array(threadParticipantUsers.values).filter{ !storedParticipantsById.contains($0.id) && !storedParticipantsByUsername.contains($0.username) } + if !participantUsers.isEmpty { + users?.append(contentsOf: participantUsers) + } + } + } + + completionHandler(postResponse, threads, users) + } + + private func matchUsername(in message: String) -> [String] { + let specialMentions = Set(["all", "here", "channel"]) + if let regex = try? NSRegularExpression(pattern: "\\B@(([a-z0-9-._]*[a-z0-9_])[.-]*)", options: [.caseInsensitive]) { + let results = regex.matches(in: message, range: _NSRange(message.startIndex..., in: message)) + if !results.isEmpty { + let username = results.map({ String(message[Range($0.range, in: message)!]).removePrefix("@") }).filter({ !specialMentions.contains($0)}) + return username + } + } + return [] + } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift new file mode 100644 index 0000000000..51aabfbee8 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Team.swift @@ -0,0 +1,36 @@ +import Foundation + +extension Network { + public func fetchTeamIfNeeded(withId teamId: String, forServerUrl serverUrl: String, completionHandler: @escaping ((_ team: Team?, _ myTeam: TeamMember?) -> Void)) { + let group = DispatchGroup() + var team: Team? = nil + var myTeam: TeamMember? = nil + + if !Database.default.queryTeamExists(withId: teamId, forServerUrl: serverUrl) { + group.enter() + + let url = buildApiUrl(serverUrl, "/teams/\(teamId)") + request(url, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in + if let data = data { + team = try? JSONDecoder().decode(Team.self, from: data) + } + group.leave() + } + } + + if !Database.default.queryMyTeamExists(withId: teamId, forServerUrl: serverUrl) { + group.enter() + let url = buildApiUrl(serverUrl, "/teams/\(teamId)/members/me") + request(url, usingMethod: "GET", forServerUrl: serverUrl) { data, response, error in + if let data = data { + myTeam = try? JSONDecoder().decode(TeamMember.self, from: data) + } + group.leave() + } + } + + group.notify(queue: .main) { + completionHandler(team, myTeam) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift new file mode 100644 index 0000000000..08eb79ddf0 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Thread.swift @@ -0,0 +1,22 @@ +import Foundation + +public typealias ThreadResponse = (_ threads: PostThread?) -> Void + +extension Network { + public func fetchThread(byId threadId: String, belongingToTeamId teamId: String, forServerUrl serverUrl: String, completionHandler: @escaping ThreadResponse) { + var thread: PostThread? = nil + guard let currentUserId = try? Database.default.queryCurrentUserId(serverUrl), + let threadTeamId = teamId.isEmpty ? Database.default.queryCurrentTeamId(serverUrl) : teamId + else { + completionHandler(nil) + return + } + let url = buildApiUrl(serverUrl, "/users/\(currentUserId)/teams/\(threadTeamId)/threads/\(threadId)") + request(url, usingMethod: "GET", forServerUrl: serverUrl) {data, response, error in + if let data = data { + thread = try? JSONDecoder().decode(PostThread.self, from: data) + } + completionHandler(thread) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift index 480430da80..be9d1b3f76 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network+Users.swift @@ -8,26 +8,71 @@ import Foundation extension Network { - public func fetchUsers(byIds userIds: [String], withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + public func fetchNeededUsers(userIds: Set, usernames: Set, forServerUrl serverUrl: String) -> [User]? { + let group = DispatchGroup() + var users: [User]? = nil + if !userIds.isEmpty || !usernames.isEmpty { + // remove existing users in the database + users = [User]() + let storedUserIds = Database.default.queryUsers(byIds: userIds, forServerUrl: serverUrl) + if !(userIds.filter{ !storedUserIds.contains($0) }).isEmpty { + group.enter() + DispatchQueue.global(qos: .default).async { + self.fetchUsers(byIds: Array(userIds), forServerUrl: serverUrl) {data, response, error in + if let data = data, + let profiles = try? JSONDecoder().decode([User].self, from: data) { + users?.append(contentsOf: profiles) + } + group.leave() + } + } + } + + let storedUsernames = Database.default.queryUsers(byUsernames: usernames, forServerUrl: serverUrl) + if !(usernames.filter{ !storedUsernames.contains($0) }).isEmpty { + group.enter() + DispatchQueue.global(qos: .default).async { + self.fetchUsers(byUsernames: Array(usernames), forServerUrl: serverUrl) {data, response, error in + if let data = data, + let profiles = try? JSONDecoder().decode([User].self, from: data) { + users?.append(contentsOf: profiles) + } + group.leave() + } + } + } + } + + group.wait() + return users + } + + public func fetchUsers(byIds userIds: [String], forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/ids" let url = buildApiUrl(serverUrl, endpoint) let data = try? JSONSerialization.data(withJSONObject: userIds, options: []) - return request(url, withMethod: "POST", withBody: data, withHeaders: nil, withServerUrl: serverUrl, completionHandler: completionHandler) + return request(url, withMethod: "POST", withBody: data, andHeaders: nil, forServerUrl: serverUrl, completionHandler: completionHandler) } - public func fetchUsers(byUsernames usernames: [String], withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + public func fetchUsers(byUsernames usernames: [String], forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/usernames" let url = buildApiUrl(serverUrl, endpoint) let data = try? JSONSerialization.data(withJSONObject: usernames, options: []) - return request(url, withMethod: "POST", withBody: data, withHeaders: nil, withServerUrl: serverUrl, completionHandler: completionHandler) + return request(url, withMethod: "POST", withBody: data, andHeaders: nil, forServerUrl: serverUrl, completionHandler: completionHandler) } - public func fetchUserProfilePicture(userId: String, lastUpdateAt: Double, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + public func fetchProfiles(inChannelId channelId: String, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + let endpoint = "/users?in_channel=\(channelId)&page=0&per_page=8&sort=" + let url = buildApiUrl(serverUrl, endpoint) + request(url, usingMethod: "GET", forServerUrl: serverUrl, completionHandler: completionHandler) + } + + public func fetchUserProfilePicture(userId: String, lastUpdateAt: Double, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { let endpoint = "/users/\(userId)/image?lastPictureUpdate=\(lastUpdateAt)" let url = buildApiUrl(serverUrl, endpoint) - return request(url, withMethod: "GET", withServerUrl: serverUrl, completionHandler: completionHandler) + return request(url, usingMethod: "GET", forServerUrl: serverUrl, completionHandler: completionHandler) } } diff --git a/ios/Gekidou/Sources/Gekidou/Networking/Network.swift b/ios/Gekidou/Sources/Gekidou/Networking/Network.swift index ac8dfbb0c6..a2d2a6090f 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/Network.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/Network.swift @@ -41,7 +41,7 @@ public class Network: NSObject { return (response as? HTTPURLResponse)?.statusCode == 200 } - internal func buildURLRequest(for url: URL, withMethod method: String, withBody body: Data?, withHeaders headers: [String:String]?, withServerUrl serverUrl: String) -> URLRequest { + internal func buildURLRequest(for url: URL, usingMethod method: String, withBody body: Data?, andHeaders headers: [String:String]?, forServerUrl serverUrl: String) -> URLRequest { let request = NSMutableURLRequest(url: url) request.httpMethod = method @@ -62,12 +62,12 @@ public class Network: NSObject { return request as URLRequest } - internal func request(_ url: URL, withMethod method: String, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { - return request(url, withMethod: method, withBody: nil, withHeaders: nil, withServerUrl: serverUrl, completionHandler: completionHandler) + internal func request(_ url: URL, usingMethod method: String, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + return request(url, withMethod: method, withBody: nil, andHeaders: nil, forServerUrl: serverUrl, completionHandler: completionHandler) } - internal func request(_ url: URL, withMethod method: String, withBody body: Data?, withHeaders headers: [String:String]?, withServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { - let urlRequest = buildURLRequest(for: url, withMethod: method, withBody: body, withHeaders: headers, withServerUrl: serverUrl) + internal func request(_ url: URL, withMethod method: String, withBody body: Data?, andHeaders headers: [String:String]?, forServerUrl serverUrl: String, completionHandler: @escaping ResponseHandler) { + let urlRequest = buildURLRequest(for: url, usingMethod: method, withBody: body, andHeaders: headers, forServerUrl: serverUrl) let task = session!.dataTask(with: urlRequest) { data, response, error in completionHandler(data, response, error) @@ -76,26 +76,3 @@ public class Network: NSObject { task.resume() } } - -extension Network: URLSessionDelegate, URLSessionTaskDelegate { - public func urlSession(_ session: URLSession, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - var credential: URLCredential? = nil - var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling - - let authMethod = challenge.protectionSpace.authenticationMethod - if authMethod == NSURLAuthenticationMethodClientCertificate { - let host = task.currentRequest!.url!.host! - if let (identity, certificate) = try? Keychain.default.getClientIdentityAndCertificate(for: host) { - credential = URLCredential(identity: identity, - certificates: [certificate], - persistence: URLCredential.Persistence.permanent) - } - disposition = .useCredential - } - - completionHandler(disposition, credential) - } -} diff --git a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift deleted file mode 100644 index f500237a8d..0000000000 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ /dev/null @@ -1,347 +0,0 @@ -import Foundation -import UserNotifications -import SQLite -import os.log - -public struct AckNotification: Codable { - let type: String - let id: String - let postId: String? - public let serverUrl: String - public let isIdLoaded: Bool - let receivedAt:Int - let platform = "ios" - - public enum AckNotificationKeys: String, CodingKey { - case type = "type" - case id = "ack_id" - case postId = "post_id" - case server_id = "server_id" - case isIdLoaded = "id_loaded" - case platform = "platform" - } - - public enum AckNotificationRequestKeys: String, CodingKey { - case type = "type" - case id = "id" - case postId = "post_id" - case isIdLoaded = "is_id_loaded" - case receivedAt = "received_at" - case platform = "platform" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: AckNotificationKeys.self) - id = try container.decode(String.self, forKey: .id) - type = try container.decode(String.self, forKey: .type) - postId = try? container.decode(String.self, forKey: .postId) - if container.contains(.isIdLoaded) { - isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true - } else { - isIdLoaded = false - } - receivedAt = Date().millisecondsSince1970 - - if let decodedIdentifier = try? container.decode(String.self, forKey: .server_id) { - serverUrl = try Database.default.getServerUrlForServer(decodedIdentifier) - } else { - serverUrl = try Database.default.getOnlyServerUrl() - } - } -} - -extension AckNotification { - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: AckNotificationRequestKeys.self) - try container.encode(id, forKey: .id) - try container.encode(postId, forKey: .postId) - try container.encode(receivedAt, forKey: .receivedAt) - try container.encode(platform, forKey: .platform) - try container.encode(type, forKey: .type) - try container.encode(isIdLoaded, forKey: .isIdLoaded) - } -} - -extension String { - func removePrefix(_ prefix: String) -> String { - guard self.hasPrefix(prefix) else { return self } - return String(self.dropFirst(prefix.count)) - } - - func toUrlSafeBase64Encode() -> String { - return Data( - self.replacingOccurrences(of: "/\\+/g", with: "-", options: .regularExpression) - .replacingOccurrences(of: "/\\//g", with: "_", options: .regularExpression) - .utf8 - ).base64EncodedString() - } -} - -extension Network { - @objc public func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { - if let jsonData = try? JSONSerialization.data(withJSONObject: userInfo), - let ackNotification = try? JSONDecoder().decode(AckNotification.self, from: jsonData) { - postNotificationReceipt(ackNotification, completionHandler: {_, _, _ in}) - } - } - - private func matchUsername(in message: String) -> [String] { - let specialMentions = Set(["all", "here", "channel"]) - if let regex = try? NSRegularExpression(pattern: "\\B@(([a-z0-9-._]*[a-z0-9_])[.-]*)", options: [.caseInsensitive]) { - let results = regex.matches(in: message, range: _NSRange(message.startIndex..., in: message)) - if !results.isEmpty { - let username = results.map({ String(message[Range($0.range, in: message)!]).removePrefix("@") }).filter({ !specialMentions.contains($0)}) - return username - } - } - return [] - } - - public func fetchProfileImageSync(_ serverUrl: String, senderId: String, overrideIconUrl: String?, completionHandler: @escaping (_ data: Data?) -> Void) { - var updatedAt: Double = 0 - func processResponse(data: Data?, response: URLResponse?, error: Error?) { - if let httpResponse = response as? HTTPURLResponse { - if (httpResponse.statusCode == 200 && error == nil) { - ImageCache.default.insertImage(data, for: senderId, updatedAt: updatedAt, onServer: serverUrl) - completionHandler(data) - } else { - os_log( - OSLogType.default, - "Mattermost Notifications: Request for profile image failed with status %{public}@ and error %{public}@", - httpResponse.statusCode, - (error?.localizedDescription ?? "") - ) - } - } - } - - if let overrideUrl = overrideIconUrl, - let url = URL(string: overrideUrl) { - request(url, withMethod: "GET", withServerUrl: "", completionHandler: processResponse) - } else { - if let lastUpdateAt = Database.default.getUserLastPictureAt(for: senderId, withServerUrl: serverUrl) { - updatedAt = lastUpdateAt - } - if let image = ImageCache.default.image(for: senderId, updatedAt: updatedAt, onServer: serverUrl) { - os_log(OSLogType.default, "Mattermost Notifications: cached image") - completionHandler(image) - } else { - ImageCache.default.removeImage(for: senderId, onServer: serverUrl) - os_log(OSLogType.default, "Mattermost Notifications: image not cached") - fetchUserProfilePicture(userId: senderId, lastUpdateAt: updatedAt, withServerUrl: serverUrl, completionHandler: processResponse) - } - } - } - - public func postNotificationReceipt(_ ackNotification: AckNotification, completionHandler: @escaping ResponseHandler) { - do { - let jsonData = try JSONEncoder().encode(ackNotification) - let headers = ["Content-Type": "application/json; charset=utf-8"] - let endpoint = "/notifications/ack" - let url = buildApiUrl(ackNotification.serverUrl, endpoint) - request(url, withMethod: "POST", withBody: jsonData, withHeaders: headers, withServerUrl: ackNotification.serverUrl, completionHandler: completionHandler) - } catch { - - } - } - - public func fetchAndStoreDataForPushNotification(_ notification: UNMutableNotificationContent, withContentHandler contentHandler: ((UNNotificationContent) -> Void)?) { - let operation = BlockOperation { - let group = DispatchGroup() - - let channelId = notification.userInfo["channel_id"] as? String - let rootId = notification.userInfo["root_id"] as? String ?? "" - let serverUrl = notification.userInfo["server_url"] as? String - let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false - - guard let serverUrl = serverUrl, - let channelId = channelId - else { return } - - let currentUser = try? Database.default.queryCurrentUser(serverUrl) - let currentUserId = currentUser?[Expression("id")] - let currentUsername = currentUser?[Expression("username")] - - - var postData: PostData? = nil - var myChannelData: ChannelMemberData? = nil - var threadData: ThreadData? = nil - var threads: [Post] = [] - var userIdsToLoad: Set = Set() - var usernamesToLoad: Set = Set() - var users: Set = Set() - - if isCRTEnabled && !rootId.isEmpty { - // Fetch the thread mentions - let teamId = Gekidou.Database.default.queryTeamIdForChannel(withId: channelId, withServerUrl: serverUrl) ?? "" - - if !teamId.isEmpty { - group.enter() - self.fetchThreadMentions(teamId: teamId, threadId: rootId, withServerUrl: serverUrl, completionHandler: {data, response, error in - if self.responseOK(response), let data = data { - threadData = try? JSONDecoder().decode(ThreadData.self, from: data) - } - group.leave() - }) - } - } else { - // Fetch the channel mentions - group.enter() - self.fetchChannelMentions(channelId: channelId, withServerUrl: serverUrl, completionHandler: { data, response, error in - if self.responseOK(response), let data = data { - myChannelData = try? JSONDecoder().decode(ChannelMemberData.self, from: data) - } - group.leave() - }) - } - - - group.enter() - let since = try? Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl) - self.fetchPostsForChannel(withId: channelId, withSince: since, withServerUrl: serverUrl, withIsCRTEnabled: isCRTEnabled, withRootId: rootId) { data, response, error in - if self.responseOK(response), let data = data, - let jsonData = try? JSONDecoder().decode(PostData.self, from: data) { - postData = jsonData - if jsonData.posts.count > 0 { - var authorIds: Set = Set() - var usernames: Set = Set() - - var threadParticipantUserIds: Set = Set() // Used to exclude the "userIds" present in the thread participants - var threadParticipantUsernames: Set = Set() // Used to exclude the "usernames" present in the thread participants - var threadParticipantUsers = [String: User]() // All unique users from thread participants are stored here - - jsonData.posts.forEach{post in - if (currentUserId != nil && post.user_id != currentUserId) { - authorIds.insert(post.user_id) - } - self.matchUsername(in: post.message).forEach{ - if ($0 != currentUsername) { - usernames.insert($0) - } - } - - if (isCRTEnabled) { - // Add root post as a thread - let rootId = post.root_id - if (rootId.isEmpty) { - threads.append(post) - } - - let participants = post.participants ?? [] - if (participants.count > 0) { - participants.forEach { participant in - let userId = participant.id - if (userId != currentUserId) { - threadParticipantUserIds.insert(userId) - if (threadParticipantUsers[userId] == nil) { - threadParticipantUsers[userId] = participant - } - } - - let username = participant.username - if (username != "" && username != currentUsername) { - threadParticipantUsernames.insert(username) - } - } - } - } - } - - if (authorIds.count > 0) { - if let existingIds = try? Database.default.queryUsers(byIds: authorIds, withServerUrl: serverUrl) { - userIdsToLoad = authorIds.filter { !existingIds.contains($0) } - // Filter the users found in the thread participants list - if (threadParticipantUserIds.count > 0) { - userIdsToLoad = userIdsToLoad.filter{ !threadParticipantUserIds.contains($0) } - } - if (userIdsToLoad.count > 0) { - group.enter() - self.fetchUsers(byIds: Array(userIdsToLoad), withServerUrl: serverUrl) { data, response, error in - if self.responseOK(response), let data = data { - let usersData = try! JSONDecoder().decode([User].self, from: data) - usersData.forEach { users.insert($0) } - } - group.leave() - } - } - } - } - - if (usernames.count > 0) { - if let existingUsernames = try? Database.default.queryUsers(byUsernames: usernames, withServerUrl: serverUrl) { - usernamesToLoad = usernames.filter{ !existingUsernames.contains($0)} - // Filter the users found in the thread participants list - if (threadParticipantUsernames.count > 0) { - usernamesToLoad = usernamesToLoad.filter{ !threadParticipantUsernames.contains($0) } - } - if (usernamesToLoad.count > 0) { - group.enter() - - self.fetchUsers(byUsernames: Array(usernamesToLoad), withServerUrl: serverUrl) { data, response, error in - if self.responseOK(response), let data = data { - let usersData = try! JSONDecoder().decode([User].self, from: data) - usersData.forEach { users.insert($0) } - } - group.leave() - } - } - } - } - - if (threadParticipantUserIds.count > 0) { - if let existingThreadParticipantUserIds = try? Database.default.queryUsers(byIds: threadParticipantUserIds, withServerUrl: serverUrl) { - threadParticipantUsers.forEach { (userId: String, user: User) in - if (!existingThreadParticipantUserIds.contains(userId)) { - users.insert(user) - } - } - } - - } - - } - } - - group.leave() - } - - group.wait() - - group.enter() - if let data = postData, - let posts = data.posts as [Post]?, - let db = try? Database.default.getDatabaseForServer(serverUrl), - posts.count > 0 { - let receivingThreads = isCRTEnabled && !rootId.isEmpty - try? db.transaction { - try? Database.default.handlePostData(db, data, channelId, since != nil, receivingThreads) - - if threads.count > 0 { - try? Database.default.handleThreads(db, threads) - } - - if users.count > 0 { - try? Database.default.insertUsers(db, users) - } - - if let myChannel = myChannelData { - try? Database.default.handleMyChannelMentions(db, myChannel, withCRTEnabled: isCRTEnabled) - } - - if let threads = threadData { - try? Database.default.handleThreadMentions(db, threads) - } - } - } - group.leave() - - if let contentHandler = contentHandler { - // Get the total mentions from all databases and set the badge icon - notification.badge = Gekidou.Database.default.getTotalMentions() as NSNumber - contentHandler(notification) - } - } - - queue.addOperation(operation) - } -} diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift new file mode 100644 index 0000000000..edaadf996c --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/AckNotification.swift @@ -0,0 +1,58 @@ +import Foundation + +public struct AckNotification: Codable { + let type: String + let id: String + let postId: String? + public let serverUrl: String + public let isIdLoaded: Bool + let receivedAt:Int + let platform = "ios" + + public enum AckNotificationKeys: String, CodingKey { + case type = "type" + case id = "ack_id" + case postId = "post_id" + case server_id = "server_id" + case isIdLoaded = "id_loaded" + case platform = "platform" + } + + public enum AckNotificationRequestKeys: String, CodingKey { + case type = "type" + case id = "id" + case postId = "post_id" + case isIdLoaded = "is_id_loaded" + case receivedAt = "received_at" + case platform = "platform" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: AckNotificationKeys.self) + id = try container.decode(String.self, forKey: .id) + type = try container.decode(String.self, forKey: .type) + postId = try? container.decode(String.self, forKey: .postId) + if container.contains(.isIdLoaded) { + isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true + } else { + isIdLoaded = false + } + receivedAt = Date().millisecondsSince1970 + + if let decodedIdentifier = try? container.decode(String.self, forKey: .server_id) { + serverUrl = try Database.default.getServerUrlForServer(decodedIdentifier) + } else { + serverUrl = try Database.default.getOnlyServerUrl() + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: AckNotificationRequestKeys.self) + try container.encode(id, forKey: .id) + try container.encode(postId, forKey: .postId) + try container.encode(receivedAt, forKey: .receivedAt) + try container.encode(platform, forKey: .platform) + try container.encode(type, forKey: .type) + try container.encode(isIdLoaded, forKey: .isIdLoaded) + } +} diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Category.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Category.swift new file mode 100644 index 0000000000..1250bfc709 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+Category.swift @@ -0,0 +1,37 @@ +import Foundation + +extension PushNotification { + func addChannelToDefaultCategoryIfNeeded(_ channel: Channel, forServerUrl serverUrl: String) -> [CategoryChannel]? { + var categoryChannels = [CategoryChannel]() + if channel.type == "D" || channel.type == "G" { + if let teamIds = Database.default.queryAllMyTeamIds(serverUrl) { + for teamId in teamIds { + if let item = categoryChannelForTeam(channelId: channel.id, teamId: teamId, type: "direct_messages", forServerUrl: serverUrl) { + categoryChannels.append(item) + } + } + return categoryChannels + } + } else if let item = categoryChannelForTeam(channelId: channel.id, teamId: channel.teamId, type: "channels", forServerUrl: serverUrl) { + categoryChannels.append(item) + return categoryChannels + } + return nil + } + + private func categoryChannelForTeam(channelId: String, teamId: String, type: String, forServerUrl serverUrl: String) -> CategoryChannel? { + guard !teamId.isEmpty, + let categoryId = Database.default.queryCategoryId(inTeamId: teamId, type: type, forServerUrl: serverUrl) else { return nil } + + let cc = Database.default.queryCategoryChannelId(inCategoryId: categoryId, channelId: channelId, forServerUrl: serverUrl) + if cc == nil { + return CategoryChannel( + id: "\(teamId)_\(channelId)", + categoryId: categoryId, + channelId: channelId + ) + } + + return nil + } +} diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift new file mode 100644 index 0000000000..4a2fb1735c --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+FetchData.swift @@ -0,0 +1,206 @@ +import Foundation +import os.log +import SQLite +import UserNotifications + +public struct PushNotificationData: Encodable { + public var categories: CategoriesWithOrder? = nil + public var categoryChannels: [CategoryChannel]? = nil + public var channel: Channel? = nil + public var myChannel: ChannelMember? = nil + public var team: Team? = nil + public var myTeam: TeamMember? = nil + public var posts: PostResponse? = nil + public var users = [User]() + public var threads: [PostThread]? = nil + + public enum PushNotificationDataKeys: String, CodingKey { + case categories, categoryChannels, channel, myChannel, team, myTeam, posts, users, threads + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: PushNotificationDataKeys.self) + try container.encodeIfPresent(self.categories, forKey: .categories) + try container.encodeIfPresent(self.categoryChannels, forKey: .categoryChannels) + try container.encodeIfPresent(self.channel, forKey: .channel) + try container.encodeIfPresent(self.myChannel, forKey: .myChannel) + try container.encodeIfPresent(self.team, forKey: .team) + try container.encodeIfPresent(self.myTeam, forKey: .myTeam) + try container.encodeIfPresent(self.posts, forKey: .posts) + try container.encode(self.users, forKey: .users) + try container.encodeIfPresent(self.threads, forKey: .threads) + } +} + +extension PushNotification { + public func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHander: @escaping ((_ data: PushNotificationData) -> Void)) { + let operation = BlockOperation { + os_log(OSLogType.default, "Mattermost Notifications: Fetch notification data.") + let fetchGroup = DispatchGroup() + + let teamId = notification["team_id"] as? String ?? "" + let channelId = notification["channel_id"] as? String + let postId = notification["post_id"] as? String + let rootId = notification["root_id"] as? String ?? "" + var serverUrl = notification["server_url"] as? String + let serverId = notification["server_id"] as? String + let isCRTEnabled = notification["is_crt_enabled"] as? Bool ?? false + + if let serverId = serverId, + serverUrl == nil { + serverUrl = try? Database.default.getServerUrlForServer(serverId) + } + + guard let serverUrl = serverUrl, + let channelId = channelId, + let _ = postId + else { return } + + var notificationData = PushNotificationData() + if !teamId.isEmpty { + fetchGroup.enter() + Network.default.fetchTeamIfNeeded(withId: teamId, forServerUrl: serverUrl) { team, myTeam in + notificationData.team = team + notificationData.myTeam = myTeam + fetchGroup.leave() + } + } + + fetchGroup.enter() + Network.default.fetchMyChannel(withId: channelId, forServerUrl: serverUrl) { channel, myChannel, profiles in + notificationData.channel = channel + notificationData.myChannel = myChannel + + if let profiles = profiles { + notificationData.users.append(contentsOf: profiles) + } + fetchGroup.leave() + } + + if let _ = notificationData.myTeam, + !teamId.isEmpty { + fetchGroup.enter() + Network.default.fetchCategories(withTeamId: teamId, forServerUrl: serverUrl) { categoriesWithOrder in + if let categoriesWithOrder = categoriesWithOrder { + notificationData.categories = categoriesWithOrder + } + fetchGroup.leave() + } + } else if let channel = notificationData.channel { + if let categoryChannels = self.addChannelToDefaultCategoryIfNeeded(channel, forServerUrl: serverUrl) { + notificationData.categoryChannels = categoryChannels + } + } + + fetchGroup.enter() + Network.default.fetchPosts(forChannelId: channelId, andRootId: rootId, havingCRTEnabled: isCRTEnabled, withAlreadyLoadedProfiles: notificationData.users, forServerUrl: serverUrl) { postResponse, threads, users in + notificationData.posts = postResponse + notificationData.threads = threads + if let users = users { + notificationData.users.append(contentsOf: users) + } + fetchGroup.leave() + } + + fetchGroup.notify(queue: DispatchQueue.main) { + if isCRTEnabled && !rootId.isEmpty { + Network.default.fetchThread(byId: rootId, belongingToTeamId: teamId, forServerUrl: serverUrl) { thread in + if let thread = thread { + if notificationData.threads == nil { + notificationData.threads = [thread] + } + if let threads = notificationData.threads, + let index = threads.firstIndex(where: { $0.id == thread.id }) { + var copy = threads[index] + copy.unreadMentions = thread.unreadMentions + copy.unreadReplies = thread.unreadReplies + copy.lastReplyAt = thread.lastReplyAt + copy.lastViewedAt = thread.lastViewedAt + notificationData.threads?[index] = copy + } + } + contentHander(notificationData); + } + } else { + contentHander(notificationData); + } + } + } + + queue.addOperation(operation) + } + + public func fetchAndStoreDataForPushNotification(_ notification: UNMutableNotificationContent, withContentHandler contentHandler: @escaping ((UNNotificationContent) -> Void)) { + guard let serverUrl = notification.userInfo["server_url"] as? String, + let channelId = notification.userInfo["channel_id"] as? String + else { + contentHandler(notification) + return + } + + let isCRTEnabled = notification.userInfo["is_crt_enabled"] as? Bool ?? false + let rootId = notification.userInfo["root_id"] as? String ?? "" + let teamId = notification.userInfo["team_id"] as? String ?? "" + + fetchDataForPushNotification(notification.userInfo) { data in + if let db = try? Database.default.getDatabaseForServer(serverUrl) { + try? db.transaction { + let receivingThreads = isCRTEnabled && !rootId.isEmpty + if let team = data.team { + try? Database.default.insertTeam(db, team) + } + + if let myTeam = data.myTeam { + try? Database.default.insertMyTeam(db, myTeam) + } + + if let categories = data.categories { + try? Database.default.insertCategoriesWithChannels(db, categories.categories) + } + + if let categoryChannels = data.categoryChannels, + !categoryChannels.isEmpty { + try? Database.default.insertChannelToDefaultCategory(db, categoryChannels) + } + + if let channel = data.channel, + !Database.default.queryChannelExists(withId: channel.id, forServerUrl: serverUrl) { + try? Database.default.insertChannel(db, channel) + } + + if var myChannel = data.myChannel { + var lastFetchedAt: Double = 0 + if let postResponse = data.posts, !receivingThreads { + let posts = Array(postResponse.posts.values) + if let fetchedAt = posts.map({max($0.createAt, $0.updateAt, $0.deleteAt)}).max() { + lastFetchedAt = fetchedAt + } + } + var lastPostAt: Double = 0 + if let channel = data.channel { + lastPostAt = isCRTEnabled ? channel.lastRootPostAt : channel.lastPostAt + myChannel.internalMsgCount = channel.totalMsgCount - myChannel.msgCount + myChannel.internalMsgCountRoot = channel.totalMsgCountRoot - myChannel.msgCountRoot + } + try? Database.default.insertOrUpdateMyChannel(db, myChannel, isCRTEnabled, lastFetchedAt, lastPostAt) + } + + if let posts = data.posts { + try? Database.default.handlePostData(db, posts, channelId, receivingThreads) + } + + if let threads = data.threads { + try? Database.default.handleThreads(db, threads, forTeamId: teamId) + } + + if !data.users.isEmpty { + try? Database.default.insertUsers(db, data.users) + } + } + } + + notification.badge = Gekidou.Database.default.getTotalMentions() as NSNumber + contentHandler(notification) + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift new file mode 100644 index 0000000000..c11672e50b --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification+ProfileImage.swift @@ -0,0 +1,43 @@ +import Foundation +import os.log + +extension PushNotification { + public func fetchProfileImageSync(_ serverUrl: String, senderId: String, overrideIconUrl: String?, completionHandler: @escaping (_ data: Data?) -> Void) { + var updatedAt: Double = 0 + func processResponse(data: Data?, response: URLResponse?, error: Error?) { + if let httpResponse = response as? HTTPURLResponse { + if (httpResponse.statusCode == 200 && error == nil) { + ImageCache.default.insertImage(data, for: senderId, updatedAt: updatedAt, forServer: serverUrl) + completionHandler(data) + } else { + os_log( + OSLogType.default, + "Mattermost Notifications: Request for profile image failed with status %{public}@ and error %{public}@", + httpResponse.statusCode, + (error?.localizedDescription ?? "") + ) + } + } + } + + if let overrideUrl = overrideIconUrl, + let url = URL(string: overrideUrl) { + Network.default.request(url, usingMethod: "GET", forServerUrl: "", completionHandler: processResponse) + } else { + if let lastUpdateAt = Database.default.getUserLastPictureAt(for: senderId, forServerUrl: serverUrl) { + updatedAt = lastUpdateAt + } + if let image = ImageCache.default.image(for: senderId, updatedAt: updatedAt, forServer: serverUrl) { + os_log(OSLogType.default, "Mattermost Notifications: cached image") + completionHandler(image) + } else { + ImageCache.default.removeImage(for: senderId, forServer: serverUrl) + os_log(OSLogType.default, "Mattermost Notifications: image not cached") + Network.default.fetchUserProfilePicture( + userId: senderId, lastUpdateAt: updatedAt, + forServerUrl: serverUrl, completionHandler: processResponse + ) + } + } + } +} diff --git a/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift new file mode 100644 index 0000000000..aefbc389fb --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/PushNotification/PushNotification.swift @@ -0,0 +1,118 @@ +import Foundation +import UserNotifications +import os.log + +public class PushNotification: NSObject { + @objc public static let `default` = PushNotification() + let fibonacciBackoffsInSeconds = [1.0, 2.0, 3.0, 5.0, 8.0] + private var retryIndex = 0 + let queue = OperationQueue() + + public override init() { + queue.maxConcurrentOperationCount = 1 + } + + @objc public func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { + let notification = UNMutableNotificationContent() + notification.userInfo = userInfo + postNotificationReceipt(notification, completionHandler: {_ in}) + } + + public func postNotificationReceipt(_ notification: UNMutableNotificationContent, completionHandler: @escaping (_ notification: UNMutableNotificationContent?) -> Void) { + if let jsonData = try? JSONSerialization.data(withJSONObject: notification.userInfo), + let ackNotification = try? JSONDecoder().decode(AckNotification.self, from: jsonData) { + postNotificationReceiptWithRetry(ackNotification) { data in + os_log( + OSLogType.default, + "Mattermost Notifications: process receipt response for serverUrl %{public}@", + ackNotification.serverUrl + ) + + guard let data = data else { + os_log( + OSLogType.default, + "Mattermost Notifications: process receipt response for serverUrl %{public}@ does not contain data", + ackNotification.serverUrl + ) + completionHandler(notification) + return + } + notification.userInfo["server_url"] = ackNotification.serverUrl + if let idLoadedNotification = self.parseIdLoadedNotification(data) { + if let body = idLoadedNotification["message"] as? String { + notification.body = body + } + + if let title = idLoadedNotification["channel_name"] as? String { + notification.title = title + } + + for (key, value) in idLoadedNotification { + notification.userInfo[key] = value + } + } + completionHandler(notification) + } + return + } + os_log(OSLogType.default, "Mattermost Notifications: Could not parse ACK notification") + completionHandler(nil) + } + + private func postNotificationReceiptWithRetry(_ ackNotification: AckNotification, completionHandler: @escaping (_ data: Data?) -> Void) { + if (self.retryIndex >= self.fibonacciBackoffsInSeconds.count) { + os_log(OSLogType.default, "Mattermost Notifications: max retries reached. Will call sendMessageIntent") + completionHandler(nil) + return + } + + do { + let jsonData = try JSONEncoder().encode(ackNotification) + let headers = ["Content-Type": "application/json; charset=utf-8"] + let endpoint = "/notifications/ack" + let url = Network.default.buildApiUrl(ackNotification.serverUrl, endpoint) + Network.default.request( + url, withMethod: "POST", withBody: jsonData, + andHeaders: headers, forServerUrl: ackNotification.serverUrl) { data, response, error in + if (error != nil && ackNotification.isIdLoaded) { + let backoffInSeconds = self.fibonacciBackoffsInSeconds[self.retryIndex] + self.retryIndex += 1 + + DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: {[weak self] in + os_log( + OSLogType.default, + "Mattermost Notifications: receipt retrieval failed. Retry %{public}@", + String(describing: self?.retryIndex) + ) + self?.postNotificationReceiptWithRetry(ackNotification, completionHandler: completionHandler) + }) + + return + } + + completionHandler(data) + } + } catch { + os_log(OSLogType.default, "Mattermost Notifications: receipt failed %{public}@", error.localizedDescription) + } + } + + private func parseIdLoadedNotification(_ data: Data?) -> [AnyHashable:Any]? { + if let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + os_log(OSLogType.default, "Mattermost Notifications: parsed json response") + var userInfo = [AnyHashable:Any]() + let userInfoKeys = ["channel_name", "team_id", "sender_id", "sender_name", "root_id", "override_username", "override_icon_url", "from_webhook", "message"] + for key in userInfoKeys { + if let value = json[key] as? String { + userInfo[key] = value + } + } + + return userInfo + } + + return nil + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Category.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Category.swift new file mode 100644 index 0000000000..8d164f271e --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Category.swift @@ -0,0 +1,111 @@ +import Foundation +import SQLite + +extension Database { + public func queryCategoryId(inTeamId teamId: String, type: String, forServerUrl serverUrl: String) -> String? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let teamIdCol = Expression("team_id") + let typeCol = Expression("type") + let query = categoryTable.where(teamIdCol == teamId && typeCol == type) + if let result = try? db.pluck(query) { + return try? result.get(idCol) + } + } + + return nil + } + + public func queryCategoryChannelId(inCategoryId categoryId: String, channelId: String, forServerUrl serverUrl: String) -> String? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let categoryIdCol = Expression("category_id") + let channelIdCol = Expression("channel_id") + let query = categoryChannelTable.where(categoryIdCol == categoryId && channelIdCol == channelId) + if let result = try? db.pluck(query) { + return try? result.get(idCol) + } + } + return nil + } + + + public func insertCategoriesWithChannels(_ db: Connection, _ categoriesWithChannels: [Category]) throws { + let categories = createCategoriesSetter(from: categoriesWithChannels) + let categoryChannels = createCategoryChannelsSetter(from: categoriesWithChannels) + try db.run(categoryTable.insertMany(or: .replace, categories)) + try db.run(categoryChannelTable.insertMany(or: .replace, categoryChannels)) + } + + public func insertChannelToDefaultCategory(_ db: Connection, _ categoryChannels: [CategoryChannel]) throws { + let categoryId = Expression("category_id") + for cc in categoryChannels { + let count = (try? db.scalar(categoryChannelTable.where(categoryId == cc.categoryId).count)) ?? 0 + let setter = createCategoryChannelsSetter(from: cc, index: count > 0 ? count + 1 : 0) + try db.run(categoryChannelTable.insert(or: .replace, setter)) + } + } + + private func createCategoriesSetter(from categories: [Category]) -> [[Setter]] { + let id = Expression("id") + let collapsed = Expression("collapsed") + let displayName = Expression("display_name") + let muted = Expression("muted") + let sortOrder = Expression("sort_order") + let sorting = Expression("sorting") + let teamId = Expression("team_id") + let type = Expression("type") + + var setters = [[Setter]]() + for category in categories { + var setter = [Setter]() + setter.append(id <- category.id) + setter.append(collapsed <- category.collapsed) + setter.append(displayName <- category.displayName) + setter.append(muted <- category.muted) + setter.append(sortOrder <- category.sortOrder / 10) + setter.append(sorting <- category.sorting) + setter.append(teamId <- category.teamId) + setter.append(type <- category.type) + setters.append(setter) + } + + return setters + } + + private func createCategoryChannelsSetter(from categories: [Category]) -> [[Setter]] { + let id = Expression("id") + let categoryId = Expression("category_id") + let channelId = Expression("channel_id") + let sortOrder = Expression("sort_order") + + var setters = [[Setter]]() + for category in categories { + for (index, chId) in category.channelIds.enumerated() { + var setter = [Setter]() + setter.append(id <- "\(category.teamId)_\(chId)") + setter.append(categoryId <- category.id) + setter.append(channelId <- chId) + setter.append(sortOrder <- index) + setters.append(setter) + } + } + + return setters + } + + private func createCategoryChannelsSetter(from categoryChannel: CategoryChannel, index: Int = 0) -> [Setter] { + let id = Expression("id") + let categoryId = Expression("category_id") + let channelId = Expression("channel_id") + let sortOrder = Expression("sort_order") + + var setter = [Setter]() + setter.append(id <- categoryChannel.id) + setter.append(categoryId <- categoryChannel.categoryId) + setter.append(channelId <- categoryChannel.channelId) + setter.append(sortOrder <- index) + + return setter + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift index 2ab38dc48f..a5a276a2f3 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift @@ -40,6 +40,27 @@ extension Database { } } + public func queryChannelExists(withId channelId: String, forServerUrl serverUrl: String) -> Bool { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let query = channelTable.where(idCol == channelId) + if let _ = try? db.pluck(query) { + return true + } + } + return false + } + + public func hasMyChannel(_ db: Connection, channelId: String) -> Bool { + let idCol = Expression("id") + let query = myChannelTable.where(idCol == channelId) + if let _ = try? db.pluck(query) { + return true + } + + return false + } + public func getCurrentChannelWithTeam(_ serverUrl: String) -> T? { do { let channelId = try queryCurrentChannelId(serverUrl) @@ -186,4 +207,131 @@ extension Database { return [] } } + + public func insertChannel(_ db: Connection, _ channel: Channel) throws { + let setter = createChannelSetter(from: channel) + try db.run(channelTable.insert(or: .replace, setter)) + let channelInfo = createChannelInfoSetter(from: channel) + try db.run(channelInfoTable.insert(or: .replace, channelInfo)) + } + + public func insertOrUpdateMyChannel(_ db: Connection, _ myChannel: ChannelMember, _ isCRTEnabled: Bool, _ lastFetchedAt: Double, _ lastPostAt: Double) throws { + let idCol = Expression("id") + let messageCountCol = Expression("message_count") + let mentionsCol = Expression("mentions_count") + let isUnreadCol = Expression("is_unread") + let lastFetchedAtCol = Expression("last_fetched_at") + let lastPostAtCol = Expression("last_post_at") + let mentionsCount = isCRTEnabled ? myChannel.mentionCountRoot : myChannel.mentionCount + let messageCount = isCRTEnabled ? myChannel.internalMsgCountRoot : myChannel.internalMsgCount + let isUnread = messageCount > 0 + + if hasThread(db, threadId: myChannel.id) { + let updateQuery = myChannelTable.where(idCol == myChannel.id) + .update( + messageCountCol <- messageCount, + mentionsCol <- mentionsCount, + isUnreadCol <- isUnreadCol, + lastPostAtCol <- lastPostAt, + lastFetchedAtCol <- lastFetchedAt + ) + let _ = try db.run(updateQuery) + } else { + let rolesCol = Expression("roles") + let manuallyUnreadCol = Expression("manually_unread") + let lastViewedAtCol = Expression("last_viewed_at") + let viewedAtCol = Expression("viewed_at") + + let setter: [Setter] = [ + idCol <- myChannel.id, + mentionsCol <- mentionsCount, + messageCountCol <- messageCount, + lastPostAtCol <- lastPostAt, + lastViewedAtCol <- myChannel.lastViewedAt, + viewedAtCol <- 0, + lastFetchedAtCol <- lastFetchedAt, + isUnreadCol <- isUnread, + manuallyUnreadCol <- false, + rolesCol <- myChannel.roles, + ] + let _ = try db.run(myChannelTable.insert(or: .replace, setter)) + try insertMyChannelSettings(db, myChannel) + try insertChannelMember(db, myChannel) + } + } + + private func insertMyChannelSettings(_ db: Connection, _ myChannel: ChannelMember) throws { + let id = Expression("id") + let notifyProps = Expression("notify_props") + + let setter: [Setter] = [ + id <- myChannel.id, + notifyProps <- myChannel.notifyProps, + ] + + let _ = try db.run(myChannelSettingsTable.insert(or: .replace, setter)) + } + + private func insertChannelMember(_ db: Connection, _ member: ChannelMember) throws { + let id = Expression("id") + let channelId = Expression("channel_id") + let userId = Expression("user_id") + let schemeAdmin = Expression("scheme_admin") + + let setter: [Setter] = [ + id <- "\(member.id)-\(member.userId)", + channelId <- member.id, + userId <- member.userId, + schemeAdmin <- member.schemeAdmin, + ] + + let _ = try db.run(channelMembershipTable.insert(or: .replace, setter)) + } + + private func createChannelSetter(from channel: Channel) -> [Setter] { + let id = Expression("id") + let createAt = Expression("create_at") + let deleteAt = Expression("delete_at") + let updateAt = Expression("update_at") + let creatorId = Expression("creator_id") + let displayName = Expression("display_name") + let name = Expression("name") + let teamId = Expression("team_id") + let type = Expression("type") + let isGroupConstrained = Expression("group_constrained") + let shared = Expression("shared") + + var setter = [Setter]() + setter.append(id <- channel.id) + setter.append(createAt <- channel.createAt) + setter.append(deleteAt <- channel.deleteAt) + setter.append(updateAt <- channel.updateAt) + setter.append(creatorId <- channel.creatorId) + setter.append(displayName <- channel.displayName) + setter.append(name <- channel.name) + setter.append(teamId <- channel.teamId) + setter.append(type <- channel.type) + setter.append(isGroupConstrained <- channel.groupConstrained) + setter.append(shared <- channel.shared) + + return setter + } + + private func createChannelInfoSetter(from channel: Channel) -> [Setter] { + let id = Expression("id") + let header = Expression("header") + let purpose = Expression("purpose") + let guestCount = Expression("guest_count") + let memberCount = Expression("member_count") + let pinnedPostCount = Expression("pinned_post_count") + + var setter = [Setter]() + setter.append(id <- channel.id) + setter.append(header <- channel.header) + setter.append(purpose <- channel.purpose) + setter.append(guestCount <- 0) + setter.append(memberCount <- 0) + setter.append(pinnedPostCount <- 0) + return setter + } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift index dc77d6b6f9..635f4b2001 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift @@ -2,26 +2,6 @@ import Foundation import SQLite extension Database { - public func hasMyChannel(_ db: Connection, channelId: String) -> Bool { - let idCol = Expression("id") - let query = myChannelTable.where(idCol == channelId) - if let _ = try? db.pluck(query) { - return true - } - - return false - } - - public func hasThread(_ db: Connection, threadId: String) -> Bool { - let idCol = Expression("id") - let query = threadTable.where(idCol == threadId) - if let _ = try? db.pluck(query) { - return true - } - - return false - } - public func getTotalMentions() -> Int { let serverUrls = getAllActiveServerUrls() var mentions = 0 @@ -58,85 +38,6 @@ extension Database { return Int(mentions ?? 0) } - public func handleMyChannelMentions(_ db: Connection, _ channelMemberData: ChannelMemberData, withCRTEnabled crtEnabled: Bool) throws { - let idCol = Expression("id") - let mentionsCol = Expression("mentions_count") - let isUnreadCol = Expression("is_unread") - let mentions = crtEnabled ? channelMemberData.mention_count_root : channelMemberData.mention_count - - if hasMyChannel(db, channelId: channelMemberData.channel_id) { - let updateQuery = myChannelTable - .where(idCol == channelMemberData.channel_id) - .update(mentionsCol <- mentions, - isUnreadCol <- true - ) - let _ = try db.run(updateQuery) - } else { - let msgCol = Expression("message_count") - let lastPostAtCol = Expression("last_post_at") - let lastViewedAtCol = Expression("last_viewed_at") - let viewedAtCol = Expression("viewed_at") - let lastFetchedAtCol = Expression("last_fetched_at") - let manuallyUnreadCol = Expression("manually_unread") - let rolesCol = Expression("roles") - let statusCol = Expression("status") - - let setters: [Setter] = [ - idCol <- channelMemberData.channel_id, - mentionsCol <- mentions, - msgCol <- mentions, - lastPostAtCol <- channelMemberData.last_update_at, - lastViewedAtCol <- channelMemberData.last_viewed_at, - viewedAtCol <- 0, - lastFetchedAtCol <- 0, - isUnreadCol <- true, - manuallyUnreadCol <- false, - rolesCol <- channelMemberData.roles, - statusCol <- "created" - ] - - let insertQuery = myChannelTable.insert(setters) - let _ = try db.run(insertQuery) - } - } - - public func handleThreadMentions(_ db: Connection, _ threadData: ThreadData) throws { - let idCol = Expression("id") - let unreadMentionsCol = Expression("unread_mentions") - - if hasThread(db, threadId: threadData.id) { - let updateQuery = threadTable - .where(idCol == threadData.id) - .update(unreadMentionsCol <- threadData.unread_mentions) - let _ = try db.run(updateQuery) - } else { - let lastReplyAtCol = Expression("last_reply_at") - let lastViewedAtCol = Expression("last_viewed_at") - let viewedAtCol = Expression("viewed_at") - let lastFetchedAtCol = Expression("last_fetched_at") - let isFollowingCol = Expression("is_following") - let unreadRepliesCol = Expression("unread_replies") - let replyCountCol = Expression("reply_count") - let statusCol = Expression("status") - - let setters: [Setter] = [ - idCol <- threadData.id, - unreadMentionsCol <- threadData.unread_mentions, - lastReplyAtCol <- threadData.last_reply_at, - lastViewedAtCol <- threadData.last_viewed_at, - viewedAtCol <- 0, - lastFetchedAtCol <- 0, - isFollowingCol <- true, - unreadRepliesCol <- threadData.unread_replies, - replyCountCol <- threadData.reply_count, - statusCol <- "created" - ] - - let insertQuery = threadTable.insert(setters) - let _ = try db.run(insertQuery) - } - } - public func resetMyChannelMentions(_ serverUrl: String, _ channelId: String) throws { if let db = try? getDatabaseForServer(serverUrl) { let idCol = Expression("id") diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift index df1d2d8154..6b8f7d8151 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift @@ -8,80 +8,6 @@ import Foundation import SQLite -public struct Post: Codable { - let id: String - let create_at: Int64 - let update_at: Int64 - let edit_at: Int64 - let delete_at: Int64 - let is_pinned: Bool - let user_id: String - let channel_id: String - let root_id: String - let original_id: String - let message: String - let type: String - let props: String - let pending_post_id: String - let metadata: String - var prev_post_id: String - // CRT - let participants: [User]? - let last_reply_at: Int64 - let reply_count: Int - let is_following: Bool - - public enum PostKeys: String, CodingKey { - case id = "id" - case create_at = "create_at" - case update_at = "update_at" - case delete_at = "delete_at" - case edit_at = "edit_at" - case is_pinned = "is_pinned" - case user_id = "user_id" - case channel_id = "channel_id" - case root_id = "root_id" - case original_id = "original_id" - case message = "message" - case type = "type" - case props = "props" - case pending_post_id = "pending_post_id" - case metadata = "metadata" - // CRT - case participants = "participants" - case last_reply_at = "last_reply_at" - case reply_count = "reply_count" - case is_following = "is_following" - } - - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: PostKeys.self) - prev_post_id = "" - id = try values.decode(String.self, forKey: .id) - create_at = try values.decode(Int64.self, forKey: .create_at) - update_at = try values.decode(Int64.self, forKey: .update_at) - delete_at = try values.decode(Int64.self, forKey: .delete_at) - edit_at = try values.decode(Int64.self, forKey: .edit_at) - is_pinned = try values.decode(Bool.self, forKey: .is_pinned) - user_id = try values.decode(String.self, forKey: .user_id) - channel_id = try values.decode(String.self, forKey: .channel_id) - root_id = try values.decode(String.self, forKey: .root_id) - original_id = try values.decode(String.self, forKey: .original_id) - message = try values.decode(String.self, forKey: .message) - let meta = try values.decode([String:Any].self, forKey: .metadata) - metadata = Database.default.json(from: meta) ?? "{}" - type = try values.decode(String.self, forKey: .type) - pending_post_id = try values.decode(String.self, forKey: .pending_post_id) - let propsData = try values.decode([String:Any].self, forKey: .props) - props = Database.default.json(from: propsData) ?? "{}" - // CRT - participants = try values.decodeIfPresent([User].self, forKey: .participants) ?? [] - last_reply_at = try values.decodeIfPresent(Int64.self, forKey: .last_reply_at) ?? 0 - reply_count = try values.decodeIfPresent(Int.self, forKey: .reply_count) ?? 0 - is_following = try values.decodeIfPresent(Bool.self, forKey: .is_following) ?? false - } -} - struct MetadataSetters { let metadata: String let reactionSetters: [[Setter]] @@ -104,110 +30,99 @@ struct ThreadSetters { } extension Database { - public func queryLastPostCreateAt(withId channelId: String, withServerUrl serverUrl: String) throws -> Int64? { - let db = try getDatabaseForServer(serverUrl) - - let earliestCol = Expression("earliest") - let latestCol = Expression("latest") - let channelIdCol = Expression("channel_id") - let earliestLatestQuery = postsInChannelTable - .select(earliestCol, latestCol) - .where(channelIdCol == channelId) - .order(latestCol.desc) - .limit(1) - - var earliest: Int64? - var latest: Int64? - if let result = try? db.pluck(earliestLatestQuery) { - earliest = try? result.get(earliestCol) - latest = try? result.get(latestCol) - } else { - return nil + public func queryLastPostInThread(withRootId rootId: String, forServerUrl serverUrl: String) -> Double? { + if let db = try? getDatabaseForServer(serverUrl) { + let createAtCol = Expression("create_at") + let rootIdCol = Expression("root_id") + let query = postTable + .select(createAtCol) + .where(rootIdCol == rootId) + .order(createAtCol.desc) + .limit(1) + if let result = try? db.pluck(query) { + return try? result.get(createAtCol) + } } - - let createAtCol = Expression("create_at") - let deleteAtCol = Expression("delete_at") - var postQuery = postTable - .select(createAtCol) - .where(channelIdCol == channelId && deleteAtCol == 0) - - if let earliest = earliest, let latest = latest { - postQuery = postQuery.filter(earliest...latest ~= createAtCol) - } - postQuery = postQuery.order(createAtCol.desc).limit(1) - - if let result = try db.pluck(postQuery) { - return try result.get(createAtCol) + return nil + } + + public func queryLastPostCreateAt(withId channelId: String, forServerUrl serverUrl: String) -> Double? { + if let db = try? getDatabaseForServer(serverUrl) { + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") + let channelIdCol = Expression("channel_id") + let earliestLatestQuery = postsInChannelTable + .select(earliestCol, latestCol) + .where(channelIdCol == channelId) + .order(latestCol.desc) + .limit(1) + + var earliest: Double? + var latest: Double? + if let result = try? db.pluck(earliestLatestQuery) { + earliest = try? result.get(earliestCol) + latest = try? result.get(latestCol) + } else { + return nil + } + + let createAtCol = Expression("create_at") + let deleteAtCol = Expression("delete_at") + var postQuery = postTable + .select(createAtCol) + .where(channelIdCol == channelId && deleteAtCol == 0) + + if let earliest = earliest, let latest = latest { + postQuery = postQuery.filter(earliest...latest ~= createAtCol) + } + postQuery = postQuery.order(createAtCol.desc).limit(1) + + if let result = try? db.pluck(postQuery) { + return try? result.get(createAtCol) + } } return nil } - public func queryPostsSinceForChannel(withId channelId: String, withServerUrl serverUrl: String) throws -> Int64? { - let db = try getDatabaseForServer(serverUrl) - - let idCol = Expression("id") - let lastFetchedAtColAsDouble = Expression("last_fetched_at") - let lastFetchedAtColAsInt64 = Expression("last_fetched_at") - let query = myChannelTable.where(idCol == channelId) - - if let result = try? db.pluck(query) { - let lastFetchAtInt64 = result[lastFetchedAtColAsInt64] - if lastFetchAtInt64 != nil, - lastFetchAtInt64! > 0 { - return lastFetchAtInt64 - } - if let last = result[lastFetchedAtColAsDouble], - last > 0 { - return Int64(last) + public func queryPostsSinceForChannel(withId channelId: String, forServerUrl serverUrl: String) -> Double? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let lastFetchedAtColAsDouble = Expression("last_fetched_at") + let query = myChannelTable.where(idCol == channelId) + + if let result = try? db.pluck(query) { + if let last = result[lastFetchedAtColAsDouble], + last > 0 { + return last + } } + + return queryLastPostCreateAt(withId: channelId, forServerUrl: serverUrl) } - - return try queryLastPostCreateAt(withId: channelId, withServerUrl: serverUrl) + + return nil } - private func updateMyChannelLastFetchedAt(_ db: Connection, _ channelId: String, _ latest: Int64) throws { - let idCol = Expression("id") - let lastFetchedAtCol = Expression("last_fetched_at") - let statusCol = Expression("_status") - - let query = myChannelTable - .where(idCol == channelId) - .update(lastFetchedAtCol <- latest, statusCol <- "updated") - - try db.run(query) - } - - public func handlePostData(_ db: Connection, _ postData: PostData, _ channelId: String, _ usedSince: Bool = false, _ receivingThreads: Bool = false) throws { + public func handlePostData(_ db: Connection, _ postData: PostResponse, _ channelId: String, _ receivingThreads: Bool = false) throws { let sortedChainedPosts = chainAndSortPosts(postData) try insertOrUpdatePosts(db, sortedChainedPosts, channelId) - let sortedAndNotDeletedPosts = sortedChainedPosts.filter({$0.delete_at == 0}) + let sortedAndNotDeletedPosts = sortedChainedPosts.filter({$0.deleteAt == 0}) if (!receivingThreads) { if !sortedAndNotDeletedPosts.isEmpty { - let earliest = sortedAndNotDeletedPosts.first!.create_at - let latest = sortedAndNotDeletedPosts.last!.create_at - try handlePostsInChannel(db, channelId, earliest, latest, usedSince) + let earliest = sortedAndNotDeletedPosts.first!.createAt + let latest = sortedAndNotDeletedPosts.last!.createAt + try handlePostsInChannel(db, channelId, earliest, latest) } - - let lastFetchedAt = postData.posts.map({max($0.create_at, $0.update_at, $0.delete_at)}).max() - try updateMyChannelLastFetchedAt(db, channelId, lastFetchedAt ?? 0) } - try handlePostsInThread(db, postData.posts) + try handlePostsInThread(db, Array(postData.posts.values)) } - public func handleThreads(_ db: Connection, _ threads: [Post]) throws { - try insertThreads(db, threads) - } - - private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Int64, _ latest: Int64, _ usedSince: Bool = false) throws { - if usedSince { - try? updatePostsInChannelLatestOnly(db, channelId, latest) - } else { - let updated = try updatePostsInChannelEarliestAndLatest(db, channelId, earliest, latest) - if (!updated) { - try? insertPostsInChannel(db, channelId, earliest, latest) - } + private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Double, _ latest: Double) throws { + let updated = try updatePostsInChannelEarliestAndLatest(db, channelId, earliest, latest) + if (!updated) { + try insertPostsInChannel(db, channelId, earliest, latest) } } @@ -219,17 +134,17 @@ extension Database { } } - private func chainAndSortPosts(_ postData: PostData) -> [Post] { + private func chainAndSortPosts(_ postData: PostResponse) -> [Post] { let order = postData.order - let posts = postData.posts + let posts = Array(postData.posts.values) var prevPostId = "" - return posts.sorted(by: {$0.create_at < $1.create_at}).enumerated().map { (index, post) in + return posts.sorted(by: {$0.createAt < $1.createAt}).enumerated().map { (index, post) in var modified = post if (index == 0) { - modified.prev_post_id = postData.prev_post_id + modified.prevPostId = postData.prevPostId } else { - modified.prev_post_id = prevPostId + modified.prevPostId = prevPostId } if (order.contains(post.id)) { @@ -240,25 +155,11 @@ extension Database { } } - private func updatePostsInChannelLatestOnly(_ db: Connection, _ channelId: String, _ latest: Int64) throws { - let channelIdCol = Expression("channel_id") - let latestCol = Expression("latest") - let statusCol = Expression("_status") - - let query = postsInChannelTable - .where(channelIdCol == channelId) - .order(latestCol.desc) - .limit(1) - .update(latestCol <- latest, statusCol <- "updated") - - try db.run(query) - } - - private func updatePostsInChannelEarliestAndLatest(_ db: Connection, _ channelId: String, _ earliest: Int64, _ latest: Int64) throws -> Bool { + private func updatePostsInChannelEarliestAndLatest(_ db: Connection, _ channelId: String, _ earliest: Double, _ latest: Double) throws -> Bool { let idCol = Expression("id") let channelIdCol = Expression("channel_id") - let earliestCol = Expression("earliest") - let latestCol = Expression("latest") + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") let statusCol = Expression("_status") let query = postsInChannelTable @@ -283,11 +184,11 @@ extension Database { return false } - private func insertPostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Int64, _ latest: Int64) throws { + private func insertPostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Double, _ latest: Double) throws { let idCol = Expression("id") let channelIdCol = Expression("channel_id") - let earliestCol = Expression("earliest") - let latestCol = Expression("latest") + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") let statusCol = Expression("_status") let id = generateId() @@ -331,29 +232,12 @@ extension Database { } } - private func insertThreads(_ db: Connection, _ posts: [Post]) throws { - let setters = try createThreadSetters(db, from: posts) - for setter in setters { - let insertThread = threadTable.insert(or: .replace, setter.threadSetters) - try db.run(insertThread) - - let threadIdCol = Expression("thread_id") - let deletePreviousThreadParticipants = threadParticipantTable.where(threadIdCol == setter.id).delete() - try db.run(deletePreviousThreadParticipants) - - if !setter.threadParticipantSetters.isEmpty { - let insertThreadParticipants = threadParticipantTable.insertMany(setter.threadParticipantSetters) - try db.run(insertThreadParticipants) - } - } - } - private func createPostSetters(from posts: [Post]) -> [PostSetters] { let id = Expression("id") - let createAt = Expression("create_at") - let updateAt = Expression("update_at") - let editAt = Expression("edit_at") - let deleteAt = Expression("delete_at") + let createAt = Expression("create_at") + let updateAt = Expression("update_at") + let editAt = Expression("edit_at") + let deleteAt = Expression("delete_at") let isPinned = Expression("is_pinned") let userId = Expression("user_id") let channelId = Expression("channel_id") @@ -373,20 +257,20 @@ extension Database { var setter = [Setter]() let metadataSetters = createPostMetadataSetters(from: post) setter.append(id <- post.id) - setter.append(createAt <- post.create_at) - setter.append(updateAt <- post.update_at) - setter.append(editAt <- post.edit_at) - setter.append(deleteAt <- post.delete_at) - setter.append(isPinned <- post.is_pinned) - setter.append(userId <- post.user_id) - setter.append(channelId <- post.channel_id) - setter.append(rootId <- post.root_id) - setter.append(originalId <- post.original_id) + setter.append(createAt <- post.createAt) + setter.append(updateAt <- post.updateAt) + setter.append(editAt <- post.editAt) + setter.append(deleteAt <- post.deleteAt) + setter.append(isPinned <- post.isPinned) + setter.append(userId <- post.userId) + setter.append(channelId <- post.channelId) + setter.append(rootId <- post.rootId) + setter.append(originalId <- post.originalId) setter.append(message <- post.message) setter.append(metadata <- metadataSetters.metadata) setter.append(type <- post.type) - setter.append(pendingPostId <- post.pending_post_id) - setter.append(prevPostId <- post.prev_post_id) + setter.append(pendingPostId <- post.pendingPostId) + setter.append(prevPostId <- post.prevPostId) setter.append(props <- post.props) setter.append(statusCol <- "created") @@ -408,13 +292,13 @@ extension Database { let userId = Expression("user_id") let postId = Expression("post_id") let emojiName = Expression("emoji_name") - let createAt = Expression("create_at") + let createAt = Expression("create_at") let name = Expression("name") let ext = Expression("extension") - let size = Expression("size") + let size = Expression("size") let mimeType = Expression("mime_type") - let width = Expression("width") - let height = Expression("height") + let width = Expression("width") + let height = Expression("height") let localPath = Expression("local_path") let imageThumbnail = Expression("image_thumbnail") let statusCol = Expression("_status") @@ -435,7 +319,7 @@ extension Database { reactionSetter.append(userId <- r["user_id"] as! String) reactionSetter.append(postId <- r["post_id"] as! String) reactionSetter.append(emojiName <- r["emoji_name"] as! String) - reactionSetter.append(createAt <- r["create_at"] as! Int64) + reactionSetter.append(createAt <- r["create_at"] as! Double) reactionSetter.append(statusCol <- "created") reactionSetters.append(reactionSetter) @@ -453,10 +337,10 @@ extension Database { fileSetter.append(postId <- f["post_id"] as! String) fileSetter.append(name <- f["name"] as! String) fileSetter.append(ext <- f["extension"] as! String) - fileSetter.append(size <- f["size"] as! Int64) + fileSetter.append(size <- f["size"] as! Double) fileSetter.append(mimeType <- f["mime_type"] as! String) - fileSetter.append(width <- (f["width"] as? Int64 ?? 0)) - fileSetter.append(height <- (f["height"] as? Int64 ?? 0)) + fileSetter.append(width <- (f["width"] as? Double ?? 0)) + fileSetter.append(height <- (f["height"] as? Double ?? 0)) fileSetter.append(localPath <- "") fileSetter.append(imageThumbnail <- (f["mini_preview"] as? String ?? "")) fileSetter.append(statusCol <- "created") @@ -496,118 +380,52 @@ extension Database { emojiSetters: emojiSetters) } - private func createThreadSetters(_ db: Connection, from posts: [Post]) throws -> [ThreadSetters] { - let id = Expression("id") - let lastReplyAt = Expression("last_reply_at") - let replyCount = Expression("reply_count") - let isFollowing = Expression("is_following") - let statusCol = Expression("_status") - let lastFetchAtCol = Expression("last_fetched_at") - - var threadsSetters: [ThreadSetters] = [] - - for post in posts { - - let query = threadTable - .select(id) - .where(id == post.id) - - if let _ = try? db.pluck(query) { - let updateQuery = threadTable - .where(id == post.id) - .update(lastReplyAt <- post.last_reply_at, - replyCount <- post.reply_count, - isFollowing <- post.is_following, - statusCol <- "updated" - ) - try db.run(updateQuery) - } else { - var setter = [Setter]() - setter.append(id <- post.id) - setter.append(lastReplyAt <- post.last_reply_at) - setter.append(replyCount <- post.reply_count) - setter.append(isFollowing <- post.is_following) - setter.append(lastFetchAtCol <- 0) - setter.append(statusCol <- "created") - - let threadSetter = ThreadSetters( - id: post.id, - threadSetters: setter, - threadParticipantSetters: createThreadParticipantSetters(from: post) - ) - threadsSetters.append(threadSetter) - } - } - - return threadsSetters - } - - private func createThreadParticipantSetters(from post: Post) -> [[Setter]] { - - var participantSetters = [[Setter]]() - - let id = Expression("id") - let userId = Expression("user_id") - let threadId = Expression("thread_id") - let statusCol = Expression("_status") - - for p in post.participants ?? [] { - var participantSetter = [Setter]() - participantSetter.append(id <- generateId() as String) - participantSetter.append(userId <- p.id) - participantSetter.append(threadId <- post.id) - participantSetter.append(statusCol <- "created") - participantSetters.append(participantSetter) - } - - return participantSetters - } - private func createPostsInThreadSetters(_ db: Connection, from posts: [Post]) throws -> [[Setter]] { var setters = [[Setter]]() var postsInThread = [String: [Post]]() for post in posts { - if !post.root_id.isEmpty && post.delete_at == 0 { - var threadPosts = postsInThread[post.root_id] ?? [Post]() + if !post.rootId.isEmpty && post.deleteAt == 0 { + var threadPosts = postsInThread[post.rootId] ?? [Post]() threadPosts.append(post) - postsInThread.updateValue(threadPosts, forKey: post.root_id) + postsInThread.updateValue(threadPosts, forKey: post.rootId) } } let rootIdCol = Expression("root_id") - let earliestCol = Expression("earliest") - let latestCol = Expression("latest") + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") let statusCol = Expression("_status") for (rootId, posts) in postsInThread { - let sortedPosts = posts.sorted(by: { $0.create_at < $1.create_at }) - let earliest = sortedPosts.first!.create_at - let latest = sortedPosts.last!.create_at - - let query = postsInThreadTable - .where(rootIdCol == rootId) - .order(latestCol.desc) - .limit(1) - if let row = try? db.pluck(query) { - let rowEarliest = try row.get(earliestCol) - let rowLatest = try row.get(latestCol) + let sortedPosts = posts.sorted(by: { $0.createAt < $1.createAt }) + if let earliest = sortedPosts.first?.createAt, + let latest = sortedPosts.last?.createAt { - let updateQuery = postsInThreadTable - .where(rootIdCol == rootId && earliestCol == rowEarliest && latestCol == rowLatest) - .update(earliestCol <- min(earliest, rowEarliest), - latestCol <- max(latest, rowLatest), statusCol <- "updated") - try db.run(updateQuery) - } else { - var setter = [Setter]() - setter.append(Expression("id") <- generateId()) - setter.append(rootIdCol <- rootId) - setter.append(earliestCol <- earliest) - setter.append(latestCol <- latest) - setter.append(statusCol <- "created") - - setters.append(setter) + let query = postsInThreadTable + .where(rootIdCol == rootId) + .order(latestCol.desc) + .limit(1) + if let row = try? db.pluck(query) { + let rowEarliest = try row.get(earliestCol) + let rowLatest = try row.get(latestCol) + + let updateQuery = postsInThreadTable + .where(rootIdCol == rootId && earliestCol == rowEarliest && latestCol == rowLatest) + .update(earliestCol <- min(earliest, rowEarliest), + latestCol <- max(latest, rowLatest), statusCol <- "updated") + try db.run(updateQuery) + } else { + var setter = [Setter]() + setter.append(Expression("id") <- generateId()) + setter.append(rootIdCol <- rootId) + setter.append(earliestCol <- earliest) + setter.append(latestCol <- latest) + setter.append(statusCol <- "created") + + setters.append(setter) + } } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Preferences.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Preferences.swift new file mode 100644 index 0000000000..6ded7a5cf7 --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Preferences.swift @@ -0,0 +1,26 @@ +import Foundation +import SQLite + +extension Database { + public func getTeammateDisplayNameSetting(_ serverUrl: String) -> String { + do { + if let displayName = geConfigDisplayNameSetting(serverUrl) { + return displayName + } + + let db = try getDatabaseForServer(serverUrl) + let category = Expression("category") + let name = Expression("name") + let value = Expression("value") + let query = preferenceTable.select(value).filter(category == "display_settings" && name == "name_format") + if let result = try db.pluck(query) { + let val = try result.get(value) + return val + } + } catch { + // do nothing + } + + return "username" + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift index b5796c751c..9608bf4dc5 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift @@ -11,19 +11,41 @@ import SQLite extension Database { public func getConfig(_ serverUrl: String, _ key: String) -> String? { - do { - let db = try getDatabaseForServer(serverUrl) + if let db = try? getDatabaseForServer(serverUrl) { let id = Expression("id") let value = Expression("value") let query = configTable.select(value).filter(id == key) - if let result = try db.pluck(query) { - let val = try result.get(value) - return val + if let result = try? db.pluck(query) { + return try? result.get(value) } - - return nil - } catch { - return nil } + return nil + } + + public func getLicense(_ serverUrl: String) -> String? { + if let db = try? getDatabaseForServer(serverUrl) { + let id = Expression("id") + let value = Expression("value") + let query = systemTable.select(value).filter(id == "license") + if let result = try? db.pluck(query) { + return try? result.get(value) + } + } + return nil + } + + public func geConfigDisplayNameSetting(_ serverUrl: String) -> String? { + let licenseValue = getLicense(serverUrl) + guard let licenseData = licenseValue?.data(using: .utf8), + let license = try? JSONSerialization.jsonObject(with: licenseData) as? Dictionary, + let lockDisplayName = getConfig(serverUrl, "LockTeammateNameDisplay") + else { return nil } + + let displayName = getConfig(serverUrl, "TeammateNameDisplay") ?? "full_name" + let licenseLock = license["LockTeammateNameDisplay"] ?? "false" + if licenseLock == "true" && lockDisplayName == "true" { + return displayName + } + return nil } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift index 829999a01e..4221872a00 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift @@ -2,13 +2,12 @@ import Foundation import SQLite extension Database { - internal func queryCurrentTeamId(_ serverUrl: String) -> String? { + public func queryCurrentTeamId(_ serverUrl: String) -> String? { if let db = try? getDatabaseForServer(serverUrl) { let idCol = Expression("id") let valueCol = Expression("value") - let query = systemTable.where(idCol == "currentTeamId") - if let result = try? db.pluck(query) { + if let result = try? db.pluck(systemTable.where(idCol == "currentTeamId")) { return try? result.get(valueCol).replacingOccurrences(of: "\"", with: "") } } @@ -16,7 +15,7 @@ extension Database { return nil } - public func queryTeamIdForChannel(withId channelId: String, withServerUrl serverUrl: String) -> String? { + public func queryTeamIdForChannel(withId channelId: String, forServerUrl serverUrl: String) -> String? { if let db = try? getDatabaseForServer(serverUrl) { let idCol = Expression("id") let teamIdCol = Expression("team_id") @@ -33,4 +32,106 @@ extension Database { return nil } + + public func queryTeamExists(withId teamId: String, forServerUrl serverUrl: String) -> Bool { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let query = teamTable.where(idCol == teamId) + if let _ = try? db.pluck(query) { + return true + } + } + return false + } + + public func queryMyTeamExists(withId teamId: String, forServerUrl serverUrl: String) -> Bool { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + let query = myTeamTable.where(idCol == teamId) + if let _ = try? db.pluck(query) { + return true + } + } + return false + } + + public func queryAllMyTeamIds(_ serverUrl: String) -> [String]? { + if let db = try? getDatabaseForServer(serverUrl) { + let idCol = Expression("id") + if let myTeams = try? db.prepare(myTeamTable.select(idCol)) { + return myTeams.map { try! $0.get(idCol) } + } + } + + return nil + } + + public func insertTeam(_ db: Connection, _ team: Team) throws { + let setter = createTeamSetter(from: team) + let insertQuery = teamTable.insert(or: .replace, setter) + try db.run(insertQuery) + } + + public func insertMyTeam(_ db: Connection, _ member: TeamMember) throws { + let myTeam = createMyTeamSetter(from: member) + let teamMember = createTeamMemberSetter(from: member) + try db.run(myTeamTable.insert(or: .replace, myTeam)) + try db.run(teamMembershipTable.insert(or: .replace, teamMember)) + } + + private func createTeamSetter(from team: Team) -> [Setter] { + let id = Expression("id") + let isAllowOpenInvite = Expression("is_allow_open_invite") + let updateAt = Expression("update_at") + let description = Expression("description") + let displayName = Expression("display_name") + let isGroupeConstrained = Expression("is_group_constrained") + let lastTeamIconUpdatedAt = Expression("last_team_icon_updated_at") + let name = Expression("name") + let type = Expression("type") + let allowedDomains = Expression("allowed_domains") + let inviteId = Expression("invite_id") + + let setter: [Setter] = [ + id <- team.id, + isAllowOpenInvite <- team.allowOpenInvite, + updateAt <- team.updateAt, + description <- team.description, + displayName <- team.displayName, + isGroupeConstrained <- team.groupConstrained, + lastTeamIconUpdatedAt <- team.lastTeamIconUpdate, + name <- team.name, + type <- team.type, + allowedDomains <- team.allowedDomains, + inviteId <- team.inviteId, + ] + return setter + } + + private func createMyTeamSetter(from member: TeamMember) -> [Setter] { + let id = Expression("id") + let roles = Expression("roles") + + var setter = [Setter]() + setter.append(id <- member.id) + setter.append(roles <- member.roles) + + return setter + } + + private func createTeamMemberSetter(from member: TeamMember) -> [Setter] { + let id = Expression("id") + let teamId = Expression("team_id") + let userId = Expression("user_id") + let schemeAdmin = Expression("scheme_admin") + + let setter: [Setter] = [ + id <- "\(member.id)-\(member.userId)", + teamId <- member.id, + userId <- member.userId, + schemeAdmin <- member.schemeAdmin, + ] + + return setter + } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift new file mode 100644 index 0000000000..54ef35668f --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift @@ -0,0 +1,188 @@ +import Foundation +import SQLite + +extension Database { + public func hasThread(_ db: Connection, threadId: String) -> Bool { + let idCol = Expression("id") + let query = threadTable.where(idCol == threadId) + if let _ = try? db.pluck(query) { + return true + } + + return false + } + + public func getTeamThreadSync(_ db: Connection, teamId: String) -> Row? { + let idCol = Expression("id") + let query = teamThreadsSyncTable.where(idCol == teamId) + return try? db.pluck(query) + } + + public func handleThreads(_ db: Connection, _ threads: [PostThread], forTeamId teamId: String) throws { + var teamIds = [String]() + if teamId.isEmpty { + let idCol = Expression("id") + if let myTeams = try? db.prepare(myTeamTable.select(idCol)) { + if let ids = try? myTeams.map({ try $0.get(idCol) }) { + teamIds.append(contentsOf: ids) + } + } + } else { + teamIds.append(teamId) + } + + for thread in threads { + handleThread(db, thread, forTeamIds: teamIds) + } + + handleTeamThreadSync(db, threads, forTeamIds: teamIds) + } + + public func handleThread(_ db: Connection, _ thread: PostThread, forTeamIds teamIds: [String]) { + if hasThread(db, threadId: thread.id) { + try? updateThread(db, thread) + } else { + try? insertThread(db, thread) + } + + try? syncParticipants(db, thread) + + if thread.isFollowing { + for teamId in teamIds { + try? handleThreadInTeam(db, thread, forTeamId: teamId) + } + } + } + + private func insertThread(_ db: Connection, _ thread: PostThread) throws { + let id = Expression("id") + let isFollowing = Expression("is_following") + let lastViewedAt = Expression("last_viewed_at") + let lastReplyAt = Expression("last_reply_at") + let unreadReplies = Expression("unread_replies") + let unreadMentions = Expression("unread_mentions") + let replyCount = Expression("reply_count") + + let setter: [Setter] = [ + id <- thread.id, + isFollowing <- thread.isFollowing, + lastViewedAt <- thread.lastViewedAt, + lastReplyAt <- thread.lastReplyAt, + unreadReplies <- thread.unreadReplies, + unreadMentions <- thread.unreadMentions, + replyCount <- thread.replyCount + ] + + let _ = try db.run(threadTable.insert(or: .replace, setter)) + } + + private func updateThread(_ db: Connection, _ thread: PostThread) throws { + let id = Expression("id") + let isFollowing = Expression("is_following") + let lastViewedAt = Expression("last_viewed_at") + let lastReplyAt = Expression("last_reply_at") + let unreadReplies = Expression("unread_replies") + let unreadMentions = Expression("unread_mentions") + let replyCount = Expression("reply_count") + + let setter: [Setter] = [ + isFollowing <- thread.isFollowing, + lastViewedAt <- thread.lastViewedAt, + lastReplyAt <- thread.lastReplyAt, + unreadReplies <- thread.unreadReplies, + unreadMentions <- thread.unreadMentions, + replyCount <- thread.replyCount + ] + + let _ = try db.run(threadTable.where(id == thread.id).update(setter)) + } + + private func syncParticipants(_ db: Connection, _ thread: PostThread) throws { + let threadIdCol = Expression("thread_id") + let deletePreviousThreadParticipants = threadParticipantTable.where(threadIdCol == thread.id).delete() + try db.run(deletePreviousThreadParticipants) + + let setters = createThreadParticipantSetters(from: thread) + if !setters.isEmpty { + try db.run(threadParticipantTable.insertMany(setters)) + } + } + + private func handleThreadInTeam(_ db: Connection, _ thread: PostThread, forTeamId teamId: String) throws { + let idCol = Expression("id") + let threadIdCol = Expression("thread_id") + let teamIdCol = Expression("team_id") + let existing = try? db.pluck(threadsInTeamTable.where(threadIdCol == thread.id && teamIdCol == teamId)) + if existing == nil { + let setter: [Setter] = [ + idCol <- generateId(), + threadIdCol <- thread.id, + teamIdCol <- teamId, + ] + let _ = try db.run(threadsInTeamTable.insert(or: .replace, setter)) + } + } + + private func handleTeamThreadSync(_ db: Connection, _ threads: [PostThread], forTeamIds teamIds: [String]) { + let sortedList = threads.filter({ $0.isFollowing }).sorted(by: { $0.lastReplyAt < $1.lastReplyAt}).map{ $0.lastReplyAt } + if let earliest = sortedList.first, + let latest = sortedList.last { + for teamId in teamIds { + if let existing = getTeamThreadSync(db, teamId: teamId) { + try? updateTeamThreadSync(db, forTeamId: teamId, starting: earliest, ending: latest, currentRow: existing) + } else { + try? insertTeamThreadSync(db, forTeamId: teamId, starting: earliest, ending: latest) + } + } + } + } + + private func insertTeamThreadSync(_ db: Connection, forTeamId teamId: String, starting earliest: Double, ending latest: Double) throws { + let idCol = Expression("id") + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") + + let setter: [Setter] = [ + idCol <- teamId, + earliestCol <- earliest, + latestCol <- latest, + ] + + let _ = try db.run(teamThreadsSyncTable.insert(or: .replace, setter)) + } + + private func updateTeamThreadSync(_ db: Connection, forTeamId teamId: String, starting earliest: Double, ending latest: Double, currentRow existing: Row) throws { + let idCol = Expression("id") + let earliestCol = Expression("earliest") + let latestCol = Expression("latest") + + let existingEarliest = (try? existing.get(earliestCol)) ?? 0 + let storeEarliest = min(earliest, existingEarliest) + + let existingLatest = (try? existing.get(latestCol)) ?? 0 + let storeLatest = max(latest, existingLatest) + + let _ = try db.run( + teamThreadsSyncTable.where(idCol == teamId) + .update(earliestCol <- storeEarliest, latestCol <- storeLatest) + ) + } + + private func createThreadParticipantSetters(from thread: PostThread) -> [[Setter]] { + var participantSetters = [[Setter]]() + + let id = Expression("id") + let userId = Expression("user_id") + let threadId = Expression("thread_id") + + for p in thread.participants { + var participantSetter = [Setter]() + participantSetter.append(id <- generateId() as String) + participantSetter.append(userId <- p.id) + participantSetter.append(threadId <- thread.id) + participantSetters.append(participantSetter) + } + + return participantSetters + } +} diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift index 0ac07971c6..056714c34a 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift @@ -9,90 +9,19 @@ import Foundation import SQLite -public struct User: Codable, Hashable { - let id: String - let auth_service: String - let update_at: Double - let delete_at: Double - let email: String - let first_name: String - let is_bot: Bool - let is_guest: Bool - let last_name: String - let last_picture_update: Double - let locale: String - let nickname: String - let position: String - let roles: String - let status: String - let username: String - let notify_props: String - let props: String - let timezone: String - - public enum UserKeys: String, CodingKey { - case id = "id" - case auth_service = "auth_service" - case update_at = "update_at" - case delete_at = "delete_at" - case email = "email" - case first_name = "first_name" - case is_bot = "is_bot" - case last_name = "last_name" - case last_picture_update = "last_picture_update" - case locale = "locale" - case nickname = "nickname" - case position = "position" - case roles = "roles" - case username = "username" - case notify_props = "notify_props" - case props = "props" - case timezone = "timezone" - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: UserKeys.self) - id = try container.decode(String.self, forKey: .id) - auth_service = try container.decode(String.self, forKey: .auth_service) - update_at = (try? container.decodeIfPresent(Double.self, forKey: .update_at)) ?? 0 - delete_at = (try? container.decodeIfPresent(Double.self, forKey: .delete_at)) ?? 0 - email = try container.decode(String.self, forKey: .email) - first_name = try container.decode(String.self, forKey: .first_name) - is_bot = container.contains(.is_bot) ? try container.decode(Bool.self, forKey: .is_bot) : false - roles = try container.decode(String.self, forKey: .roles) - is_guest = roles.contains("system_guest") - last_name = try container.decode(String.self, forKey: .last_name) - last_picture_update = (try? container.decodeIfPresent(Double.self, forKey: .last_picture_update)) ?? 0 - locale = try container.decode(String.self, forKey: .locale) - nickname = try container.decode(String.self, forKey: .nickname) - position = try container.decode(String.self, forKey: .position) - status = "offline" - username = try container.decode(String.self, forKey: .username) - - let notifyPropsData = try? container.decodeIfPresent([String: String].self, forKey: .notify_props) - if (notifyPropsData != nil) { - notify_props = Database.default.json(from: notifyPropsData) ?? "{}" - } else { - notify_props = "{}" - } - - let propsData = try? container.decodeIfPresent([String: String].self, forKey: .props) - if (propsData != nil) { - props = Database.default.json(from: propsData) ?? "{}" - } else { - props = "{}" +extension Database { + public func getUserFromRow(_ row: Row) -> User? { + do { + let decoder = row.decoder() + let _ = try decoder.container(keyedBy: User.UserKeys.self) + return try User(from: decoder) + } catch { + print(error.localizedDescription) } - let timezoneData = try? container.decodeIfPresent([String: String].self, forKey: .timezone) - if (timezoneData != nil) { - timezone = Database.default.json(from: timezoneData) ?? "{}" - } else { - timezone = "{}" - } + return nil } -} -extension Database { public func queryCurrentUserId(_ serverUrl: String) throws -> String { let db = try getDatabaseForServer(serverUrl) @@ -120,8 +49,17 @@ extension Database { throw DatabaseError.NoResults(query.expression.description) } - public func getUserLastPictureAt(for userId: String, withServerUrl serverUrl: String) -> Double? { - let idCol = Expression("id") + public func getCurrentUserLocale(_ serverUrl: String) -> String { + if let user = try? queryCurrentUser(serverUrl) { + if let locale = try? user.get(Expression("locale")) { + return locale + } + } + + return "en" + } + + public func getUserLastPictureAt(for userId: String, forServerUrl serverUrl: String) -> Double? { var updateAt: Double? do { let db = try getDatabaseForServer(serverUrl) @@ -129,7 +67,7 @@ extension Database { let stmtString = "SELECT * FROM User WHERE id='\(userId)'" let results: [User] = try db.prepareRowIterator(stmtString).map {try $0.decode()} - updateAt = results.first?.last_picture_update + updateAt = results.first?.lastPictureUpdate } catch { return nil @@ -138,51 +76,56 @@ extension Database { return updateAt } - public func queryUsers(byIds: Set, withServerUrl: String) throws -> Set { - let db = try getDatabaseForServer(withServerUrl) - + public func queryUsers(byIds userIds: Set, forServerUrl serverUrl: String) -> Set { var result: Set = Set() - let idCol = Expression("id") - for user in try db.prepare( - userTable.select(idCol).filter(byIds.contains(idCol)) - ) { - result.insert(user[idCol]) + if let db = try? getDatabaseForServer(serverUrl) { + + let idCol = Expression("id") + if let users = try? db.prepare( + userTable.select(idCol).filter(userIds.contains(idCol)) + ) { + for user in users { + result.insert(user[idCol]) + } + } } return result } - public func queryUsers(byUsernames: Set, withServerUrl: String) throws -> Set { - let db = try getDatabaseForServer(withServerUrl) - + public func queryUsers(byUsernames usernames: Set, forServerUrl serverUrl: String) -> Set { var result: Set = Set() - let usernameCol = Expression("username") - for user in try db.prepare( - userTable.select(usernameCol).filter(byUsernames.contains(usernameCol)) - ) { - result.insert(user[usernameCol]) + if let db = try? getDatabaseForServer(serverUrl) { + let usernameCol = Expression("username") + if let users = try? db.prepare( + userTable.select(usernameCol).filter(usernames.contains(usernameCol)) + ) { + for user in users { + result.insert(user[usernameCol]) + } + } } return result } - public func insertUsers(_ db: Connection, _ users: Set) throws { - let setters = createUserSettedrs(from: users) + public func insertUsers(_ db: Connection, _ users: [User]) throws { + let setters = createUserSetters(from: users) let insertQuery = userTable.insertMany(or: .replace, setters) try db.run(insertQuery) } - private func createUserSettedrs(from users: Set) -> [[Setter]] { + private func createUserSetters(from users: [User]) -> [[Setter]] { let id = Expression("id") let authService = Expression("auth_service") - let updateAt = Expression("update_at") - let deleteAt = Expression("delete_at") + let updateAt = Expression("update_at") + let deleteAt = Expression("delete_at") let email = Expression("email") let firstName = Expression("first_name") let isBot = Expression("is_bot") let isGuest = Expression("is_guest") let lastName = Expression("last_name") - let lastPictureUpdate = Expression("last_picture_update") + let lastPictureUpdate = Expression("last_picture_update") let locale = Expression("locale") let nickname = Expression("nickname") let position = Expression("position") @@ -197,22 +140,22 @@ extension Database { for user in users { var setter = [Setter]() setter.append(id <- user.id) - setter.append(authService <- user.auth_service) - setter.append(updateAt <- Int64(user.update_at)) - setter.append(deleteAt <- Int64(user.delete_at)) + setter.append(authService <- user.authService) + setter.append(updateAt <- user.updateAt) + setter.append(deleteAt <- user.deleteAt) setter.append(email <- user.email) - setter.append(firstName <- user.first_name) - setter.append(isBot <- user.is_bot) - setter.append(isGuest <- user.is_guest) - setter.append(lastName <- user.last_name) - setter.append(lastPictureUpdate <- Int64(user.last_picture_update)) + setter.append(firstName <- user.firstName) + setter.append(isBot <- user.isBot) + setter.append(isGuest <- user.isGuest) + setter.append(lastName <- user.lastName) + setter.append(lastPictureUpdate <- user.lastPictureUpdate) setter.append(locale <- user.locale) setter.append(nickname <- user.nickname) setter.append(position <- user.position) setter.append(roles <- user.roles) setter.append(status <- user.status) setter.append(username <- user.username) - setter.append(notifyProps <- user.notify_props) + setter.append(notifyProps <- user.notifyProps) setter.append(props <- user.props) setter.append(timezone <- user.timezone) setters.append(setter) diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift index d4802bbd97..3f64c42470 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift @@ -43,6 +43,7 @@ public class Database: NSObject { internal var systemTable = Table("System") internal var teamTable = Table("Team") internal var myTeamTable = Table("MyTeam") + internal var teamMembershipTable = Table("TeamMembership") internal var channelTable = Table("Channel") internal var channelInfoTable = Table("ChannelInfo") internal var channelMembershipTable = Table("ChannelMembership") @@ -58,7 +59,12 @@ public class Database: NSObject { internal var userTable = Table("User") internal var threadTable = Table("Thread") internal var threadParticipantTable = Table("ThreadParticipant") + internal var threadsInTeamTable = Table("ThreadsInTeam") + internal var teamThreadsSyncTable = Table("TeamThreadsSync") internal var configTable = Table("Config") + internal var preferenceTable = Table("Preference") + internal var categoryTable = Table("Category") + internal var categoryChannelTable = Table("CategoryChannel") @objc public static let `default` = Database() diff --git a/ios/Gekidou/Sources/Gekidou/String+Extensions.swift b/ios/Gekidou/Sources/Gekidou/String+Extensions.swift new file mode 100644 index 0000000000..d36eb1e17a --- /dev/null +++ b/ios/Gekidou/Sources/Gekidou/String+Extensions.swift @@ -0,0 +1,16 @@ +import Foundation + +extension String { + func removePrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } + + func toUrlSafeBase64Encode() -> String { + return Data( + self.replacingOccurrences(of: "/\\+/g", with: "-", options: .regularExpression) + .replacingOccurrences(of: "/\\//g", with: "_", options: .regularExpression) + .utf8 + ).base64EncodedString() + } +} diff --git a/ios/GekidouWrapper.swift b/ios/GekidouWrapper.swift index 24bda2774f..8a82055a62 100644 --- a/ios/GekidouWrapper.swift +++ b/ios/GekidouWrapper.swift @@ -13,7 +13,14 @@ import Gekidou @objc public static let `default` = GekidouWrapper() @objc func postNotificationReceipt(_ userInfo: [AnyHashable:Any]) { - Network.default.postNotificationReceipt(userInfo) + PushNotification.default.postNotificationReceipt(userInfo) + } + + @objc func fetchDataForPushNotification(_ notification: [AnyHashable:Any], withContentHandler contentHander: @escaping ((_ data: Data?) -> Void)) { + PushNotification.default.fetchDataForPushNotification(notification, withContentHandler: { data in + let jsonData = try? JSONEncoder().encode(data) + contentHander(jsonData) + }) } @objc func attachSession(_ id: String, completionHandler: @escaping () -> Void) { diff --git a/ios/Mattermost/AppDelegate.mm b/ios/Mattermost/AppDelegate.mm index c35997300f..3bc4d11fda 100644 --- a/ios/Mattermost/AppDelegate.mm +++ b/ios/Mattermost/AppDelegate.mm @@ -48,6 +48,7 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; } [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error: nil]; + [[GekidouWrapper default] setPreference:@"true" forKey:@"ApplicationIsRunning"]; [RNNotifications startMonitorNotifications]; @@ -91,10 +92,22 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; // When rootId is nil, clear channel's root post notifications or else clear all thread notifications [[NotificationHelper default] clearChannelOrThreadNotificationsWithUserInfo:userInfo]; [[GekidouWrapper default] postNotificationReceipt:userInfo]; + [RNNotifications didReceiveBackgroundNotification:userInfo withCompletionHandler:completionHandler]; + return; } - if (state != UIApplicationStateActive || isClearAction) { - [RNNotifications didReceiveBackgroundNotification:userInfo withCompletionHandler:completionHandler]; + if (state != UIApplicationStateActive) { + [[GekidouWrapper default] fetchDataForPushNotification:userInfo withContentHandler:^(NSData * _Nullable data) { + NSMutableDictionary *notification = [userInfo mutableCopy]; + NSError *jsonError; + if (data != nil) { + id json = [NSJSONSerialization JSONObjectWithData:data options:NULL error:&jsonError]; + if (!jsonError) { + [notification setObject:json forKey:@"data"]; + } + } + [RNNotifications didReceiveBackgroundNotification:notification withCompletionHandler:completionHandler]; + }]; } else { completionHandler(UIBackgroundFetchResultNewData); } @@ -130,6 +143,7 @@ NSString* const NOTIFICATION_TEST_ACTION = @"test"; -(void)applicationWillTerminate:(UIApplication *)application { [[GekidouWrapper default] setPreference:@"false" forKey:@"ApplicationIsForeground"]; + [[GekidouWrapper default] setPreference:@"false" forKey:@"ApplicationIsRunning"]; } - (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { diff --git a/ios/MattermostShare/ViewModels/ShareViewModel.swift b/ios/MattermostShare/ViewModels/ShareViewModel.swift index eb5a753932..fa5a0470cc 100644 --- a/ios/MattermostShare/ViewModels/ShareViewModel.swift +++ b/ios/MattermostShare/ViewModels/ShareViewModel.swift @@ -91,7 +91,7 @@ class ShareViewModel: ObservableObject { return } - Gekidou.Network.default.fetchUserProfilePicture(userId: userId, lastUpdateAt: 0, withServerUrl: serverUrl, completionHandler: {data, response, error in + Gekidou.Network.default.fetchUserProfilePicture(userId: userId, lastUpdateAt: 0, forServerUrl: serverUrl, completionHandler: {data, response, error in guard (response as? HTTPURLResponse)?.statusCode == 200 else { debugPrint("Error while fetching image \(String(describing: (response as? HTTPURLResponse)?.statusCode))") return diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index a5d6e305c6..5ac2d24d4a 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -4,12 +4,8 @@ import Intents import os.log class NotificationService: UNNotificationServiceExtension { - let preferences = Gekidou.Preferences.default - let fibonacciBackoffsInSeconds = [1.0, 2.0, 3.0, 5.0, 8.0] var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? - - var retryIndex = 0 override init() { super.init() @@ -20,68 +16,66 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - if let bestAttemptContent = bestAttemptContent, - let jsonData = try? JSONSerialization.data(withJSONObject: bestAttemptContent.userInfo), - let ackNotification = try? JSONDecoder().decode(AckNotification.self, from: jsonData) { - fetchReceipt(ackNotification) - } else { - os_log(OSLogType.default, "Mattermost Notifications: bestAttemptContent seems to be empty, will call sendMessageIntent") - sendMessageIntent(notification: request.content) - } - } - - func processResponse(serverUrl: String, data: Data, bestAttemptContent: UNMutableNotificationContent) { - bestAttemptContent.userInfo["server_url"] = serverUrl - os_log( - OSLogType.default, - "Mattermost Notifications: process receipt response for serverUrl %{public}@", - serverUrl - ) - let json = try? JSONSerialization.jsonObject(with: data) as! [String: Any] - os_log( - OSLogType.default, - "Mattermost Notifications: parsed json response %{public}@", - String(describing: json != nil) - ) - if let json = json { - if let message = json["message"] as? String { - bestAttemptContent.body = message - } - if let channelName = json["channel_name"] as? String { - bestAttemptContent.title = channelName - } - - let userInfoKeys = ["channel_name", "team_id", "sender_id", "sender_name", "root_id", "override_username", "override_icon_url", "from_webhook", "message"] - for key in userInfoKeys { - if let value = json[key] as? String { - bestAttemptContent.userInfo[key] = value + if let bestAttemptContent = bestAttemptContent { + PushNotification.default.postNotificationReceipt(bestAttemptContent, completionHandler: {[weak self] notification in + if let notification = notification { + self?.bestAttemptContent = notification + if (Gekidou.Preferences.default.object(forKey: "ApplicationIsRunning") as? String != "true") { + PushNotification.default.fetchAndStoreDataForPushNotification(bestAttemptContent, withContentHandler: {notification in + os_log(OSLogType.default, "Mattermost Notifications: processed data for db. Will call sendMessageIntent") + self?.sendMessageIntent() + }) + } else { + bestAttemptContent.badge = Gekidou.Database.default.getTotalMentions() as NSNumber + os_log(OSLogType.default, "Mattermost Notifications: app in the foreground, no data processed. Will call sendMessageIntent") + self?.sendMessageIntent() + } + return } - } - } - - if (preferences.object(forKey: "ApplicationIsForeground") as? String != "true") { - Network.default.fetchAndStoreDataForPushNotification(bestAttemptContent, withContentHandler: {[weak self] notification in - os_log(OSLogType.default, "Mattermost Notifications: processed data for db. Will call sendMessageIntent") - self?.sendMessageIntent(notification: bestAttemptContent) + + os_log(OSLogType.default, "Mattermost Notifications: notification receipt seems to be empty, will call sendMessageIntent") + self?.sendMessageIntent() }) } else { - bestAttemptContent.badge = Gekidou.Database.default.getTotalMentions() as NSNumber - os_log(OSLogType.default, "Mattermost Notifications: app in the foreground, no data processed. Will call sendMessageIntent") - sendMessageIntent(notification: bestAttemptContent) + os_log(OSLogType.default, "Mattermost Notifications: bestAttemptContent seems to be empty, will call sendMessageIntent") + sendMessageIntent() } } - + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. os_log(OSLogType.default, "Mattermost Notifications: service extension time expired") - if let bestAttemptContent = bestAttemptContent { - os_log(OSLogType.default, "Mattermost Notifications: calling sendMessageIntent before expiration") - sendMessageIntent(notification: bestAttemptContent) + os_log(OSLogType.default, "Mattermost Notifications: calling sendMessageIntent before expiration") + sendMessageIntent() + } + + private func sendMessageIntent() { + guard let notification = bestAttemptContent else { return } + if #available(iOSApplicationExtension 15.0, *) { + let overrideUsername = notification.userInfo["override_username"] as? String + let senderId = notification.userInfo["sender_id"] as? String + let sender = overrideUsername ?? senderId + + guard let serverUrl = notification.userInfo["server_url"] as? String, + let sender = sender + else { + os_log(OSLogType.default, "Mattermost Notifications: No intent created. will call contentHandler to present notification") + self.contentHandler?(notification) + return + } + + let overrideIconUrl = notification.userInfo["override_icon_url"] as? String + os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, sender) + + PushNotification.default.fetchProfileImageSync(serverUrl, senderId: sender, overrideIconUrl: overrideIconUrl) {[weak self] data in + self?.sendMessageIntentCompletion(data) + } } } - func sendMessageIntentCompletion(_ notification: UNNotificationContent, _ avatarData: Data?) { + private func sendMessageIntentCompletion(_ avatarData: Data?) { + guard let notification = bestAttemptContent else { return } if #available(iOSApplicationExtension 15.0, *), let imgData = avatarData, let channelId = notification.userInfo["channel_id"] as? String { @@ -139,79 +133,4 @@ class NotificationService: UNNotificationServiceExtension { self.contentHandler?(notification) } } - - func sendMessageIntent(notification: UNNotificationContent) { - if #available(iOSApplicationExtension 15.0, *) { - let overrideUsername = notification.userInfo["override_username"] as? String - let senderId = notification.userInfo["sender_id"] as? String - let sender = overrideUsername ?? senderId - - guard let serverUrl = notification.userInfo["server_url"] as? String, - let sender = sender - else { - os_log(OSLogType.default, "Mattermost Notifications: No intent created. will call contentHandler to present notification") - self.contentHandler?(notification) - return - } - - let overrideIconUrl = notification.userInfo["override_icon_url"] as? String - os_log(OSLogType.default, "Mattermost Notifications: Fetching profile Image in server %{public}@ for sender %{public}@", serverUrl, sender) - - Network.default.fetchProfileImageSync(serverUrl, senderId: sender, overrideIconUrl: overrideIconUrl) {[weak self] data in - self?.sendMessageIntentCompletion(notification, data) - } - } - } - - func fetchReceipt(_ ackNotification: AckNotification) -> Void { - if (self.retryIndex >= self.fibonacciBackoffsInSeconds.count) { - os_log(OSLogType.default, "Mattermost Notifications: max retries reached. Will call sendMessageIntent") - sendMessageIntent(notification: bestAttemptContent!) - return - } - - Network.default.postNotificationReceipt(ackNotification) {data, response, error in - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - os_log( - OSLogType.default, - "Mattermost Notifications: notification receipt failed with status %{public}@. Will call sendMessageIntent", - String(describing: httpResponse.statusCode) - ) - self.sendMessageIntent(notification: self.bestAttemptContent!) - return - } - - guard let data = data, error == nil else { - if (ackNotification.isIdLoaded) { - // Receipt retrieval failed. Kick off retries. - let backoffInSeconds = self.fibonacciBackoffsInSeconds[self.retryIndex] - - DispatchQueue.main.asyncAfter(deadline: .now() + backoffInSeconds, execute: { - os_log( - OSLogType.default, - "Mattermost Notifications: receipt retrieval failed. Retry %{public}@", - String(describing: self.retryIndex) - ) - self.fetchReceipt(ackNotification) - }) - - self.retryIndex += 1 - } - return - } - - self.processResponse(serverUrl: ackNotification.serverUrl, data: data, bestAttemptContent: self.bestAttemptContent!) - } - } - -} - -extension Date { - var millisecondsSince1970: Int { - return Int((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - init(milliseconds: Int) { - self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) - } }