mirror of https://github.com/oxen-io/session-ios
Merge branch 'dev' into switch-video-view
commit
e261678d6d
@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// This migration adds the FTS table back for internal test users whose FTS table was removed unintentionally
|
||||
enum _012_AddFTSIfNeeded: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "AddFTSIfNeeded"
|
||||
static let needsConfigSync: Bool = false
|
||||
static let minExpectedRunDuration: TimeInterval = 0.1
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
// Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work.
|
||||
// This issue only happens to internal test users.
|
||||
if try db.tableExists(Interaction.fullTextSearchTableName) == false {
|
||||
try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: Interaction.databaseTableName)
|
||||
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
||||
|
||||
t.column(Interaction.Columns.body.name)
|
||||
t.column(Interaction.Columns.threadId.name)
|
||||
}
|
||||
}
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
@ -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