mirror of https://github.com/oxen-io/session-ios
				
				
				
			Merge remote-tracking branch 'upstream/dev' into feature/job-runner-unit-tests
# Conflicts: # SessionMessagingKit/Configuration.swift # SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift # SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift # SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift # SessionMessagingKit/Jobs/Types/MessageSendJob.swift # SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift # SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift # SessionUtilitiesKit/JobRunner/JobRunner.swiftpull/813/head
						commit
						4801ebd7c2
					
				@ -0,0 +1,166 @@
 | 
			
		||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
 | 
			
		||||
 | 
			
		||||
import Foundation
 | 
			
		||||
import GRDB
 | 
			
		||||
import PromiseKit
 | 
			
		||||
import SignalCoreKit
 | 
			
		||||
import SessionUtilitiesKit
 | 
			
		||||
import SessionSnodeKit
 | 
			
		||||
 | 
			
		||||
public enum GroupLeavingJob: JobExecutor {
 | 
			
		||||
    public static var maxFailureCount: Int = 0
 | 
			
		||||
    public static var requiresThreadId: Bool = true
 | 
			
		||||
    public static var requiresInteractionId: Bool = true
 | 
			
		||||
    
 | 
			
		||||
    public static func run(
 | 
			
		||||
        _ job: SessionUtilitiesKit.Job,
 | 
			
		||||
        queue: DispatchQueue,
 | 
			
		||||
        success: @escaping (SessionUtilitiesKit.Job, Bool) -> (),
 | 
			
		||||
        failure: @escaping (SessionUtilitiesKit.Job, Error?, Bool) -> (),
 | 
			
		||||
        deferred: @escaping (SessionUtilitiesKit.Job) -> ())
 | 
			
		||||
    {
 | 
			
		||||
        guard
 | 
			
		||||
            let detailsData: Data = job.details,
 | 
			
		||||
            let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData),
 | 
			
		||||
            let interactionId: Int64 = job.interactionId
 | 
			
		||||
        else {
 | 
			
		||||
            failure(job, JobRunnerError.missingRequiredDetails, true)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        guard let thread: SessionThread = Storage.shared.read({ db in try? SessionThread.fetchOne(db, id: details.groupPublicKey)}) else {
 | 
			
		||||
            SNLog("Can't leave nonexistent closed group.")
 | 
			
		||||
            failure(job, MessageSenderError.noThread, true)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        guard let closedGroup: ClosedGroup = Storage.shared.read({ db in try? thread.closedGroup.fetchOne(db)}) else {
 | 
			
		||||
            failure(job, MessageSenderError.invalidClosedGroupUpdate, true)
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        Storage.shared.writeAsync { db -> Promise<Void> in
 | 
			
		||||
            try MessageSender.sendNonDurably(
 | 
			
		||||
                db,
 | 
			
		||||
                message: ClosedGroupControlMessage(
 | 
			
		||||
                    kind: .memberLeft
 | 
			
		||||
                ),
 | 
			
		||||
                interactionId: interactionId,
 | 
			
		||||
                in: thread
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        .done(on: queue) { _ in
 | 
			
		||||
            // Remove the group from the database and unsubscribe from PNs
 | 
			
		||||
            ClosedGroupPoller.shared.stopPolling(for: details.groupPublicKey)
 | 
			
		||||
            
 | 
			
		||||
            Storage.shared.writeAsync { db in
 | 
			
		||||
                let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | 
			
		||||
                
 | 
			
		||||
                try closedGroup
 | 
			
		||||
                    .keyPairs
 | 
			
		||||
                    .deleteAll(db)
 | 
			
		||||
                
 | 
			
		||||
                let _ = PushNotificationAPI.performOperation(
 | 
			
		||||
                    .unsubscribe,
 | 
			
		||||
                    for: details.groupPublicKey,
 | 
			
		||||
                    publicKey: userPublicKey
 | 
			
		||||
                )
 | 
			
		||||
                
 | 
			
		||||
                try Interaction
 | 
			
		||||
                    .filter(id: interactionId)
 | 
			
		||||
                    .updateAll(
 | 
			
		||||
                        db,
 | 
			
		||||
                        [
 | 
			
		||||
                            Interaction.Columns.variant.set(to: Interaction.Variant.infoClosedGroupCurrentUserLeft),
 | 
			
		||||
                            Interaction.Columns.body.set(to: "GROUP_YOU_LEFT".localized())
 | 
			
		||||
                        ]
 | 
			
		||||
                    )
 | 
			
		||||
                
 | 
			
		||||
                // Update the group (if the admin leaves the group is disbanded)
 | 
			
		||||
                let wasAdminUser: Bool = try GroupMember
 | 
			
		||||
                    .filter(GroupMember.Columns.groupId == thread.id)
 | 
			
		||||
                    .filter(GroupMember.Columns.profileId == userPublicKey)
 | 
			
		||||
                    .filter(GroupMember.Columns.role == GroupMember.Role.admin)
 | 
			
		||||
                    .isNotEmpty(db)
 | 
			
		||||
                
 | 
			
		||||
                if wasAdminUser {
 | 
			
		||||
                    try GroupMember
 | 
			
		||||
                        .filter(GroupMember.Columns.groupId == thread.id)
 | 
			
		||||
                        .deleteAll(db)
 | 
			
		||||
                }
 | 
			
		||||
                else {
 | 
			
		||||
                    try GroupMember
 | 
			
		||||
                        .filter(GroupMember.Columns.groupId == thread.id)
 | 
			
		||||
                        .filter(GroupMember.Columns.profileId == userPublicKey)
 | 
			
		||||
                        .deleteAll(db)
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if details.deleteThread {
 | 
			
		||||
                    _ = try SessionThread
 | 
			
		||||
                        .filter(id: thread.id)
 | 
			
		||||
                        .deleteAll(db)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            success(job, false)
 | 
			
		||||
        }
 | 
			
		||||
        .catch(on: queue) { error in
 | 
			
		||||
            Storage.shared.writeAsync { db in
 | 
			
		||||
                try Interaction
 | 
			
		||||
                    .filter(id: job.interactionId)
 | 
			
		||||
                    .updateAll(
 | 
			
		||||
                        db,
 | 
			
		||||
                        [
 | 
			
		||||
                            Interaction.Columns.variant.set(to: Interaction.Variant.infoClosedGroupCurrentUserErrorLeaving),
 | 
			
		||||
                            Interaction.Columns.body.set(to: "group_unable_to_leave".localized())
 | 
			
		||||
                        ]
 | 
			
		||||
                    )
 | 
			
		||||
            }
 | 
			
		||||
            success(job, false)
 | 
			
		||||
        }
 | 
			
		||||
        .retainUntilComplete()
 | 
			
		||||
        
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MARK: - GroupLeavingJob.Details
 | 
			
		||||
 | 
			
		||||
extension GroupLeavingJob {
 | 
			
		||||
    public struct Details: Codable {
 | 
			
		||||
        private enum CodingKeys: String, CodingKey {
 | 
			
		||||
            case groupPublicKey
 | 
			
		||||
            case deleteThread
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        public let groupPublicKey: String
 | 
			
		||||
        public let deleteThread: Bool
 | 
			
		||||
        
 | 
			
		||||
        // MARK: - Initialization
 | 
			
		||||
        
 | 
			
		||||
        public init(
 | 
			
		||||
            groupPublicKey: String,
 | 
			
		||||
            deleteThread: Bool
 | 
			
		||||
        ) {
 | 
			
		||||
            self.groupPublicKey = groupPublicKey
 | 
			
		||||
            self.deleteThread = deleteThread
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // MARK: - Codable
 | 
			
		||||
        
 | 
			
		||||
        public init(from decoder: Decoder) throws {
 | 
			
		||||
            let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
 | 
			
		||||
            
 | 
			
		||||
            self = Details(
 | 
			
		||||
                groupPublicKey: try container.decode(String.self, forKey: .groupPublicKey),
 | 
			
		||||
                deleteThread: try container.decode(Bool.self, forKey: .deleteThread)
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        public func encode(to encoder: Encoder) throws {
 | 
			
		||||
            var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
 | 
			
		||||
 | 
			
		||||
            try container.encode(groupPublicKey, forKey: .groupPublicKey)
 | 
			
		||||
            try container.encode(deleteThread, forKey: .deleteThread)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,182 @@
 | 
			
		||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
 | 
			
		||||
 | 
			
		||||
import UIKit
 | 
			
		||||
import SessionUtilitiesKit
 | 
			
		||||
 | 
			
		||||
public extension UIContextualAction {
 | 
			
		||||
    private static var lookupMap: Atomic<[Int: [String: [Int: ThemeValue]]]> = Atomic([:])
 | 
			
		||||
    
 | 
			
		||||
    enum Side: Int {
 | 
			
		||||
        case leading
 | 
			
		||||
        case trailing
 | 
			
		||||
        
 | 
			
		||||
        func key(for indexPath: IndexPath) -> String {
 | 
			
		||||
            return "\(indexPath.section)-\(indexPath.row)-\(rawValue)"
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        init?(for view: UIView) {
 | 
			
		||||
            guard view.frame.minX == 0 else {
 | 
			
		||||
                self = .trailing
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            self = .leading
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    convenience init(
 | 
			
		||||
        title: String? = nil,
 | 
			
		||||
        icon: UIImage? = nil,
 | 
			
		||||
        iconHeight: CGFloat = Values.mediumFontSize,
 | 
			
		||||
        themeTintColor: ThemeValue = .white,
 | 
			
		||||
        themeBackgroundColor: ThemeValue,
 | 
			
		||||
        side: Side,
 | 
			
		||||
        actionIndex: Int,
 | 
			
		||||
        indexPath: IndexPath,
 | 
			
		||||
        tableView: UITableView,
 | 
			
		||||
        handler: @escaping UIContextualAction.Handler
 | 
			
		||||
    ) {
 | 
			
		||||
        self.init(style: .normal, title: title, handler: handler)
 | 
			
		||||
        self.image = UIContextualAction
 | 
			
		||||
            .imageWith(
 | 
			
		||||
                title: title,
 | 
			
		||||
                icon: icon,
 | 
			
		||||
                iconHeight: iconHeight,
 | 
			
		||||
                themeTintColor: themeTintColor
 | 
			
		||||
            )?
 | 
			
		||||
            .withRenderingMode(.alwaysTemplate)
 | 
			
		||||
        self.themeBackgroundColor = themeBackgroundColor
 | 
			
		||||
        
 | 
			
		||||
        UIContextualAction.lookupMap.mutate {
 | 
			
		||||
            $0[tableView.hashValue] = ($0[tableView.hashValue] ?? [:])
 | 
			
		||||
                .setting(
 | 
			
		||||
                    side.key(for: indexPath),
 | 
			
		||||
                    (($0[tableView.hashValue] ?? [:])[side.key(for: indexPath)] ?? [:])
 | 
			
		||||
                        .setting(actionIndex, themeTintColor)
 | 
			
		||||
                )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private static func imageWith(
 | 
			
		||||
        title: String?,
 | 
			
		||||
        icon: UIImage?,
 | 
			
		||||
        iconHeight: CGFloat,
 | 
			
		||||
        themeTintColor: ThemeValue
 | 
			
		||||
    ) -> UIImage? {
 | 
			
		||||
        let stackView: UIStackView = UIStackView()
 | 
			
		||||
        stackView.axis = .vertical
 | 
			
		||||
        stackView.alignment = .center
 | 
			
		||||
        stackView.spacing = 4
 | 
			
		||||
        
 | 
			
		||||
        if let icon: UIImage = icon {
 | 
			
		||||
            let scale: Double = iconHeight / icon.size.height
 | 
			
		||||
            let aspectRatio: CGFloat = (icon.size.width / icon.size.height)
 | 
			
		||||
            let imageView: UIImageView = UIImageView(image: icon)
 | 
			
		||||
            imageView.frame = CGRect(x: 0, y: 0, width: iconHeight * aspectRatio, height: iconHeight)
 | 
			
		||||
            imageView.contentMode = .scaleAspectFit
 | 
			
		||||
            imageView.themeTintColor = themeTintColor
 | 
			
		||||
            stackView.addArrangedSubview(imageView)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if let title: String = title {
 | 
			
		||||
            let label: UILabel = UILabel()
 | 
			
		||||
            label.font = .systemFont(ofSize: Values.verySmallFontSize)
 | 
			
		||||
            label.text = title
 | 
			
		||||
            label.textAlignment = .center
 | 
			
		||||
            label.themeTextColor = themeTintColor
 | 
			
		||||
            label.minimumScaleFactor = 0.75
 | 
			
		||||
            label.numberOfLines = (title.components(separatedBy: " ").count > 1 ? 2 : 1)
 | 
			
		||||
            label.frame = CGRect(
 | 
			
		||||
                origin: .zero,
 | 
			
		||||
                // Note: It looks like there is a semi-max width of 68px for images in the swipe actions
 | 
			
		||||
                // if the image ends up larger then there an odd behaviour can occur where 8/10 times the
 | 
			
		||||
                // image is scaled down to fit, but ocassionally (primarily if you hide the action and
 | 
			
		||||
                // immediately swipe to show it again once the cell hits the edge of the screen) the image
 | 
			
		||||
                // won't be scaled down but will be full size - appearing as if two different images are used
 | 
			
		||||
                size: label.sizeThatFits(CGSize(width: 68, height: 999))
 | 
			
		||||
            )
 | 
			
		||||
            label.set(.width, to: label.frame.width)
 | 
			
		||||
            
 | 
			
		||||
            stackView.addArrangedSubview(label)
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        stackView.frame = CGRect(
 | 
			
		||||
            origin: .zero,
 | 
			
		||||
            size: stackView.systemLayoutSizeFitting(CGSize(width: 999, height: 999))
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        // Based on https://stackoverflow.com/a/41288197/1118398
 | 
			
		||||
        let renderFormat: UIGraphicsImageRendererFormat = UIGraphicsImageRendererFormat()
 | 
			
		||||
        renderFormat.scale = UIScreen.main.scale
 | 
			
		||||
        
 | 
			
		||||
        let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(
 | 
			
		||||
            size: stackView.bounds.size,
 | 
			
		||||
            format: renderFormat
 | 
			
		||||
        )
 | 
			
		||||
        return renderer.image { rendererContext in
 | 
			
		||||
            stackView.layer.render(in: rendererContext.cgContext)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    private static func firstSubviewOfType<T>(in superview: UIView) -> T? {
 | 
			
		||||
        guard !(superview is T) else { return superview as? T }
 | 
			
		||||
        guard !superview.subviews.isEmpty else { return nil }
 | 
			
		||||
        
 | 
			
		||||
        for subview in superview.subviews {
 | 
			
		||||
            if let result: T = firstSubviewOfType(in: subview) {
 | 
			
		||||
                return result
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return nil
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    static func willBeginEditing(indexPath: IndexPath, tableView: UITableView) {
 | 
			
		||||
        guard
 | 
			
		||||
            let targetCell: UITableViewCell = tableView.cellForRow(at: indexPath),
 | 
			
		||||
            targetCell.superview != tableView,
 | 
			
		||||
            let targetSuperview: UIView = targetCell.superview?
 | 
			
		||||
                .subviews
 | 
			
		||||
                .filter({ $0 != targetCell })
 | 
			
		||||
                .first,
 | 
			
		||||
            let side: Side = Side(for: targetSuperview),
 | 
			
		||||
            let themeMap: [Int: ThemeValue] = UIContextualAction.lookupMap.wrappedValue
 | 
			
		||||
                .getting(tableView.hashValue)?
 | 
			
		||||
                .getting(side.key(for: indexPath)),
 | 
			
		||||
            targetSuperview.subviews.count == themeMap.count
 | 
			
		||||
        else { return }
 | 
			
		||||
        
 | 
			
		||||
        let targetViews: [UIImageView] = targetSuperview.subviews
 | 
			
		||||
            .compactMap { subview in firstSubviewOfType(in: subview) }
 | 
			
		||||
        
 | 
			
		||||
        guard targetViews.count == themeMap.count else { return }
 | 
			
		||||
        
 | 
			
		||||
        // Set the imageView and background colours (so they change correctly when the theme changes)
 | 
			
		||||
        targetViews.enumerated().forEach { index, targetView in
 | 
			
		||||
            guard let themeTintColor: ThemeValue = themeMap[index] else { return }
 | 
			
		||||
            
 | 
			
		||||
            targetView.themeTintColor = themeTintColor
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    static func didEndEditing(indexPath: IndexPath?, tableView: UITableView) {
 | 
			
		||||
        guard let indexPath: IndexPath = indexPath else { return }
 | 
			
		||||
        
 | 
			
		||||
        let leadingKey: String = Side.leading.key(for: indexPath)
 | 
			
		||||
        let trailingKey: String = Side.trailing.key(for: indexPath)
 | 
			
		||||
        
 | 
			
		||||
        guard
 | 
			
		||||
            UIContextualAction.lookupMap.wrappedValue[tableView.hashValue]?[leadingKey] != nil ||
 | 
			
		||||
            UIContextualAction.lookupMap.wrappedValue[tableView.hashValue]?[trailingKey] != nil
 | 
			
		||||
        else { return }
 | 
			
		||||
        
 | 
			
		||||
        UIContextualAction.lookupMap.mutate {
 | 
			
		||||
            $0[tableView.hashValue]?[leadingKey] = nil
 | 
			
		||||
            $0[tableView.hashValue]?[trailingKey] = nil
 | 
			
		||||
            
 | 
			
		||||
            if $0[tableView.hashValue]?.isEmpty == true {
 | 
			
		||||
                $0[tableView.hashValue] = nil
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue