Working on the MediaGallery and ClosedGroup handling
Fixed a couple of issues around the duplicate messages handling Fixed a few issues with ClosedGroup polling and ClosedGroup control message handling Started working through updating the MediaGallery
This commit is contained in:
parent
f4ca219030
commit
0db74ce1e3
|
@ -727,6 +727,8 @@
|
|||
FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; };
|
||||
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; };
|
||||
FD09C5E428237209000CE219 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */; };
|
||||
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; };
|
||||
FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; };
|
||||
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; };
|
||||
FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; };
|
||||
FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; };
|
||||
|
@ -1782,6 +1784,8 @@
|
|||
FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = "<group>"; };
|
||||
FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = "<group>"; };
|
||||
FD09C5E328237209000CE219 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = "<group>"; };
|
||||
FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = "<group>"; };
|
||||
FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = "<group>"; };
|
||||
FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = "<group>"; };
|
||||
FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
||||
FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = "<group>"; };
|
||||
|
@ -2927,6 +2931,8 @@
|
|||
34969559219B605E00DCFE74 /* ImagePickerController.swift */,
|
||||
45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */,
|
||||
45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */,
|
||||
FD09C5E728264937000CE219 /* MediaDetailViewController.swift */,
|
||||
FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */,
|
||||
452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */,
|
||||
45F32C1D205718B000A300D5 /* MediaPageViewController.swift */,
|
||||
454A84032059C787008B8C75 /* MediaTileViewController.swift */,
|
||||
|
@ -5067,6 +5073,7 @@
|
|||
3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */,
|
||||
34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */,
|
||||
34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */,
|
||||
FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */,
|
||||
7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */,
|
||||
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */,
|
||||
3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */,
|
||||
|
@ -5140,6 +5147,7 @@
|
|||
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
|
||||
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
|
||||
34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */,
|
||||
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */,
|
||||
FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */,
|
||||
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
|
||||
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
|
||||
|
|
|
@ -1,66 +1,77 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc(SNEditClosedGroupVC)
|
||||
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private let thread: TSGroupThread
|
||||
private var name = ""
|
||||
private var zombies: Set<String> = []
|
||||
private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } }
|
||||
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
|
||||
let profileId: String
|
||||
let role: GroupMember.Role
|
||||
let profile: Profile?
|
||||
}
|
||||
|
||||
private let threadId: String
|
||||
private var originalName: String = ""
|
||||
private var originalMembersAndZombieIds: Set<String> = []
|
||||
private var name: String = ""
|
||||
private var hasContactsToAdd: Bool = false
|
||||
private var userPublicKey: String = ""
|
||||
private var membersAndZombies: [GroupMemberDisplayInfo] = []
|
||||
private var adminIds: Set<String> = []
|
||||
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
|
||||
private var tableViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
private lazy var groupPublicKey: String = {
|
||||
let groupID = thread.groupModel.groupId
|
||||
return LKGroupUtilities.getDecodedGroupID(groupID)
|
||||
}()
|
||||
// MARK: - Components
|
||||
|
||||
// MARK: Components
|
||||
private lazy var groupNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var groupNameTextField: TextField = {
|
||||
let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||
let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var addMembersButton: Button = {
|
||||
let result = Button(style: .prominentOutline, size: .large)
|
||||
let result: Button = Button(style: .prominentOutline, size: .large)
|
||||
result.setTitle("Add Members", for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
||||
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
@objc private lazy var tableView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc(initWithThreadID:)
|
||||
init(with threadID: String) {
|
||||
var thread: TSGroupThread!
|
||||
Storage.read { transaction in
|
||||
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
|
||||
}
|
||||
self.thread = thread
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc(initWithThreadId:)
|
||||
init(with threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -70,27 +81,61 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle("Edit Group")
|
||||
|
||||
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
||||
backButton.tintColor = Colors.text
|
||||
navigationItem.backBarButtonItem = backButton
|
||||
func getDisplayName(for publicKey: String) -> String {
|
||||
return Profile.displayName(for: publicKey)
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
GRDBStorage.shared.read { [weak self] db in
|
||||
self?.userPublicKey = getUserHexEncodedPublicKey(db)
|
||||
self?.name = try ClosedGroup
|
||||
.select(.name)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
.defaulting(to: "Group")
|
||||
self?.originalName = (self?.name ?? "")
|
||||
|
||||
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == threadId)
|
||||
.including(optional: GroupMember.profile.aliased(profileAlias))
|
||||
.order(
|
||||
(GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top
|
||||
profileAlias[.nickname],
|
||||
profileAlias[.name],
|
||||
GroupMember.Columns.profileId
|
||||
)
|
||||
.asRequest(of: GroupMemberDisplayInfo.self)
|
||||
.fetchAll(db)
|
||||
self?.membersAndZombies = allGroupMembers
|
||||
.filter { $0.role == .standard || $0.role == .zombie }
|
||||
self?.adminIds = allGroupMembers
|
||||
.filter { $0.role == .admin }
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
|
||||
let uniqueGroupMemberIds: Set<String> = allGroupMembers
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
self?.originalMembersAndZombieIds = uniqueGroupMemberIds
|
||||
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||
}
|
||||
|
||||
setUpViewHierarchy()
|
||||
// Always show zombies at the bottom
|
||||
zombies = Storage.shared.getZombieMembers(for: groupPublicKey)
|
||||
membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
+ zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
updateNavigationBarButtons()
|
||||
name = thread.groupModel.groupName!
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Group name container
|
||||
groupNameLabel.text = thread.groupModel.groupName
|
||||
groupNameLabel.text = name
|
||||
|
||||
let groupNameContainer = UIView()
|
||||
groupNameContainer.addSubview(groupNameLabel)
|
||||
groupNameLabel.pin(to: groupNameContainer)
|
||||
|
@ -98,6 +143,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
groupNameTextField.pin(to: groupNameContainer)
|
||||
groupNameContainer.set(.height, to: 40)
|
||||
groupNameTextField.alpha = 0
|
||||
|
||||
// Top container
|
||||
let topContainer = UIView()
|
||||
topContainer.addSubview(groupNameContainer)
|
||||
|
@ -105,19 +151,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
topContainer.set(.height, to: 40)
|
||||
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
|
||||
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
|
||||
|
||||
// Members label
|
||||
let membersLabel = UILabel()
|
||||
membersLabel.textColor = Colors.text
|
||||
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
membersLabel.text = "Members"
|
||||
|
||||
// Add members button
|
||||
let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty
|
||||
if (!hasContactsToAdd) {
|
||||
if !self.hasContactsToAdd {
|
||||
addMembersButton.isUserInteractionEnabled = false
|
||||
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
addMembersButton.layer.borderColor = disabledColor.cgColor
|
||||
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
|
||||
}
|
||||
|
||||
// Middle stack view
|
||||
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
|
||||
middleStackView.axis = .horizontal
|
||||
|
@ -125,8 +173,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
|
||||
middleStackView.isLayoutMarginsRelativeArrangement = true
|
||||
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
|
||||
|
||||
// Table view
|
||||
tableViewHeightConstraint = tableView.set(.height, to: 0)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [
|
||||
UIView.vSpacer(Values.veryLargeSpacing),
|
||||
|
@ -140,6 +190,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
// Scroll view
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
|
@ -155,41 +206,48 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = membersAndZombies[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
cell.isZombie = zombies.contains(publicKey)
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
cell.accessory = !isCurrentUserAdmin ? .lock : .none
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: membersAndZombies[indexPath.row].profileId,
|
||||
profile: membersAndZombies[indexPath.row].profile,
|
||||
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
|
||||
accessory: (adminIds.contains(userPublicKey) ?
|
||||
.none :
|
||||
.lock
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
return thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
return adminIds.contains(userPublicKey)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
let publicKey = membersAndZombies[indexPath.row]
|
||||
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
||||
|
||||
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
|
||||
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return }
|
||||
self.membersAndZombies.remove(at: index)
|
||||
self?.adminIds.remove(profileId)
|
||||
self?.membersAndZombies.remove(at: indexPath.row)
|
||||
}
|
||||
removeAction.backgroundColor = Colors.destructive
|
||||
|
||||
return [ removeAction ]
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
private func updateNavigationBarButtons() {
|
||||
if isEditingGroupName {
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
|
||||
cancelButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
@ -199,21 +257,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
||||
private func handleIsEditingGroupNameChanged() {
|
||||
updateNavigationBarButtons()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
|
||||
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
|
||||
}
|
||||
|
||||
if isEditingGroupName {
|
||||
groupNameTextField.becomeFirstResponder()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
groupNameTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func showEditGroupNameUI() {
|
||||
isEditingGroupName = true
|
||||
}
|
||||
|
@ -225,92 +287,159 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
@objc private func handleDoneButtonTapped() {
|
||||
if isEditingGroupName {
|
||||
updateGroupName()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
commitChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateGroupName() {
|
||||
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
|
||||
let updatedName: String = groupNameTextField.text
|
||||
.defaulting(to: "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
guard !updatedName.isEmpty else {
|
||||
return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased())
|
||||
}
|
||||
guard name.count < 64 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
|
||||
guard updatedName.count < 64 else {
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
|
||||
isEditingGroupName = false
|
||||
self.name = name
|
||||
groupNameLabel.text = name
|
||||
groupNameLabel.text = updatedName
|
||||
self.name = updatedName
|
||||
}
|
||||
|
||||
@objc private func addMembers() {
|
||||
let title = "Add Members"
|
||||
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
|
||||
guard let self = self else { return }
|
||||
var members = self.membersAndZombies
|
||||
members.append(contentsOf: selectedUsers)
|
||||
func getDisplayName(for publicKey: String) -> String {
|
||||
return Profile.displayName(for: publicKey)
|
||||
|
||||
let userSelectionVC: UserSelectionVC = UserSelectionVC(
|
||||
with: title,
|
||||
excluding: membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
) { [weak self] selectedUserIds in
|
||||
GRDBStorage.shared.read { [weak self] db in
|
||||
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let selectedGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
|
||||
.filter(selectedUserIds.contains(GroupMember.Columns.profileId))
|
||||
.including(optional: GroupMember.profile.aliased(profileAlias))
|
||||
.asRequest(of: GroupMemberDisplayInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
self?.membersAndZombies = (self?.membersAndZombies ?? [])
|
||||
.appending(contentsOf: selectedGroupMembers)
|
||||
.sorted(by: { lhs, rhs in
|
||||
if lhs.role == .zombie && rhs.role != .zombie {
|
||||
return false
|
||||
}
|
||||
else if lhs.role != .zombie && rhs.role == .zombie {
|
||||
return true
|
||||
}
|
||||
|
||||
let lhsDisplayName: String = Profile.displayName(
|
||||
for: .contact,
|
||||
id: lhs.profileId,
|
||||
name: lhs.profile?.name,
|
||||
nickname: lhs.profile?.nickname
|
||||
)
|
||||
let rhsDisplayName: String = Profile.displayName(
|
||||
for: .contact,
|
||||
id: rhs.profileId,
|
||||
name: rhs.profile?.name,
|
||||
nickname: rhs.profile?.nickname
|
||||
)
|
||||
|
||||
return (lhsDisplayName < rhsDisplayName)
|
||||
})
|
||||
.filter { $0.role == .standard || $0.role == .zombie }
|
||||
|
||||
let uniqueGroupMemberIds: Set<String> = (self?.membersAndZombies ?? [])
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
.inserting(contentsOf: self?.adminIds)
|
||||
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||
}
|
||||
self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
let hasContactsToAdd = !Set(Contact.fetchAllIds()).subtracting(self.membersAndZombies).isEmpty
|
||||
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd
|
||||
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
self.addMembersButton.layer.borderColor = color.cgColor
|
||||
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||
|
||||
let color = (self?.hasContactsToAdd == true ?
|
||||
Colors.accent :
|
||||
Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
)
|
||||
self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true)
|
||||
self?.addMembersButton.layer.borderColor = color.cgColor
|
||||
self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||
}
|
||||
navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil)
|
||||
|
||||
navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func commitChanges() {
|
||||
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in
|
||||
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) {
|
||||
editVC.navigationController!.popToViewController(conversationVC, animated: true)
|
||||
} else {
|
||||
editVC.navigationController!.popViewController(animated: true)
|
||||
let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in
|
||||
guard
|
||||
let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers,
|
||||
let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC
|
||||
else {
|
||||
editVC?.navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
editVC?.navigationController?.popToViewController(conversationVC, animated: true)
|
||||
}
|
||||
let storage = SNMessagingKitConfiguration.shared.storage
|
||||
let members = Set(self.membersAndZombies)
|
||||
let name = self.name
|
||||
let zombies = storage.getZombieMembers(for: groupPublicKey)
|
||||
guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else {
|
||||
|
||||
let threadId: String = self.threadId
|
||||
let updatedName: String = self.name
|
||||
let userPublicKey: String = self.userPublicKey
|
||||
let updatedMemberIds: Set<String> = self.membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
|
||||
guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else {
|
||||
return popToConversationVC(self)
|
||||
}
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else {
|
||||
return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.")
|
||||
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else {
|
||||
return showError(
|
||||
title: "Couldn't Update Group",
|
||||
message: "Can't leave while adding or removing other members."
|
||||
)
|
||||
}
|
||||
}
|
||||
guard members.count <= 100 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: ""))
|
||||
guard updatedMemberIds.count <= 100 else {
|
||||
return showError(title: "vc_create_closed_group_too_many_group_members_error".localized())
|
||||
}
|
||||
var promise: Promise<Void>!
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in
|
||||
Storage.write(with: { transaction in
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
promise = MessageSender.leave(groupPublicKey, using: transaction)
|
||||
} else {
|
||||
promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction)
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
GRDBStorage.shared
|
||||
.write { db in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
|
||||
return try MessageSender.update(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
with: updatedMemberIds,
|
||||
name: updatedName
|
||||
)
|
||||
}
|
||||
}, completion: {
|
||||
let _ = promise.done(on: DispatchQueue.main) {
|
||||
guard let self = self else { return }
|
||||
self.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
.done(on: DispatchQueue.main) { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
popToConversationVC(self)
|
||||
}
|
||||
promise.catch(on: DispatchQueue.main) { error in
|
||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
self?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func showError(title: String, message: String = "") {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentAlert(alert)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private protocol TableViewTouchDelegate {
|
||||
|
||||
func tableViewWasTouched(_ tableView: TableView)
|
||||
}
|
||||
|
||||
private final class TableView : UITableView {
|
||||
private final class TableView: UITableView {
|
||||
var touchDelegate: TableViewTouchDelegate?
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
|
@ -18,107 +19,127 @@ private final class TableView : UITableView {
|
|||
}
|
||||
}
|
||||
|
||||
final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contacts = Contact.fetchAllIds()
|
||||
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||
private var selectedContacts: Set<String> = []
|
||||
|
||||
// MARK: Components
|
||||
private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: ""))
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized())
|
||||
|
||||
private lazy var tableView: TableView = {
|
||||
let result = TableView()
|
||||
let result: TableView = TableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.touchDelegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
|
||||
let customTitleFontSize = Values.largeFontSize
|
||||
setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize)
|
||||
setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize)
|
||||
|
||||
// Set up navigation bar buttons
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = closeButton
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
// Set up content
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
if !contacts.isEmpty {
|
||||
let mainStackView = UIStackView()
|
||||
mainStackView.axis = .vertical
|
||||
nameTextField.delegate = self
|
||||
let nameTextFieldContainer = UIView()
|
||||
nameTextFieldContainer.addSubview(nameTextField)
|
||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.separator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
mainStackView.addArrangedSubview(separator)
|
||||
tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high
|
||||
tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||
mainStackView.addArrangedSubview(tableView)
|
||||
let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
view.addSubview(scrollView)
|
||||
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
||||
scrollView.pin(to: view)
|
||||
} else {
|
||||
let explanationLabel = UILabel()
|
||||
guard !contactProfiles.isEmpty else {
|
||||
let explanationLabel: UILabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
|
||||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
|
||||
let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
view.addSubview(stackView)
|
||||
stackView.center(.horizontal, in: view)
|
||||
|
||||
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
||||
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
|
||||
return
|
||||
}
|
||||
|
||||
let mainStackView: UIStackView = UIStackView()
|
||||
mainStackView.axis = .vertical
|
||||
nameTextField.delegate = self
|
||||
|
||||
let nameTextFieldContainer: UIView = UIView()
|
||||
nameTextFieldContainer.addSubview(nameTextField)
|
||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||
|
||||
let separator: UIView = UIView()
|
||||
separator.backgroundColor = Colors.separator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
mainStackView.addArrangedSubview(separator)
|
||||
tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high
|
||||
tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||
mainStackView.addArrangedSubview(tableView)
|
||||
|
||||
let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
view.addSubview(scrollView)
|
||||
|
||||
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
||||
scrollView.pin(to: view)
|
||||
}
|
||||
|
||||
// MARK: Table View Data Source
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return contacts.count
|
||||
return contactProfiles.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = contacts[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: contactProfiles[indexPath.row].id,
|
||||
profile: contactProfiles[indexPath.row],
|
||||
isZombie: false,
|
||||
accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text!
|
||||
}
|
||||
|
@ -139,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let publicKey = contacts[indexPath.row]
|
||||
if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return }
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
if !selectedContacts.contains(contactProfiles[indexPath.row].id) {
|
||||
selectedContacts.insert(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
else {
|
||||
selectedContacts.remove(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
|
|
|
@ -9,41 +9,46 @@ import GRDB
|
|||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate,
|
||||
SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate,
|
||||
ConversationTitleViewDelegate {
|
||||
extension ConversationVC:
|
||||
InputViewDelegate,
|
||||
MessageCellDelegate,
|
||||
ScrollToBottomButtonDelegate,
|
||||
SendMediaNavDelegate,
|
||||
UIDocumentPickerDelegate,
|
||||
AttachmentApprovalViewControllerDelegate,
|
||||
GifPickerViewControllerDelegate
|
||||
{
|
||||
@objc func handleTitleViewTapped() {
|
||||
// Don't take the user to settings for unapproved threads
|
||||
guard !viewModel.viewData.requiresApproval else { return }
|
||||
|
||||
func handleTitleViewTapped() {
|
||||
// Don't take the user to settings for message requests
|
||||
guard
|
||||
let contactThread: TSContactThread = thread as? TSContactThread,
|
||||
let contact: Contact = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: contactThread.contactSessionID()) }),
|
||||
contact.isApproved,
|
||||
contact.didApproveMe
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
openSettings()
|
||||
}
|
||||
|
||||
|
||||
@objc func openSettings() {
|
||||
let settingsVC = OWSConversationSettingsViewController()
|
||||
settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection)
|
||||
let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController()
|
||||
settingsVC.configure(
|
||||
withThreadId: viewModel.viewData.thread.id,
|
||||
threadName: viewModel.viewData.threadName,
|
||||
isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup),
|
||||
isOpenGroup: (viewModel.viewData.thread.variant == .openGroup),
|
||||
isNoteToSelf: viewModel.viewData.threadIsNoteToSelf
|
||||
)
|
||||
settingsVC.conversationSettingsViewDelegate = self
|
||||
navigationController!.pushViewController(settingsVC, animated: true, completion: nil)
|
||||
navigationController?.pushViewController(settingsVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - ScrollToBottomButtonDelegate
|
||||
|
||||
func handleScrollToBottomButtonTapped() {
|
||||
// The table view's content size is calculated by the estimated height of cells,
|
||||
// so the result may be inaccurate before all the cells are loaded. Use this
|
||||
// to scroll to the last row instead.
|
||||
let indexPath = IndexPath(row: viewItems.count - 1, section: 0)
|
||||
unreadViewItems.removeAll()
|
||||
messagesTableView.scrollToRow(at: indexPath, at: .top, animated: true)
|
||||
scrollToBottom(isAnimated: true)
|
||||
}
|
||||
|
||||
// MARK: Blocking
|
||||
// MARK: - Blocking
|
||||
|
||||
@objc func unblock() {
|
||||
guard let thread = thread as? TSContactThread else { return }
|
||||
let publicKey = thread.contactSessionID()
|
||||
|
@ -75,26 +80,18 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}
|
||||
|
||||
func showBlockedModalIfNeeded() -> Bool {
|
||||
guard let thread = thread as? TSContactThread, thread.isBlocked() else { return false }
|
||||
guard viewModel.viewData.threadIsBlocked else { return false }
|
||||
|
||||
let blockedModal = BlockedModal(publicKey: thread.contactSessionID())
|
||||
let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id)
|
||||
blockedModal.modalPresentationStyle = .overFullScreen
|
||||
blockedModal.modalTransitionStyle = .crossDissolve
|
||||
present(blockedModal, animated: true, completion: nil)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Attachments
|
||||
func didPasteImageFromPasteboard(_ image: UIImage) {
|
||||
guard let imageData = image.jpegData(compressionQuality: 1.0) else { return }
|
||||
let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String)
|
||||
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
|
||||
|
||||
let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self)
|
||||
approvalVC.modalPresentationStyle = .fullScreen
|
||||
self.present(approvalVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - SendMediaNavDelegate
|
||||
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
@ -111,14 +108,16 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}
|
||||
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
|
||||
snInputView.text = newMessageText ?? ""
|
||||
snInputView.text = (newMessageText ?? "")
|
||||
}
|
||||
|
||||
// MARK: - AttachmentApprovalViewControllerDelegate
|
||||
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
|
||||
sendAttachments(attachments, with: messageText ?? "") { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
||||
scrollToBottom(isAnimated: false)
|
||||
resetMentions()
|
||||
self.snInputView.text = ""
|
||||
|
@ -142,6 +141,25 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
sendMediaNavController.sendMediaNavDelegate = self
|
||||
sendMediaNavController.modalPresentationStyle = .fullScreen
|
||||
present(sendMediaNavController, animated: true, completion: nil)
|
||||
// MARK: - ExpandingAttachmentsButtonDelegate
|
||||
|
||||
func handleGIFButtonTapped() {
|
||||
let gifVC = GifPickerViewController()
|
||||
gifVC.delegate = self
|
||||
|
||||
let navController = OWSNavigationController(rootViewController: gifVC)
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
present(navController, animated: true) { }
|
||||
}
|
||||
|
||||
func handleDocumentButtonTapped() {
|
||||
// UIDocumentPickerModeImport copies to a temp file within our container.
|
||||
// It uses more memory than "open" but lets us avoid working with security scoped URLs.
|
||||
let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import)
|
||||
documentPickerVC.delegate = self
|
||||
documentPickerVC.modalPresentationStyle = .fullScreen
|
||||
SNAppearance.switchToDocumentPickerAppearance()
|
||||
present(documentPickerVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func handleLibraryButtonTapped() {
|
||||
|
@ -155,14 +173,20 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}
|
||||
}
|
||||
|
||||
func handleGIFButtonTapped() {
|
||||
let gifVC = GifPickerViewController(thread: thread)
|
||||
gifVC.delegate = self
|
||||
let navController = OWSNavigationController(rootViewController: gifVC)
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
present(navController, animated: true) { }
|
||||
func handleCameraButtonTapped() {
|
||||
guard requestCameraPermissionIfNeeded() else { return }
|
||||
requestMicrophonePermissionIfNeeded { }
|
||||
if AVAudioSession.sharedInstance().recordPermission != .granted {
|
||||
SNLog("Proceeding without microphone access. Any recorded video will be silent.")
|
||||
}
|
||||
let sendMediaNavController = SendMediaNavigationController.showingCameraFirst()
|
||||
sendMediaNavController.sendMediaNavDelegate = self
|
||||
sendMediaNavController.modalPresentationStyle = .fullScreen
|
||||
present(sendMediaNavController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - GifPickerViewControllerDelegate
|
||||
|
||||
func gifPickerDidSelect(attachment: SignalAttachment) {
|
||||
showAttachmentApprovalDialog(for: [ attachment ])
|
||||
}
|
||||
|
@ -176,6 +200,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
SNAppearance.switchToDocumentPickerAppearance()
|
||||
present(documentPickerVC, animated: true, completion: nil)
|
||||
}
|
||||
// MARK: - UIDocumentPickerDelegate
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance
|
||||
|
@ -242,21 +267,24 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - InputViewDelegate
|
||||
|
||||
// MARK: Message Sending
|
||||
// MARK: --Message Sending
|
||||
func handleSendButtonTapped() {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
func sendMessage(hasPermissionToSendSeed: Bool = false) {
|
||||
guard !showBlockedModalIfNeeded() else { return }
|
||||
|
||||
|
||||
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
let thread = self.thread
|
||||
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
if text.contains(mnemonic) && !thread.isNoteToSelf() && !hasPermissionToSendSeed {
|
||||
|
||||
let isNoteToSelf: Bool = GRDBStorage.shared.read { db in viewModel.viewData.thread.isNoteToSelf(db) }
|
||||
.defaulting(to: false)
|
||||
if text.contains(mnemonic) && !isNoteToSelf && !hasPermissionToSendSeed {
|
||||
// Warn the user if they're about to send their seed to someone
|
||||
let modal = SendSeedModal()
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
|
@ -264,118 +292,166 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) }
|
||||
return present(modal, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
let sentTimestamp: UInt64 = NSDate.millisecondTimestamp()
|
||||
let message: VisibleMessage = VisibleMessage()
|
||||
message.sentTimestamp = sentTimestamp
|
||||
message.text = text
|
||||
message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model)
|
||||
|
||||
|
||||
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
|
||||
// use it to determine if the user is creating a new thread and update the 'isApproved'
|
||||
// flags appropriately
|
||||
let thread: SessionThread = viewModel.viewData.thread
|
||||
let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible
|
||||
let linkPreviewDraft = snInputView.linkPreviewInfo?.draft
|
||||
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
|
||||
let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000)))
|
||||
let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
|
||||
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
|
||||
|
||||
let promise: Promise<Void> = self.approveMessageRequestIfNeeded(
|
||||
for: self.thread,
|
||||
approveMessageRequestIfNeeded(
|
||||
for: thread,
|
||||
isNewThread: !oldThreadShouldBeVisible,
|
||||
timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting
|
||||
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
|
||||
)
|
||||
.map { [weak self] _ in
|
||||
self?.viewModel.appendUnsavedOutgoingTextMessage(tsMessage)
|
||||
|
||||
Storage.write(with: { transaction in
|
||||
message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview)
|
||||
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction)
|
||||
},
|
||||
completion: { [weak self] in
|
||||
// At this point the TSOutgoingMessage should have its link preview set, so we can scroll to the bottom knowing
|
||||
// the height of the new message cell
|
||||
self?.scrollToBottom(isAnimated: false)
|
||||
.done { [weak self] _ in
|
||||
GRDBStorage.shared.writeAsync(
|
||||
updates: { db in
|
||||
// Update the thread to be visible
|
||||
_ = try SessionThread
|
||||
.filter(id: thread.id)
|
||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
// Create the interaction
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
linkPreviewUrl: linkPreviewDraft?.urlString
|
||||
).inserted(db)
|
||||
|
||||
// If there is a LinkPreview add it now
|
||||
if let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft {
|
||||
var attachmentId: String?
|
||||
|
||||
// If the LinkPreview has image data then create an attachment first
|
||||
if let imageData: Data = linkPreviewDraft.jpegImageData {
|
||||
attachmentId = try LinkPreview.saveAttachmentIfPossible(
|
||||
db,
|
||||
imageData: imageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
}
|
||||
|
||||
try LinkPreview(
|
||||
url: linkPreviewDraft.urlString,
|
||||
title: linkPreviewDraft.title,
|
||||
attachmentId: attachmentId
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else { return }
|
||||
|
||||
// If there is a Quote the insert it now
|
||||
if let quoteModel: QuotedReplyModel = quoteModel {
|
||||
try Quote(
|
||||
interactionId: interactionId,
|
||||
authorId: quoteModel.authorId,
|
||||
timestampMs: quoteModel.timestampMs,
|
||||
body: quoteModel.body,
|
||||
attachmentId: quoteModel.attachment?.id
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
with: [],
|
||||
in: thread
|
||||
)
|
||||
},
|
||||
completion: { [weak self] _, _ in
|
||||
// At this point the Interaction should have its link preview set, so we can
|
||||
// scroll to the bottom knowing the height of the new message cell
|
||||
DispatchQueue.main.async {
|
||||
self?.scrollToBottom(isAnimated: false)
|
||||
self?.handleMessageSent()
|
||||
}
|
||||
)
|
||||
|
||||
Storage.shared.write { transaction in
|
||||
MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction)
|
||||
}
|
||||
|
||||
self?.handleMessageSent()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Show an error indicating that approving the thread failed
|
||||
promise.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
// Show an error indicating that approving the thread failed
|
||||
let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||
self?.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
promise.retainUntilComplete()
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) {
|
||||
guard !showBlockedModalIfNeeded() else { return }
|
||||
|
||||
|
||||
for attachment in attachments {
|
||||
if attachment.hasError {
|
||||
return showErrorAlert(for: attachment, onDismiss: onComplete)
|
||||
}
|
||||
}
|
||||
|
||||
let thread = self.thread
|
||||
let sentTimestamp: UInt64 = NSDate.millisecondTimestamp()
|
||||
let message = VisibleMessage()
|
||||
message.sentTimestamp = sentTimestamp
|
||||
message.text = replaceMentions(in: text)
|
||||
|
||||
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
|
||||
// use it to determine if the user is creating a new thread and update the 'isApproved'
|
||||
// flags appropriately
|
||||
let thread: SessionThread = viewModel.viewData.thread
|
||||
let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible
|
||||
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
|
||||
|
||||
let promise: Promise<Void> = self.approveMessageRequestIfNeeded(
|
||||
for: self.thread,
|
||||
let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000)))
|
||||
|
||||
approveMessageRequestIfNeeded(
|
||||
for: thread,
|
||||
isNewThread: !oldThreadShouldBeVisible,
|
||||
timestamp: (sentTimestamp - 1) // Set 1ms earlier as this is used for sorting
|
||||
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
|
||||
)
|
||||
.map { [weak self] _ in
|
||||
Storage.write(
|
||||
with: { transaction in
|
||||
tsMessage.save(with: transaction)
|
||||
// The new message cell is inserted at this point, but the TSOutgoingMessage doesn't have its attachment yet
|
||||
.done { [weak self] _ in
|
||||
GRDBStorage.shared.writeAsync(
|
||||
updates: { db in
|
||||
// Update the thread to be visible
|
||||
_ = try SessionThread
|
||||
.filter(id: thread.id)
|
||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
// Create the interaction
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs
|
||||
).inserted(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
with: attachments,
|
||||
in: thread
|
||||
)
|
||||
},
|
||||
completion: { [weak self] in
|
||||
Storage.write(with: { transaction in
|
||||
MessageSender.send(message, with: attachments, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
// At this point the TSOutgoingMessage should have its attachments set, so we can scroll to the bottom knowing
|
||||
// the height of the new message cell
|
||||
completion: { [weak self] _, _ in
|
||||
// At this point the Interaction should have its link preview set, so we can
|
||||
// scroll to the bottom knowing the height of the new message cell
|
||||
DispatchQueue.main.async {
|
||||
self?.scrollToBottom(isAnimated: false)
|
||||
})
|
||||
self?.handleMessageSent()
|
||||
|
||||
// Attachment successfully sent - dismiss the screen
|
||||
onComplete?()
|
||||
self?.handleMessageSent()
|
||||
|
||||
// Attachment successfully sent - dismiss the screen
|
||||
onComplete?()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Show an error indicating that approving the thread failed
|
||||
promise.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
// Show an error indicating that approving the thread failed
|
||||
let alert = UIAlertController(title: "Session", message: "An error occurred when trying to accept this message request", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
|
||||
self?.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
promise.retainUntilComplete()
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
func handleMessageSent() {
|
||||
|
@ -399,7 +475,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
|
||||
self.markAllAsRead()
|
||||
if Environment.shared.preferences.soundInForeground() {
|
||||
let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true)
|
||||
let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true)
|
||||
AudioServicesPlaySystemSound(soundID)
|
||||
}
|
||||
SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread)
|
||||
|
@ -467,6 +543,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
currentMentionStartIndex = nil
|
||||
mentions = []
|
||||
}
|
||||
|
||||
// MARK: --Attachments
|
||||
|
||||
func didPasteImageFromPasteboard(_ image: UIImage) {
|
||||
guard let imageData = image.jpegData(compressionQuality: 1.0) else { return }
|
||||
let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String)
|
||||
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
|
||||
|
||||
func replaceMentions(in text: String) -> String {
|
||||
var result = text
|
||||
|
@ -475,8 +558,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)")
|
||||
}
|
||||
return result
|
||||
let approvalVC = AttachmentApprovalViewController.wrappedInNavController(attachments: [ attachment ], approvalDelegate: self)
|
||||
approvalVC.modalPresentationStyle = .fullScreen
|
||||
self.present(approvalVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: --Mentions
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
|
||||
guard let currentMentionStartIndex = currentMentionStartIndex else { return }
|
||||
mentions.append(mention)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@property (nonatomic) BOOL showVerificationOnAppear;
|
||||
|
||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection;
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
|
||||
|
||||
@end
|
||||
|
||||
|
|
|
@ -11,11 +11,8 @@
|
|||
#import <SignalCoreKit/NSDate+OWS.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/OWSProfileManager.h>
|
||||
#import <SessionMessagingKit/OWSSounds.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIUtil.h>
|
||||
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
|
||||
#import <SessionMessagingKit/OWSDisappearingMessagesConfiguration.h>
|
||||
#import <SessionMessagingKit/OWSPrimaryStorage.h>
|
||||
#import <SessionMessagingKit/TSGroupThread.h>
|
||||
#import <SessionMessagingKit/TSOutgoingMessage.h>
|
||||
|
@ -30,11 +27,20 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
|
||||
|
||||
@property (nonatomic) TSThread *thread;
|
||||
@property (nonatomic) NSString *threadId;
|
||||
@property (nonatomic) NSString *threadName;
|
||||
@property (nonatomic) BOOL isNoteToSelf;
|
||||
@property (nonatomic) BOOL isClosedGroup;
|
||||
@property (nonatomic) BOOL isOpenGroup;
|
||||
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
|
||||
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection;
|
||||
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
|
||||
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
|
||||
|
||||
@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled;
|
||||
@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex;
|
||||
@property (nonatomic) BOOL isDisappearingMessagesEnabled;
|
||||
@property (nonatomic) NSInteger disappearingMessagesDurationIndex;
|
||||
|
||||
@property (nullable, nonatomic) MediaGallery *mediaGallery;
|
||||
@property (nonatomic, readonly) UIImageView *avatarView;
|
||||
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
|
||||
|
@ -96,15 +102,6 @@ CGFloat kIconViewLength = 24;
|
|||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (TSAccountManager *)tsAccountManager
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
|
||||
|
||||
return SSKEnvironment.shared.tsAccountManager;
|
||||
}
|
||||
|
||||
#pragma mark
|
||||
|
||||
- (void)observeNotifications
|
||||
|
@ -120,46 +117,19 @@ CGFloat kIconViewLength = 24;
|
|||
return [OWSPrimaryStorage sharedManager].dbReadWriteConnection;
|
||||
}
|
||||
|
||||
- (nullable NSString *)threadName
|
||||
{
|
||||
NSString *threadName = self.thread.name;
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
||||
return [SMKProfile displayNameWithId:thread.contactSessionID customFallback: @"Anonymous"];
|
||||
} else if (threadName.length == 0 && [self isGroupThread]) {
|
||||
threadName = [MessageStrings newGroupDefaultTitle];
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(nullable NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
self.threadId = threadId;
|
||||
self.threadName = threadName;
|
||||
self.isClosedGroup = isClosedGroup;
|
||||
self.isOpenGroup = isOpenGroup;
|
||||
self.isNoteToSelf = isNoteToSelf;
|
||||
|
||||
if (!isClosedGroup && !isOpenGroup) {
|
||||
self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
|
||||
}
|
||||
return threadName;
|
||||
}
|
||||
|
||||
- (BOOL)isGroupThread
|
||||
{
|
||||
return [self.thread isKindOfClass:[TSGroupThread class]];
|
||||
}
|
||||
|
||||
- (BOOL)isOpenGroup
|
||||
{
|
||||
if ([self isGroupThread]) {
|
||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
||||
return thread.isOpenGroup;
|
||||
else {
|
||||
self.threadName = (threadName ?: [MessageStrings newGroupDefaultTitle]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
-(BOOL)isClosedGroup
|
||||
{
|
||||
if (self.isGroupThread) {
|
||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
||||
return thread.groupModel.groupType == closedGroup;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection
|
||||
{
|
||||
OWSAssertDebug(thread);
|
||||
self.thread = thread;
|
||||
self.uiDatabaseConnection = uiDatabaseConnection;
|
||||
}
|
||||
|
||||
#pragma mark - ContactEditingDelegate
|
||||
|
@ -202,7 +172,7 @@ CGFloat kIconViewLength = 24;
|
|||
self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize];
|
||||
self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
self.displayNameLabel.textAlignment = NSTextAlignmentCenter;
|
||||
|
||||
|
||||
self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO];
|
||||
self.displayNameTextField.textAlignment = NSTextAlignmentCenter;
|
||||
self.displayNameTextField.accessibilityLabel = @"Edit name text field";
|
||||
|
@ -211,45 +181,42 @@ CGFloat kIconViewLength = 24;
|
|||
self.displayNameContainer = [UIView new];
|
||||
self.displayNameContainer.accessibilityLabel = @"Edit name text field";
|
||||
self.displayNameContainer.isAccessibilityElement = YES;
|
||||
|
||||
|
||||
[self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40];
|
||||
[self.displayNameContainer addSubview:self.displayNameLabel];
|
||||
[self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer];
|
||||
[self.displayNameContainer addSubview:self.displayNameTextField];
|
||||
[self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer];
|
||||
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
||||
|
||||
self.tableView.estimatedRowHeight = 45;
|
||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||
|
||||
_disappearingMessagesDurationLabel = [UILabel new];
|
||||
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
|
||||
|
||||
self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId];
|
||||
self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId];
|
||||
self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled;
|
||||
self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex;
|
||||
|
||||
self.disappearingMessagesConfiguration =
|
||||
[OWSDisappearingMessagesConfiguration fetchObjectWithUniqueId:self.thread.uniqueId];
|
||||
|
||||
if (!self.disappearingMessagesConfiguration) {
|
||||
self.disappearingMessagesConfiguration = [OWSDisappearingMessagesConfiguration defaultWith: self.thread.uniqueId];
|
||||
}
|
||||
|
||||
[self updateTableContents];
|
||||
|
||||
|
||||
NSString *title;
|
||||
if ([self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
title = NSLocalizedString(@"Settings", @"");
|
||||
} else {
|
||||
title = NSLocalizedString(@"Group Settings", @"");
|
||||
}
|
||||
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
|
||||
self.tableView.backgroundColor = UIColor.clearColor;
|
||||
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[self updateNavBarButtons];
|
||||
}
|
||||
}
|
||||
|
@ -259,8 +226,6 @@ CGFloat kIconViewLength = 24;
|
|||
OWSTableContents *contents = [OWSTableContents new];
|
||||
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
|
||||
|
||||
BOOL isNoteToSelf = self.thread.isNoteToSelf;
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
|
||||
OWSTableSection *section = [OWSTableSection new];
|
||||
|
@ -269,7 +234,7 @@ CGFloat kIconViewLength = 24;
|
|||
section.customHeaderHeight = @(UITableViewAutomaticDimension);
|
||||
|
||||
// Copy Session ID
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
return [weakSelf
|
||||
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
|
||||
|
@ -290,7 +255,7 @@ CGFloat kIconViewLength = 24;
|
|||
} actionBlock:^{
|
||||
[weakSelf showMediaGallery];
|
||||
}]];
|
||||
|
||||
|
||||
// Invite button
|
||||
if (self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
|
@ -315,7 +280,7 @@ CGFloat kIconViewLength = 24;
|
|||
} actionBlock:^{
|
||||
[weakSelf tappedConversationSearch];
|
||||
}]];
|
||||
|
||||
|
||||
// Disappearing messages
|
||||
if (![self isOpenGroup]) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
|
@ -327,7 +292,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
NSString *iconName
|
||||
= (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
= (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
|
||||
|
||||
UILabel *rowLabel = [UILabel new];
|
||||
|
@ -338,7 +303,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled;
|
||||
switchView.on = strongSelf.isDisappearingMessagesEnabled;
|
||||
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -351,11 +316,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
UILabel *subtitleLabel = [UILabel new];
|
||||
NSString *displayName;
|
||||
if (self.thread.isGroupThread) {
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
displayName = @"the group";
|
||||
} else {
|
||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
||||
displayName = [SMKProfile displayNameWithId:thread.contactSessionID customFallback:@"anonymous"];
|
||||
displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"];
|
||||
}
|
||||
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
|
||||
subtitleLabel.textColor = LKColors.text;
|
||||
|
@ -375,7 +339,7 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -405,7 +369,7 @@ CGFloat kIconViewLength = 24;
|
|||
slider.minimumValue = 0;
|
||||
slider.tintColor = LKColors.accent;
|
||||
slider.continuous = NO;
|
||||
slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex;
|
||||
slider.value = strongSelf.disappearingMessagesDurationIndex;
|
||||
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
[cell.contentView addSubview:slider];
|
||||
|
@ -413,7 +377,7 @@ CGFloat kIconViewLength = 24;
|
|||
[slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel];
|
||||
[slider autoPinTrailingToSuperviewMargin];
|
||||
[slider autoPinBottomToSuperviewMargin];
|
||||
|
||||
|
||||
cell.userInteractionEnabled = !strongSelf.hasLeftGroup;
|
||||
|
||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||
|
@ -428,11 +392,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// Closed group settings
|
||||
__block BOOL isUserMember = NO;
|
||||
if (self.isGroupThread) {
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey];
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
if (self.isGroupThread && self.isClosedGroup && isUserMember) {
|
||||
if (self.isClosedGroup && isUserMember) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
|
||||
|
@ -455,8 +418,8 @@ CGFloat kIconViewLength = 24;
|
|||
[weakSelf didTapLeaveGroup];
|
||||
}]];
|
||||
}
|
||||
|
||||
if (!isNoteToSelf) {
|
||||
|
||||
if (!self.isNoteToSelf) {
|
||||
// Notification sound
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
|
@ -483,8 +446,8 @@ CGFloat kIconViewLength = 24;
|
|||
[cell.contentView addSubview:contentRow];
|
||||
[contentRow autoPinEdgesToSuperviewMargins];
|
||||
|
||||
OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread];
|
||||
cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound];
|
||||
NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId];
|
||||
cell.detailTextLabel.text = [SMKSound displayNameFor:sound];
|
||||
|
||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||
OWSConversationSettingsViewController, @"notifications");
|
||||
|
@ -494,11 +457,11 @@ CGFloat kIconViewLength = 24;
|
|||
customRowHeight:UITableViewAutomaticDimension
|
||||
actionBlock:^{
|
||||
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
||||
vc.thread = weakSelf.thread;
|
||||
vc.threadId = weakSelf.threadId;
|
||||
[weakSelf.navigationController pushViewController:vc animated:YES];
|
||||
}]];
|
||||
|
||||
if (self.isGroupThread) {
|
||||
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
// Notification Settings
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
|
@ -517,7 +480,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions;
|
||||
switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId];
|
||||
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -547,7 +510,7 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||
}
|
||||
|
||||
|
||||
// Mute thread
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -560,7 +523,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *muteConversationSwitch = [UISwitch new];
|
||||
NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate;
|
||||
NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId];
|
||||
NSDate *now = [NSDate date];
|
||||
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
|
||||
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
|
||||
|
@ -570,9 +533,9 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} actionBlock:nil]];
|
||||
}
|
||||
|
||||
|
||||
// Block contact
|
||||
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf) { return [UITableViewCell new]; }
|
||||
|
@ -584,7 +547,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *blockConversationSwitch = [UISwitch new];
|
||||
blockConversationSwitch.on = strongSelf.thread.isBlocked;
|
||||
blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId];
|
||||
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = blockConversationSwitch;
|
||||
|
@ -671,36 +634,36 @@ CGFloat kIconViewLength = 24;
|
|||
[profilePictureView autoSetDimension:ALDimensionWidth toSize:size];
|
||||
[profilePictureView autoSetDimension:ALDimensionHeight toSize:size];
|
||||
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
|
||||
|
||||
|
||||
self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
||||
|
||||
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]];
|
||||
stackView.axis = UILayoutConstraintAxisVertical;
|
||||
stackView.spacing = LKValues.mediumSpacing;
|
||||
stackView.distribution = UIStackViewDistributionEqualCentering;
|
||||
stackView.distribution = UIStackViewDistributionEqualCentering;
|
||||
stackView.alignment = UIStackViewAlignmentCenter;
|
||||
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
|
||||
CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing;
|
||||
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
|
||||
[stackView setLayoutMarginsRelativeArrangement:YES];
|
||||
|
||||
if (!self.isGroupThread) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
SRCopyableLabel *subtitleView = [SRCopyableLabel new];
|
||||
subtitleView.textColor = LKColors.text;
|
||||
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
|
||||
subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
|
||||
subtitleView.numberOfLines = 2;
|
||||
subtitleView.text = ((TSContactThread *)self.thread).contactSessionID;
|
||||
subtitleView.text = self.threadId;
|
||||
subtitleView.textAlignment = NSTextAlignmentCenter;
|
||||
[stackView addArrangedSubview:subtitleView];
|
||||
}
|
||||
|
||||
[profilePictureView updateForThread:self.thread];
|
||||
|
||||
|
||||
[profilePictureView updateForThreadId:self.threadId];
|
||||
|
||||
return stackView;
|
||||
}
|
||||
|
||||
|
@ -739,48 +702,41 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
[super viewWillDisappear:animated];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) {
|
||||
// don't save defaults, else we'll unintentionally save the configuration and notify the contact.
|
||||
// Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex'
|
||||
// has changed as the 'durationIndex' value defaults to 1 hour when disabled)
|
||||
if (
|
||||
self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && (
|
||||
!self.originalIsDisappearingMessagesEnabled ||
|
||||
self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
||||
[self.disappearingMessagesConfiguration saveWithTransaction:transaction];
|
||||
OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc]
|
||||
initWithTimestamp:[NSDate ows_millisecondTimeStamp]
|
||||
thread:self.thread
|
||||
configuration:self.disappearingMessagesConfiguration
|
||||
createdByRemoteName:nil
|
||||
createdInExistingGroup:NO];
|
||||
[infoMessage saveWithTransaction:transaction];
|
||||
|
||||
SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new];
|
||||
BOOL isEnabled = self.disappearingMessagesConfiguration.isEnabled;
|
||||
expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0;
|
||||
[SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction];
|
||||
}];
|
||||
}
|
||||
|
||||
[SMKDisappearingMessagesConfiguration
|
||||
update:self.threadId
|
||||
isEnabled: self.isDisappearingMessagesEnabled
|
||||
durationIndex: self.disappearingMessagesDurationIndex
|
||||
];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)editGroup
|
||||
{
|
||||
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId];
|
||||
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId];
|
||||
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)didTapLeaveGroup
|
||||
{
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
NSString *message;
|
||||
if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) {
|
||||
if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) {
|
||||
message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
|
||||
} else {
|
||||
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
|
||||
}
|
||||
|
||||
|
||||
UIAlertController *alert =
|
||||
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title")
|
||||
message:message
|
||||
|
@ -801,9 +757,8 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (BOOL)hasLeftGroup
|
||||
{
|
||||
if (self.isGroupThread) {
|
||||
TSGroupThread *groupThread = (TSGroupThread *)self.thread;
|
||||
return !groupThread.isCurrentUserMemberInGroup;
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
return ![SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
|
||||
return NO;
|
||||
|
@ -834,13 +789,9 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
if (uiSwitch.isOn) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId];
|
||||
} else {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:nil transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -849,13 +800,12 @@ CGFloat kIconViewLength = 24;
|
|||
if (![sender isKindOfClass:[UISwitch class]]) {
|
||||
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
||||
}
|
||||
if (![self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
OWSFailDebug(@"unexpected thread type: %@", self.thread.class);
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
OWSFailDebug(@"unexpected group thread");
|
||||
}
|
||||
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
||||
TSContactThread *contactThread = (TSContactThread *)self.thread;
|
||||
|
||||
BOOL isCurrentlyBlocked = contactThread.isBlocked;
|
||||
BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId];
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
if (blockConversationSwitch.isOn) {
|
||||
|
@ -863,12 +813,12 @@ CGFloat kIconViewLength = 24;
|
|||
if (isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showBlockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showBlockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
|
||||
// If we successfully blocked then force a config sync
|
||||
if (isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
|
@ -882,12 +832,12 @@ CGFloat kIconViewLength = 24;
|
|||
if (!isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
|
||||
// If we successfully unblocked then force a config sync
|
||||
if (!isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
|
@ -900,7 +850,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)toggleDisappearingMessages:(BOOL)flag
|
||||
{
|
||||
self.disappearingMessagesConfiguration.isEnabled = flag;
|
||||
self.isDisappearingMessagesEnabled = flag;
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
@ -908,21 +858,23 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)durationSliderDidChange:(UISlider *)slider
|
||||
{
|
||||
// snap the slider to a valid value
|
||||
NSUInteger index = (NSUInteger)(slider.value + 0.5);
|
||||
NSInteger index = (NSInteger)(slider.value + 0.5);
|
||||
[slider setValue:index animated:YES];
|
||||
NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index];
|
||||
self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue];
|
||||
self.disappearingMessagesDurationIndex = index;
|
||||
|
||||
[self updateDisappearingMessagesDurationLabel];
|
||||
}
|
||||
|
||||
- (void)updateDisappearingMessagesDurationLabel
|
||||
{
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
NSString *keepForFormat = @"Disappear after %@";
|
||||
self.disappearingMessagesDurationLabel.text =
|
||||
[NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString];
|
||||
} else {
|
||||
self.disappearingMessagesDurationLabel.text = [NSString
|
||||
stringWithFormat:keepForFormat,
|
||||
[SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex]
|
||||
];
|
||||
}
|
||||
else {
|
||||
self.disappearingMessagesDurationLabel.text
|
||||
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
|
||||
}
|
||||
|
@ -933,30 +885,16 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)copySessionID
|
||||
{
|
||||
UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID;
|
||||
UIPasteboard.generalPasteboard.string = self.threadId;
|
||||
}
|
||||
|
||||
- (void)inviteUsersToOpenGroup
|
||||
{
|
||||
NSString *threadID = self.thread.uniqueId;
|
||||
SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID];
|
||||
NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey];
|
||||
NSString *threadId = self.threadId;
|
||||
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
|
||||
excluding:[NSSet new]
|
||||
completion:^(NSSet<NSString *> *selectedUsers) {
|
||||
for (NSString *user in selectedUsers) {
|
||||
SNVisibleMessage *message = [SNVisibleMessage new];
|
||||
message.sentTimestamp = [NSDate millisecondTimestamp];
|
||||
message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url];
|
||||
TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user];
|
||||
TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread];
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[tsMessage saveWithTransaction:transaction];
|
||||
}];
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[SNMessageSender send:message inThread:thread usingTransaction:transaction];
|
||||
}];
|
||||
}
|
||||
[SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId];
|
||||
}];
|
||||
[self.navigationController pushViewController:userSelectionVC animated:YES];
|
||||
}
|
||||
|
@ -965,8 +903,7 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
OWSLogDebug(@"");
|
||||
|
||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread
|
||||
options:MediaGalleryOptionSliderEnabled];
|
||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup];
|
||||
|
||||
self.mediaGallery = mediaGallery;
|
||||
|
||||
|
@ -983,9 +920,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
BOOL isEnabled = uiSwitch.isOn;
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction];
|
||||
}];
|
||||
|
||||
[SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled];
|
||||
}
|
||||
|
||||
- (void)hideEditNameUI
|
||||
|
@ -1001,9 +937,9 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName
|
||||
{
|
||||
_isEditingDisplayName = isEditingDisplayName;
|
||||
|
||||
|
||||
[self updateNavBarButtons];
|
||||
|
||||
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1;
|
||||
self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0;
|
||||
|
@ -1017,13 +953,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)saveName
|
||||
{
|
||||
if (![self.thread isKindOfClass:TSContactThread.class]) { return; }
|
||||
NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID;
|
||||
SMKProfile *profile = [SMKProfile fetchOrCreateWithId:sessionID];
|
||||
if (self.isClosedGroup || self.isOpenGroup) { return; }
|
||||
|
||||
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
profile.nickname = text.length > 0 ? text : nil;
|
||||
[SMKProfile saveProfile: profile];
|
||||
self.displayNameLabel.text = text.length > 0 ? text : profile.name;
|
||||
self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId];
|
||||
[self hideEditNameUI];
|
||||
}
|
||||
|
||||
|
@ -1054,14 +987,13 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
NSString *recipientId = notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
|
||||
if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] &&
|
||||
[((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) {
|
||||
[self updateTableContents];
|
||||
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
|
||||
DispatchMainThreadSafe(^{
|
||||
[self updateTableContents];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
/// Shown when the user taps a profile picture in the conversation settings.
|
||||
@objc(SNProfilePictureVC)
|
||||
final class ProfilePictureVC : BaseVC {
|
||||
final class ProfilePictureVC: BaseVC {
|
||||
private let image: UIImage
|
||||
private let snTitle: String
|
||||
|
||||
@objc init(image: UIImage, title: String) {
|
||||
self.image = image
|
||||
self.snTitle = title
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -52,50 +52,48 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
public func update(
|
||||
with name: String,
|
||||
notificationMode: SessionThread.NotificationMode,
|
||||
mutedUntilTimestamp: TimeInterval?,
|
||||
onlyNotifyForMentions: Bool,
|
||||
userCount: Int?
|
||||
) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.update(with: name, notificationMode: notificationMode, userCount: userCount)
|
||||
self?.update(with: name, mutedUntilTimestamp: mutedUntilTimestamp, onlyNotifyForMentions: onlyNotifyForMentions, userCount: userCount)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the subtitle
|
||||
let subtitle: NSAttributedString? = {
|
||||
switch notificationMode {
|
||||
case .none:
|
||||
return NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
)
|
||||
.appending(string: "Muted")
|
||||
|
||||
case .mentionsOnly:
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
let imageAttachment = NSTextAttachment()
|
||||
let color: UIColor = (isDarkMode ? .white : .black)
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
||||
imageAttachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -2,
|
||||
width: Values.smallFontSize,
|
||||
height: Values.smallFontSize
|
||||
)
|
||||
|
||||
return NSAttributedString(attachment: imageAttachment)
|
||||
.appending(string: " ")
|
||||
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||
|
||||
case .all:
|
||||
guard let userCount: Int = userCount else { return nil }
|
||||
|
||||
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||
return NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
)
|
||||
.appending(string: "Muted")
|
||||
}
|
||||
guard !onlyNotifyForMentions else {
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
let imageAttachment = NSTextAttachment()
|
||||
let color: UIColor = (isDarkMode ? .white : .black)
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
||||
imageAttachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -2,
|
||||
width: Values.smallFontSize,
|
||||
height: Values.smallFontSize
|
||||
)
|
||||
|
||||
return NSAttributedString(attachment: imageAttachment)
|
||||
.appending(string: " ")
|
||||
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||
}
|
||||
guard let userCount: Int = userCount else { return nil }
|
||||
|
||||
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||
}()
|
||||
|
||||
self.titleLabel.text = name
|
||||
|
|
|
@ -98,7 +98,8 @@ public class HomeViewModel {
|
|||
private let contactProfile: Profile?
|
||||
private let closedGroupAvatarProfiles: [GroupMemberInfo]?
|
||||
|
||||
public let notificationMode: SessionThread.NotificationMode
|
||||
public let mutedUntilTimestamp: TimeInterval?
|
||||
public let onlyNotifyForMentions: Bool
|
||||
public let isPinned: Bool
|
||||
|
||||
/// A flag indicating whether the contact is blocked (will be null for non-contact threads)
|
||||
|
@ -113,17 +114,14 @@ public class HomeViewModel {
|
|||
public let lastInteractionInfo: InteractionInfo?
|
||||
|
||||
public var displayName: String {
|
||||
switch variant {
|
||||
case .closedGroup: return (closedGroupName ?? "Unknown Group")
|
||||
case .openGroup: return (openGroupName ?? "Unknown Group")
|
||||
case .contact:
|
||||
guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() }
|
||||
guard let profile: Profile = profile else {
|
||||
return Profile.truncated(id: id, truncating: .middle)
|
||||
}
|
||||
|
||||
return (profile.nickname ?? profile.name)
|
||||
}
|
||||
return SessionThread.displayName(
|
||||
threadId: id,
|
||||
variant: variant,
|
||||
closedGroupName: closedGroupName,
|
||||
openGroupName: openGroupName,
|
||||
isNoteToSelf: isNoteToSelf,
|
||||
profile: contactProfile
|
||||
)
|
||||
}
|
||||
|
||||
public var profile: Profile? {
|
||||
|
@ -179,7 +177,8 @@ public class HomeViewModel {
|
|||
self.currentUserProfile = Profile(id: "", name: "")
|
||||
self.contactProfile = nil
|
||||
self.closedGroupAvatarProfiles = nil
|
||||
self.notificationMode = .none
|
||||
self.mutedUntilTimestamp = nil
|
||||
self.onlyNotifyForMentions = false
|
||||
self.isPinned = false
|
||||
self.contactIsBlocked = nil
|
||||
self.isNoteToSelf = false
|
||||
|
@ -255,7 +254,8 @@ public class HomeViewModel {
|
|||
openGroup[.name].forKey(ThreadInfo.openGroupNameKey),
|
||||
openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey),
|
||||
|
||||
thread[.notificationMode],
|
||||
thread[.mutedUntilTimestamp],
|
||||
thread[.onlyNotifyForMentions],
|
||||
thread[.isPinned],
|
||||
contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey),
|
||||
SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey),
|
||||
|
@ -293,7 +293,10 @@ public class HomeViewModel {
|
|||
)
|
||||
.joining(optional: SessionThread.openGroup.aliased(openGroup))
|
||||
.with(currentUserProfileExpression)
|
||||
.including(required: SessionThread.association(to: currentUserProfileExpression).forKey(ThreadInfo.currentUserProfileKey))
|
||||
.including(
|
||||
required: SessionThread.association(to: currentUserProfileExpression)
|
||||
.forKey(ThreadInfo.currentUserProfileKey)
|
||||
)
|
||||
.with(unreadInteractionExpression)
|
||||
.joining(
|
||||
optional: SessionThread
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
|
@ -0,0 +1,3 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
|
@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg
|
|||
|
||||
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
||||
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
|
||||
imageView.image = assetItem.asyncThumbnail { [weak imageView] image in
|
||||
assetItem.asyncThumbnail { [weak imageView] image in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let imageView = imageView else {
|
||||
|
|
|
@ -9,9 +9,10 @@ public enum PhotoGridItemType {
|
|||
case photo, animated, video
|
||||
}
|
||||
|
||||
public protocol PhotoGridItem: class {
|
||||
public protocol PhotoGridItem: AnyObject {
|
||||
var type: PhotoGridItemType { get }
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage?
|
||||
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void)
|
||||
}
|
||||
|
||||
public class PhotoGridViewCell: UICollectionViewCell {
|
||||
|
@ -119,28 +120,21 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
public func configure(item: PhotoGridItem) {
|
||||
self.item = item
|
||||
|
||||
self.image = item.asyncThumbnail { image in
|
||||
guard let currentItem = self.item else {
|
||||
return
|
||||
}
|
||||
|
||||
guard currentItem === item else {
|
||||
return
|
||||
}
|
||||
item.asyncThumbnail { [weak self] image in
|
||||
guard let currentItem = self?.item else { return }
|
||||
guard currentItem === item else { return }
|
||||
|
||||
if image == nil {
|
||||
Logger.debug("image == nil")
|
||||
}
|
||||
self.image = image
|
||||
|
||||
self?.image = image
|
||||
}
|
||||
|
||||
switch item.type {
|
||||
case .video:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||
case .animated:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||
case .photo:
|
||||
self.contentTypeBadgeImage = nil
|
||||
case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||
case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||
case .photo: self.contentTypeBadgeImage = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import Photos
|
|||
import PromiseKit
|
||||
import CoreServices
|
||||
|
||||
protocol PhotoLibraryDelegate: class {
|
||||
protocol PhotoLibraryDelegate: AnyObject {
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||
}
|
||||
|
||||
|
@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
|||
return .photo
|
||||
}
|
||||
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
||||
var syncImageResult: UIImage?
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
|
||||
var hasLoadedImage = false
|
||||
|
||||
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
||||
// already available.
|
||||
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
||||
DispatchMainThreadSafe({
|
||||
syncImageResult = image
|
||||
|
||||
// Once we've _successfully_ completed (e.g. invoked the completion with
|
||||
// a non-nil image), don't invoke the completion again with a nil argument.
|
||||
if !hasLoadedImage || image != nil {
|
||||
|
@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
|||
}
|
||||
})
|
||||
}
|
||||
return syncImageResult
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,29 +9,25 @@ public struct SessionApp {
|
|||
|
||||
// MARK: - View Convenience Methods
|
||||
|
||||
public static func presentConversation(for recipientId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
|
||||
public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
|
||||
let maybeThread: SessionThread? = GRDBStorage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: recipientId, variant: .contact)
|
||||
try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
|
||||
}
|
||||
|
||||
guard maybeThread != nil else { return }
|
||||
|
||||
self.presentConversation(for: recipientId, action: action, animated: animated)
|
||||
}
|
||||
|
||||
public static func presentConversation(for threadId: String, animated: Bool) {
|
||||
guard GRDBStorage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else {
|
||||
SNLog("Unable to find thread with id:\(threadId)")
|
||||
return
|
||||
}
|
||||
|
||||
self.presentConversation(for: threadId, animated: animated)
|
||||
self.presentConversation(
|
||||
for: threadId,
|
||||
action: action,
|
||||
focusInteractionId: nil,
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
|
||||
public static func presentConversation(
|
||||
for threadId: String,
|
||||
action: ConversationViewModel.Action = .none,
|
||||
focusInteractionId: Int64? = nil,
|
||||
action: ConversationViewModel.Action,
|
||||
focusInteractionId: Int64?,
|
||||
animated: Bool
|
||||
) {
|
||||
guard Thread.isMainThread else {
|
||||
|
|
|
@ -154,7 +154,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
}
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
||||
guard thread.notificationMode != .none else { return }
|
||||
guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||
|
||||
let isMessageRequest = thread.isMessageRequest(db)
|
||||
|
||||
|
@ -192,7 +192,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
|
||||
// Don't fire the notification if the current user isn't mentioned
|
||||
// and isOnlyNotifyingForMentions is on.
|
||||
if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) {
|
||||
if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -213,9 +213,21 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
notificationTitle = (isMessageRequest ? "Session" : senderName)
|
||||
|
||||
case .closedGroup, .openGroup:
|
||||
let groupName: String = thread.name(db)
|
||||
let groupName: String = SessionThread
|
||||
.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
)
|
||||
|
||||
notificationTitle = (isBackgroundPoll ? groupName:
|
||||
notificationTitle = (isBackgroundPoll ? groupName :
|
||||
String(
|
||||
format: NotificationStrings.incomingGroupMessageTitleFormat,
|
||||
senderName,
|
||||
|
@ -269,7 +281,22 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
|
||||
switch previewType {
|
||||
case .noNameNoPreview: notificationTitle = nil
|
||||
case .nameNoPreview, .namePreview: notificationTitle = thread.name(db)
|
||||
case .nameNoPreview, .namePreview:
|
||||
notificationTitle = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
isNoteToSelf: (thread.isNoteToSelf(db) == true),
|
||||
profile: try? Profile.fetchOne(db, id: thread.id)
|
||||
)
|
||||
|
||||
default: notificationTitle = nil
|
||||
}
|
||||
|
||||
|
|
|
@ -400,7 +400,7 @@ final class ConversationCell : UITableViewCell {
|
|||
private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString {
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
if (threadInfo.notificationMode == .none) {
|
||||
if Date().timeIntervalSince1970 < (threadInfo.mutedUntilTimestamp ?? 0) {
|
||||
result.append(NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
|
@ -409,7 +409,7 @@ final class ConversationCell : UITableViewCell {
|
|||
]
|
||||
))
|
||||
}
|
||||
else if threadInfo.notificationMode == .mentionsOnly {
|
||||
else if threadInfo.onlyNotifyForMentions {
|
||||
let imageAttachment = NSTextAttachment()
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant)
|
||||
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
|
||||
|
|
|
@ -1,45 +1,50 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class UserCell : UITableViewCell {
|
||||
var accessory = Accessory.none
|
||||
var publicKey = ""
|
||||
var isZombie = false
|
||||
|
||||
// MARK: Accessory
|
||||
final class UserCell: UITableViewCell {
|
||||
// MARK: - Accessory
|
||||
|
||||
enum Accessory {
|
||||
case none
|
||||
case lock
|
||||
case tick(isSelected: Bool)
|
||||
}
|
||||
|
||||
// MARK: Components
|
||||
private lazy var profilePictureView = ProfilePictureView()
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var accessoryImageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let result: UIImageView = UIImageView()
|
||||
result.contentMode = .scaleAspectFit
|
||||
let size: CGFloat = 24
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
result.set(.width, to: 24)
|
||||
result.set(.height, to: 24)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var separator: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.separator
|
||||
result.set(.height, to: Values.separatorThickness)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setUpViewHierarchy()
|
||||
|
@ -53,19 +58,30 @@ final class UserCell : UITableViewCell {
|
|||
private func setUpViewHierarchy() {
|
||||
// Background color
|
||||
backgroundColor = Colors.cellBackground
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear // Disabled for now
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
let profilePictureViewSize = Values.smallProfilePictureSize
|
||||
profilePictureView.set(.width, to: profilePictureViewSize)
|
||||
profilePictureView.set(.height, to: profilePictureViewSize)
|
||||
profilePictureView.size = profilePictureViewSize
|
||||
|
||||
// Main stack view
|
||||
let spacer = UIView.hStretchingSpacer()
|
||||
spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing).isActive = true
|
||||
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, UIView.hSpacer(Values.mediumSpacing), displayNameLabel, spacer, accessoryImageView ])
|
||||
let stackView = UIStackView(
|
||||
arrangedSubviews: [
|
||||
profilePictureView,
|
||||
UIView.hSpacer(Values.mediumSpacing),
|
||||
displayNameLabel,
|
||||
spacer,
|
||||
accessoryImageView
|
||||
]
|
||||
)
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
@ -73,16 +89,39 @@ final class UserCell : UITableViewCell {
|
|||
contentView.addSubview(stackView)
|
||||
stackView.pin(to: contentView)
|
||||
stackView.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
// Set up the separator
|
||||
contentView.addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
separator.pin(
|
||||
[
|
||||
UIView.HorizontalEdge.leading,
|
||||
UIView.VerticalEdge.bottom,
|
||||
UIView.HorizontalEdge.trailing
|
||||
],
|
||||
to: contentView
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
func update() {
|
||||
profilePictureView.update(for: publicKey)
|
||||
displayNameLabel.text = Profile.displayName(id: publicKey)
|
||||
func update(
|
||||
with publicKey: String,
|
||||
profile: Profile?,
|
||||
isZombie: Bool,
|
||||
accessory: Accessory
|
||||
) {
|
||||
profilePictureView.update(
|
||||
publicKey: publicKey,
|
||||
profile: profile,
|
||||
threadVariant: .contact
|
||||
)
|
||||
|
||||
displayNameLabel.text = Profile.displayName(
|
||||
for: .contact,
|
||||
id: publicKey,
|
||||
name: profile?.name,
|
||||
nickname: profile?.nickname
|
||||
)
|
||||
|
||||
switch accessory {
|
||||
case .none: accessoryImageView.isHidden = true
|
||||
|
@ -99,7 +138,7 @@ final class UserCell : UITableViewCell {
|
|||
accessoryImageView.tintColor = Colors.text
|
||||
}
|
||||
|
||||
let alpha: CGFloat = isZombie ? 0.5 : 1
|
||||
let alpha: CGFloat = (isZombie ? 0.5 : 1)
|
||||
[ profilePictureView, displayNameLabel, accessoryImageView ].forEach { $0.alpha = alpha }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,32 +4,35 @@ import UIKit
|
|||
import SessionMessagingKit
|
||||
|
||||
@objc(SNUserSelectionVC)
|
||||
final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private let navBarTitle: String
|
||||
private let usersToExclude: Set<String>
|
||||
private let completion: (Set<String>) -> Void
|
||||
private var selectedUsers: Set<String> = []
|
||||
|
||||
private lazy var users: [String] = {
|
||||
var result = Contact.fetchAllIds()
|
||||
result.removeAll { usersToExclude.contains($0) }
|
||||
return result
|
||||
private lazy var users: [Profile] = {
|
||||
return Profile
|
||||
.fetchAllContactProfiles()
|
||||
.filter { usersToExclude.contains($0.id) }
|
||||
}()
|
||||
|
||||
// MARK: Components
|
||||
// MARK: - Components
|
||||
|
||||
@objc private lazy var tableView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.alwaysBounceVertical = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc(initWithTitle:excluding:completion:)
|
||||
init(with title: String, excluding usersToExclude: Set<String>, completion: @escaping (Set<String>) -> Void) {
|
||||
self.navBarTitle = title
|
||||
|
@ -51,29 +54,36 @@ final class UserSelectionVC : BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
tableView.pin(to: view)
|
||||
}
|
||||
|
||||
// MARK: Data
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return users.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = users[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
let isSelected = selectedUsers.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: users[indexPath.row].id,
|
||||
profile: users[indexPath.row],
|
||||
isZombie: false,
|
||||
accessory: .tick(isSelected: selectedUsers.contains(users[indexPath.row].id))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let publicKey = users[indexPath.row]
|
||||
if !selectedUsers.contains(publicKey) { selectedUsers.insert(publicKey) } else { selectedUsers.remove(publicKey) }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return }
|
||||
let isSelected = selectedUsers.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
if !selectedUsers.contains(users[indexPath.row].id) {
|
||||
selectedUsers.insert(users[indexPath.row].id)
|
||||
}
|
||||
else {
|
||||
selectedUsers.remove(users[indexPath.row].id)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
@objc private func handleDoneButtonTapped() {
|
||||
|
|
|
@ -68,7 +68,8 @@ public final class BackgroundPoller : NSObject {
|
|||
.appending(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: try envelope.serializedData(),
|
||||
serverHash: message.info.hash
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -35,7 +35,9 @@ public enum Legacy {
|
|||
internal static let interactionCollection = "TSInteraction"
|
||||
internal static let attachmentsCollection = "TSAttachements" // Note: This is how it was previously spelt
|
||||
internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection"
|
||||
|
||||
internal static let receivedMessageTimestampsCollection = "ReceivedMessageTimestampsCollection"
|
||||
internal static let receivedMessageTimestampsKey = "receivedMessageTimestamps"
|
||||
|
||||
internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection"
|
||||
internal static let messageReceiveJobCollection = "MessageReceiveJobCollection"
|
||||
internal static let messageSendJobCollection = "MessageSendJobCollection"
|
||||
|
|
|
@ -49,11 +49,11 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.shouldBeVisible, .boolean).notNull()
|
||||
t.column(.isPinned, .boolean).notNull()
|
||||
t.column(.messageDraft, .text)
|
||||
t.column(.notificationMode, .integer)
|
||||
.notNull()
|
||||
.defaults(to: SessionThread.NotificationMode.all)
|
||||
t.column(.notificationSound, .integer)
|
||||
t.column(.mutedUntilTimestamp, .double)
|
||||
t.column(.onlyNotifyForMentions, .boolean)
|
||||
.notNull()
|
||||
.defaults(to: false)
|
||||
}
|
||||
|
||||
try db.create(table: DisappearingMessagesConfiguration.self) { t in
|
||||
|
@ -274,12 +274,14 @@ enum _001_InitialSetupMigration: Migration {
|
|||
}
|
||||
|
||||
try db.create(table: ControlMessageProcessRecord.self) { t in
|
||||
t.column(.threadId, .text).notNull()
|
||||
t.column(.sentTimestampMs, .integer).notNull()
|
||||
t.column(.serverHash, .text).notNull()
|
||||
t.column(.openGroupMessageServerId, .integer).notNull()
|
||||
t.column(.threadId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.variant, .integer).notNull()
|
||||
t.column(.timestampMs, .integer).notNull()
|
||||
t.column(.serverExpirationTimestamp, .double)
|
||||
|
||||
t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId])
|
||||
t.uniqueKey([.threadId, .variant, .timestampMs])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
var attachments: [String: Legacy.Attachment] = [:]
|
||||
var processedAttachmentIds: Set<String> = []
|
||||
var outgoingReadReceiptsTimestampsMs: [String: Set<Int64>] = [:]
|
||||
var receivedMessageTimestamps: Set<UInt64> = []
|
||||
|
||||
// Map the Legacy types for the NSKeyedUnarchiver
|
||||
NSKeyedUnarchiver.setClass(
|
||||
|
@ -192,6 +193,16 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
.union(timestampsMs)
|
||||
}
|
||||
|
||||
receivedMessageTimestamps = receivedMessageTimestamps.inserting(
|
||||
contentsOf: transaction
|
||||
.object(
|
||||
forKey: Legacy.receivedMessageTimestampsKey,
|
||||
inCollection: Legacy.receivedMessageTimestampsCollection
|
||||
)
|
||||
.asType([UInt64].self)
|
||||
.defaulting(to: [])
|
||||
.asSet()
|
||||
)
|
||||
}
|
||||
|
||||
// We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here
|
||||
|
@ -292,21 +303,16 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
let threadVariant: SessionThread.Variant
|
||||
let notificationMode: SessionThread.NotificationMode
|
||||
let onlyNotifyForMentions: Bool
|
||||
|
||||
switch thread {
|
||||
case let groupThread as TSGroupThread:
|
||||
threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup)
|
||||
notificationMode = (thread.isMuted ? .none :
|
||||
(groupThread.isOnlyNotifyingForMentions ?
|
||||
.mentionsOnly :
|
||||
.all
|
||||
)
|
||||
)
|
||||
onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions
|
||||
|
||||
default:
|
||||
threadVariant = .contact
|
||||
notificationMode = (thread.isMuted ? .none : .all)
|
||||
onlyNotifyForMentions = false
|
||||
}
|
||||
|
||||
try autoreleasepool {
|
||||
|
@ -320,8 +326,8 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
nil :
|
||||
thread.messageDraft
|
||||
),
|
||||
notificationMode: notificationMode,
|
||||
mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970
|
||||
mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970,
|
||||
onlyNotifyForMentions: onlyNotifyForMentions
|
||||
).insert(db)
|
||||
|
||||
// Disappearing Messages Configuration
|
||||
|
@ -564,7 +570,15 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
// Insert the data
|
||||
let interaction: Interaction = try Interaction(
|
||||
serverHash: serverHash,
|
||||
serverHash: {
|
||||
switch variant {
|
||||
// Don't store the 'serverHash' for these so sync messages
|
||||
// are seen as duplicates
|
||||
case .infoDisappearingMessagesUpdate: return nil
|
||||
|
||||
default: return serverHash
|
||||
}
|
||||
}(),
|
||||
threadId: threadId,
|
||||
authorId: authorId,
|
||||
variant: variant,
|
||||
|
@ -587,6 +601,17 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
openGroupWhisperTo: nil // TODO: This in SOGSV4
|
||||
).inserted(db)
|
||||
|
||||
// Insert a 'ControlMessageProcessRecord' if needed (for duplication prevention)
|
||||
try ControlMessageProcessRecord(
|
||||
threadId: threadId,
|
||||
variant: variant,
|
||||
timestampMs: Int64(legacyInteraction.timestamp)
|
||||
)?.insert(db)
|
||||
|
||||
// Remove timestamps we created records for (they will be protected by unique
|
||||
// constraints so don't need legacy process records)
|
||||
receivedMessageTimestamps.remove(legacyInteraction.timestamp)
|
||||
|
||||
guard let interactionId: Int64 = interaction.id else {
|
||||
// TODO: Is it possible the old database has duplicates which could hit this case?
|
||||
SNLog("[Migration Error] Failed to insert interaction")
|
||||
|
@ -777,6 +802,13 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
}
|
||||
|
||||
// Insert a 'ControlMessageProcessRecord' for any remaining 'receivedMessageTimestamp'
|
||||
// entries as "legacy"
|
||||
try ControlMessageProcessRecord.generateLegacyProcessRecords(
|
||||
db,
|
||||
receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) }
|
||||
)
|
||||
|
||||
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End")
|
||||
|
||||
// Clear out processed data (give the memory a change to be freed)
|
||||
|
@ -801,6 +833,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
interactions = [:]
|
||||
attachments = [:]
|
||||
receivedMessageTimestamps = []
|
||||
|
||||
// MARK: - Process Legacy Jobs
|
||||
|
||||
|
@ -1009,7 +1042,8 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
messages: [
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: legacyJob.data,
|
||||
serverHash: legacyJob.serverHash
|
||||
serverHash: legacyJob.serverHash,
|
||||
serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds)
|
||||
)
|
||||
],
|
||||
isBackgroundPoll: legacyJob.isBackgroundPoll
|
||||
|
|
|
@ -8,11 +8,16 @@ import SessionUtilitiesKit
|
|||
import AVFAudio
|
||||
import AVFoundation
|
||||
|
||||
public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "attachment" }
|
||||
public static let interactionAttachments = hasOne(InteractionAttachment.self)
|
||||
internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId])
|
||||
internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId])
|
||||
public static let interaction = hasOne(
|
||||
Interaction.self,
|
||||
through: interactionAttachments,
|
||||
using: InteractionAttachment.interaction
|
||||
)
|
||||
fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey)
|
||||
fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey)
|
||||
|
||||
|
@ -195,6 +200,28 @@ public struct Attachment: Codable, Identifiable, Equatable, FetchableRecord, Per
|
|||
self.digest = nil
|
||||
self.caption = nil
|
||||
}
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
||||
public func delete(_ db: Database) throws -> Bool {
|
||||
// Delete all associated files
|
||||
if FileManager.default.fileExists(atPath: thumbnailsDirPath) {
|
||||
try? FileManager.default.removeItem(atPath: thumbnailsDirPath)
|
||||
}
|
||||
|
||||
if
|
||||
let legacyThumbnailPath: String = legacyThumbnailPath,
|
||||
FileManager.default.fileExists(atPath: legacyThumbnailPath)
|
||||
{
|
||||
try? FileManager.default.removeItem(atPath: legacyThumbnailPath)
|
||||
}
|
||||
|
||||
if let originalFilePath: String = originalFilePath {
|
||||
try? FileManager.default.removeItem(atPath: originalFilePath)
|
||||
}
|
||||
|
||||
return try performDelete(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
@ -623,6 +650,19 @@ extension Attachment {
|
|||
return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails"
|
||||
}
|
||||
|
||||
var legacyThumbnailPath: String? {
|
||||
guard
|
||||
let originalFilePath: String = originalFilePath,
|
||||
(isImage || isVideo || isAnimated)
|
||||
else { return nil }
|
||||
|
||||
let fileUrl: URL = URL(fileURLWithPath: originalFilePath)
|
||||
let filename: String = fileUrl.lastPathComponent.filenameWithoutExtension
|
||||
let containingDir: String = fileUrl.deletingLastPathComponent().absoluteString
|
||||
|
||||
return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg"
|
||||
}
|
||||
|
||||
var originalImage: UIImage? {
|
||||
guard let originalFilePath: String = originalFilePath else { return nil }
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe
|
|||
public static var databaseTableName: String { "closedGroup" }
|
||||
internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
|
||||
private static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
|
||||
private static let keyPairs = hasMany(
|
||||
internal static let keyPairs = hasMany(
|
||||
ClosedGroupKeyPair.self,
|
||||
using: ClosedGroupKeyPair.closedGroupForeignKey
|
||||
)
|
||||
|
|
|
@ -113,28 +113,11 @@ public extension Contact {
|
|||
static func fetchOrCreate(_ db: Database, id: ID) -> Contact {
|
||||
return ((try? fetchOne(db, id: id)) ?? Contact(id: id))
|
||||
}
|
||||
|
||||
static func fetchAllIds() -> [String] {
|
||||
return GRDBStorage.shared
|
||||
.read { db in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let contacts: [Contact] = try Contact
|
||||
.filter(Contact.Columns.id != userPublicKey)
|
||||
.filter(Contact.Columns.didApproveMe == true)
|
||||
.fetchAll(db)
|
||||
let profiles: [Profile] = try Profile
|
||||
.fetchAll(db, ids: contacts.map { $0.id })
|
||||
|
||||
// Sort the contacts by their displayName value
|
||||
return profiles
|
||||
.sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() })
|
||||
.map { $0.id }
|
||||
}
|
||||
.defaulting(to: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// TODO: Remove this when possible
|
||||
@objc(SMKContact)
|
||||
public class SMKContact: NSObject {
|
||||
@objc let isApproved: Bool
|
||||
|
@ -158,5 +141,16 @@ public class SMKContact: NSObject {
|
|||
didApproveMe: existingContact?.didApproveMe ?? false
|
||||
)
|
||||
}
|
||||
|
||||
@objc(isBlockedFor:)
|
||||
public static func isBlocked(id: String) -> Bool {
|
||||
return GRDBStorage.shared
|
||||
.read { db in
|
||||
try Contact
|
||||
.select(.isBlocked)
|
||||
.asRequest(of: Bool.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,20 +4,196 @@ import Foundation
|
|||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// We can rely on the unique constraints within the `Interaction` table to prevent duplicate `VisibleMessage`
|
||||
/// values from being processed, but some control messages don’t have an associated interaction - this table provides
|
||||
/// a de-duping mechanism for those messages
|
||||
///
|
||||
/// **Note:** It’s entirely possible for there to be a false-positive with this record where multiple users sent the same
|
||||
/// type of control message at the same time - this is very unlikely to occur though since unique to the millisecond level
|
||||
public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "controlMessageProcessRecord" }
|
||||
|
||||
/// For notifications and migrated timestamps default to '15' days (which is the timeout for messages on the
|
||||
/// server at the time of writing)
|
||||
public static let defaultExpirationSeconds: TimeInterval = (15 * 24 * 60 * 60)
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case threadId
|
||||
case sentTimestampMs
|
||||
case serverHash
|
||||
case openGroupMessageServerId
|
||||
case timestampMs
|
||||
case variant
|
||||
case serverExpirationTimestamp
|
||||
}
|
||||
|
||||
public let threadId: String
|
||||
public let sentTimestampMs: Int64
|
||||
public let serverHash: String
|
||||
public let openGroupMessageServerId: Int64
|
||||
public enum Variant: Int, Codable, CaseIterable, DatabaseValueConvertible {
|
||||
/// **Note:** This value should only be used for entries created from the initial migration, when inserting
|
||||
/// new records it will check if there is an existing legacy record and if so it will attempt to create a "legacy"
|
||||
/// version of the new record to try and trip the unique constraint
|
||||
case legacyEntry = 0
|
||||
|
||||
case readReceipt = 1
|
||||
case typingIndicator = 2
|
||||
case closedGroupControlMessage = 3
|
||||
case dataExtractionNotification = 4
|
||||
case expirationTimerUpdate = 5
|
||||
case configurationMessage = 6
|
||||
case unsendRequest = 7
|
||||
case messageRequestResponse = 8
|
||||
}
|
||||
|
||||
/// The id for the thread the control message is associated to
|
||||
///
|
||||
/// **Note:** For user-specific control message (eg. `ConfigurationMessage`) this value will be the
|
||||
/// users public key
|
||||
public let threadId: String
|
||||
|
||||
/// The type of control message
|
||||
///
|
||||
/// **Note:** It would be nice to include this in the unique constraint to reduce the likelihood of false positives
|
||||
/// but this can result in control messages getting re-handled because the variant is unknown in the migration
|
||||
public let variant: Variant
|
||||
|
||||
/// The timestamp of the control message
|
||||
public let timestampMs: Int64
|
||||
|
||||
/// The timestamp for when this message will expire on the server (will be used for garbage collection)
|
||||
public let serverExpirationTimestamp: TimeInterval?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init?(
|
||||
threadId: String,
|
||||
message: Message,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
isRetry: Bool = false
|
||||
) {
|
||||
// All `VisibleMessage` values will have an associated `Interaction` so just let
|
||||
// the unique constraints on that table prevent duplicate messages
|
||||
if message is VisibleMessage { return nil }
|
||||
|
||||
// TODO: Need to allow duplicates for call messages
|
||||
|
||||
// If the message failed to process and we are retrying then there will already
|
||||
// be a `ControlMessageProcessRecord`, so return nil to prevent the insertion
|
||||
// causing a unique constraint violation
|
||||
if isRetry { return nil }
|
||||
|
||||
// Allow '.new' closed group config message duplicates in this case to avoid
|
||||
// the following situation:
|
||||
// • The app performed a background poll or received a push notification
|
||||
// • This method was invoked and the received message timestamps table was updated
|
||||
// • Processing wasn't finished
|
||||
// • The user doesn't see the new closed group
|
||||
if case .new = (message as? ClosedGroupControlMessage)?.kind { return nil }
|
||||
|
||||
// For all other cases we want to prevent duplicate handling of the message (this
|
||||
// can happen in a number of situations, primarily with sync messages though hence
|
||||
// why we don't include the 'serverHash' as part of this record
|
||||
self.threadId = threadId
|
||||
self.variant = {
|
||||
switch message {
|
||||
case is ReadReceipt: return .readReceipt
|
||||
case is TypingIndicator: return .typingIndicator
|
||||
case is ClosedGroupControlMessage: return .closedGroupControlMessage
|
||||
case is DataExtractionNotification: return .dataExtractionNotification
|
||||
case is ExpirationTimerUpdate: return .expirationTimerUpdate
|
||||
case is ConfigurationMessage: return .configurationMessage
|
||||
case is UnsendRequest: return .unsendRequest
|
||||
case is MessageRequestResponse: return .messageRequestResponse
|
||||
default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type")
|
||||
}
|
||||
}()
|
||||
self.timestampMs = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
}
|
||||
|
||||
public func insert(_ db: Database) throws {
|
||||
// If this isn't a legacy entry then check if there is a single entry and, if so,
|
||||
// try to create a "legacy entry" version of this record to see if a unique constraint
|
||||
// conflict occurs
|
||||
if !threadId.isEmpty && variant != .legacyEntry {
|
||||
let legacyEntry: ControlMessageProcessRecord? = try? ControlMessageProcessRecord
|
||||
.filter(Columns.threadId == "")
|
||||
.filter(Columns.variant == Variant.legacyEntry)
|
||||
.fetchOne(db)
|
||||
|
||||
if legacyEntry != nil {
|
||||
try ControlMessageProcessRecord(
|
||||
threadId: "",
|
||||
variant: .legacyEntry,
|
||||
timestampMs: timestampMs,
|
||||
serverExpirationTimestamp: (legacyEntry?.serverExpirationTimestamp ?? 0)
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
try performInsert(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Extensions
|
||||
|
||||
internal extension ControlMessageProcessRecord {
|
||||
init?(
|
||||
threadId: String,
|
||||
variant: Interaction.Variant,
|
||||
timestampMs: Int64
|
||||
) {
|
||||
switch variant {
|
||||
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted,
|
||||
.infoClosedGroupCreated:
|
||||
return nil
|
||||
|
||||
case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft:
|
||||
self.variant = .closedGroupControlMessage
|
||||
|
||||
case .infoDisappearingMessagesUpdate:
|
||||
self.variant = .expirationTimerUpdate
|
||||
|
||||
case .infoScreenshotNotification, .infoMediaSavedNotification:
|
||||
self.variant = .dataExtractionNotification
|
||||
|
||||
case .infoMessageRequestAccepted:
|
||||
self.variant = .messageRequestResponse
|
||||
}
|
||||
|
||||
self.threadId = threadId
|
||||
self.timestampMs = timestampMs
|
||||
self.serverExpirationTimestamp = (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds)
|
||||
}
|
||||
|
||||
/// This method should only be used for records created during migration from the legacy
|
||||
/// `receivedMessageTimestamps` collection which doesn't include thread or variant info
|
||||
///
|
||||
/// In order to get around this but maintain the unique constraints on everything we create entries for each timestamp
|
||||
/// for every thread and every timestamp (while this is wildly inefficient there is a garbage collection process which will
|
||||
/// clean out these excessive entries after `defaultExpirationSeconds`)
|
||||
static func generateLegacyProcessRecords(_ db: Database, receivedMessageTimestamps: [Int64]) throws {
|
||||
let defaultExpirationTimestamp: TimeInterval = (
|
||||
Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds
|
||||
)
|
||||
|
||||
try receivedMessageTimestamps.forEach { timestampMs in
|
||||
try ControlMessageProcessRecord(
|
||||
threadId: "",
|
||||
variant: .legacyEntry,
|
||||
timestampMs: timestampMs,
|
||||
serverExpirationTimestamp: defaultExpirationTimestamp
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// This method should only be called from either the `generateLegacyProcessRecords` method above or
|
||||
/// within the 'insert' method to maintain the unique constraint
|
||||
fileprivate init(
|
||||
threadId: String,
|
||||
variant: Variant,
|
||||
timestampMs: Int64,
|
||||
serverExpirationTimestamp: TimeInterval
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.variant = variant
|
||||
self.timestampMs = timestampMs
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,12 +85,6 @@ public extension DisappearingMessagesConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
var durationIndex: Int {
|
||||
return DisappearingMessagesConfiguration.validDurationsSeconds
|
||||
.firstIndex(of: durationSeconds)
|
||||
.defaulting(to: 0)
|
||||
}
|
||||
|
||||
var durationString: String {
|
||||
NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false)
|
||||
}
|
||||
|
@ -133,7 +127,98 @@ extension DisappearingMessagesConfiguration {
|
|||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// TODO: Remove this when possible
|
||||
|
||||
@objc(SMKDisappearingMessagesConfiguration)
|
||||
public class SMKDisappearingMessagesConfiguration: NSObject {
|
||||
@objc public static var maxDurationSeconds: UInt = UInt(DisappearingMessagesConfiguration.maxDurationSeconds)
|
||||
|
||||
@objc public static var validDurationsSeconds: [UInt] = DisappearingMessagesConfiguration
|
||||
.validDurationsSeconds
|
||||
.map { UInt($0) }
|
||||
|
||||
@objc(isEnabledFor:)
|
||||
public static func isEnabled(for threadId: String) -> Bool {
|
||||
return GRDBStorage.shared
|
||||
.read { db in
|
||||
try DisappearingMessagesConfiguration
|
||||
.select(.isEnabled)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: Bool.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
@objc(durationIndexFor:)
|
||||
public static func durationIndex(for threadId: String) -> Int {
|
||||
let durationSeconds: TimeInterval = GRDBStorage.shared
|
||||
.read { db in
|
||||
try DisappearingMessagesConfiguration
|
||||
.select(.durationSeconds)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: DisappearingMessagesConfiguration.defaultDuration)
|
||||
|
||||
return DisappearingMessagesConfiguration.validDurationsSeconds
|
||||
.firstIndex(of: durationSeconds)
|
||||
.defaulting(to: 0)
|
||||
}
|
||||
|
||||
@objc(durationStringFor:)
|
||||
public static func durationString(for index: Int) -> String {
|
||||
let durationSeconds: TimeInterval = (
|
||||
index >= 0 && index < DisappearingMessagesConfiguration.validDurationsSeconds.count ?
|
||||
DisappearingMessagesConfiguration.validDurationsSeconds[index] :
|
||||
DisappearingMessagesConfiguration.validDurationsSeconds[0]
|
||||
)
|
||||
|
||||
return NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false)
|
||||
}
|
||||
|
||||
@objc(update:isEnabled:durationIndex:)
|
||||
public static func update(_ threadId: String, isEnabled: Bool, durationIndex: Int) {
|
||||
let durationSeconds: TimeInterval = (
|
||||
durationIndex >= 0 && durationIndex < DisappearingMessagesConfiguration.validDurationsSeconds.count ?
|
||||
DisappearingMessagesConfiguration.validDurationsSeconds[durationIndex] :
|
||||
DisappearingMessagesConfiguration.validDurationsSeconds[0]
|
||||
)
|
||||
|
||||
GRDBStorage.shared.write { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
||||
return
|
||||
}
|
||||
|
||||
let config: DisappearingMessagesConfiguration = (try DisappearingMessagesConfiguration
|
||||
.fetchOne(db, id: threadId)?
|
||||
.with(
|
||||
isEnabled: isEnabled,
|
||||
durationSeconds: durationSeconds
|
||||
)
|
||||
.saved(db))
|
||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .infoDisappearingMessagesUpdate,
|
||||
body: config.messageInfoString(with: nil),
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: ExpirationTimerUpdate(
|
||||
syncTarget: nil,
|
||||
duration: UInt32(floor(durationSeconds))
|
||||
),
|
||||
interactionId: interaction.id,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,3 +56,39 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec
|
|||
self.role = role
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when possible
|
||||
|
||||
@objc(SMKGroupMember)
|
||||
public class SMKGroupMember: NSObject {
|
||||
@objc(isCurrentUserMemberOf:)
|
||||
public static func isCurrentUserMember(of groupId: String) -> Bool {
|
||||
return GRDBStorage.shared.read { db in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let numEntries: Int = try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == groupId)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
.fetchCount(db)
|
||||
|
||||
return (numEntries > 0)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
@objc(isCurrentUserAdminOf:)
|
||||
public static func isCurrentUserAdmin(of groupId: String) -> Bool {
|
||||
return GRDBStorage.shared.read { db in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let numEntries: Int = try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == groupId)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
|
||||
.fetchCount(db)
|
||||
|
||||
return (numEntries > 0)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
)
|
||||
public static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
|
||||
public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey)
|
||||
internal static let interactionAttachments = hasMany(
|
||||
public static let interactionAttachments = hasMany(
|
||||
InteractionAttachment.self,
|
||||
using: InteractionAttachment.interactionForeignKey
|
||||
)
|
||||
|
|
|
@ -121,5 +121,45 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco
|
|||
public extension OpenGroup {
|
||||
static func idFor(room: String, server: String) -> String {
|
||||
return "\(server.lowercased()).\(room)"
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// TODO: Remove this when possible
|
||||
|
||||
@objc(SMKOpenGroup)
|
||||
public class SMKOpenGroup: NSObject {
|
||||
@objc(inviteUsers:toOpenGroupFor:)
|
||||
public static func invite(selectedUsers: Set<String>, threadId: String) {
|
||||
GRDBStorage.shared.write { db in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return }
|
||||
|
||||
let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)"
|
||||
|
||||
try selectedUsers.forEach { userId in
|
||||
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact)
|
||||
|
||||
try LinkPreview(
|
||||
url: urlString,
|
||||
variant: .openGroupInvitation,
|
||||
title: openGroup.name
|
||||
)
|
||||
.save(db)
|
||||
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: userId,
|
||||
variant: .standardOutgoing,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
linkPreviewUrl: urlString
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ public struct Profile: Codable, Identifiable, Equatable, FetchableRecord, Persis
|
|||
internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId])
|
||||
internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id])
|
||||
internal static let groupMemberForeignKey = ForeignKey([Columns.id], to: [GroupMember.Columns.profileId])
|
||||
internal static let contact = hasOne(Contact.self, using: contactForeignKey)
|
||||
public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey)
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
|
@ -204,9 +205,9 @@ public extension Profile {
|
|||
public extension Profile {
|
||||
func with(
|
||||
name: String? = nil,
|
||||
nickname: Updatable<String> = .existing,
|
||||
profilePictureUrl: Updatable<String> = .existing,
|
||||
profilePictureFileName: Updatable<String> = .existing,
|
||||
nickname: Updatable<String?> = .existing,
|
||||
profilePictureUrl: Updatable<String?> = .existing,
|
||||
profilePictureFileName: Updatable<String?> = .existing,
|
||||
profileEncryptionKey: Updatable<OWSAES256Key> = .existing
|
||||
) -> Profile {
|
||||
return Profile(
|
||||
|
@ -223,6 +224,22 @@ public extension Profile {
|
|||
// MARK: - GRDB Interactions
|
||||
|
||||
public extension Profile {
|
||||
static func fetchAllContactProfiles(excludeCurrentUser: Bool = true) -> [Profile] {
|
||||
return GRDBStorage.shared
|
||||
.read { db in
|
||||
// Sort the contacts by their displayName value
|
||||
return try Profile
|
||||
.filter(Profile.Columns.id != (excludeCurrentUser ? "" : getUserHexEncodedPublicKey(db)))
|
||||
.joining(
|
||||
required: Profile.contact
|
||||
.filter(Contact.Columns.didApproveMe == true)
|
||||
)
|
||||
.fetchAll(db)
|
||||
.sorted(by: { lhs, rhs -> Bool in lhs.displayName() < rhs.displayName() })
|
||||
}
|
||||
.defaulting(to: [])
|
||||
}
|
||||
|
||||
static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String {
|
||||
return displayName(
|
||||
db,
|
||||
|
@ -402,8 +419,8 @@ public extension Profile {
|
|||
) -> String {
|
||||
if let nickname: String = nickname { return nickname }
|
||||
|
||||
guard let name: String = name else {
|
||||
return (customFallback ?? Profile.truncated(id: id, truncating: .start))
|
||||
guard let name: String = name, name != id else {
|
||||
return (customFallback ?? Profile.truncated(id: id, truncating: .middle))
|
||||
}
|
||||
|
||||
switch context {
|
||||
|
@ -418,6 +435,9 @@ public extension Profile {
|
|||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when possible
|
||||
|
||||
@objc(SMKProfile)
|
||||
public class SMKProfile: NSObject {
|
||||
var id: String
|
||||
|
@ -480,4 +500,19 @@ public class SMKProfile: NSObject {
|
|||
@objc public static var localProfileKey: OWSAES256Key? {
|
||||
Profile.fetchOrCreateCurrentUser().profileEncryptionKey
|
||||
}
|
||||
|
||||
@objc(displayNameAfterSavingNickname:forProfileId:)
|
||||
public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String {
|
||||
return GRDBStorage.shared.write { db in
|
||||
let profile: Profile = Profile.fetchOrCreate(id: profileId)
|
||||
let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil)
|
||||
|
||||
_ = try profile
|
||||
.with(nickname: .update(targetNickname))
|
||||
.saved(db)
|
||||
|
||||
return (targetNickname ?? profile.name)
|
||||
}
|
||||
.defaulting(to: "")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,9 +23,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
case shouldBeVisible
|
||||
case isPinned
|
||||
case messageDraft
|
||||
case notificationMode
|
||||
case notificationSound
|
||||
case mutedUntilTimestamp
|
||||
case onlyNotifyForMentions
|
||||
}
|
||||
|
||||
public enum Variant: Int, Codable, DatabaseValueConvertible {
|
||||
|
@ -33,12 +33,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
case closedGroup
|
||||
case openGroup
|
||||
}
|
||||
|
||||
public enum NotificationMode: Int, Codable, DatabaseValueConvertible {
|
||||
case none
|
||||
case all
|
||||
case mentionsOnly // Only applicable to group threads
|
||||
}
|
||||
|
||||
/// Unique identifier for a thread (formerly known as uniqueId)
|
||||
///
|
||||
|
@ -63,9 +57,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
/// The value the user started entering into the input field before they left the conversation screen
|
||||
public let messageDraft: String?
|
||||
|
||||
/// The notification mode this thread is set to
|
||||
public let notificationMode: NotificationMode
|
||||
|
||||
/// The sound which should be used when receiving a notification for this thread
|
||||
///
|
||||
/// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound`
|
||||
|
@ -74,6 +65,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
/// Timestamp (seconds since epoch) for when this thread should stop being muted
|
||||
public let mutedUntilTimestamp: TimeInterval?
|
||||
|
||||
/// A flag indicating whether the thread should only notify for mentions
|
||||
public let onlyNotifyForMentions: Bool
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
public var contact: QueryInterfaceRequest<Contact> {
|
||||
|
@ -105,9 +99,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
shouldBeVisible: Bool = false,
|
||||
isPinned: Bool = false,
|
||||
messageDraft: String? = nil,
|
||||
notificationMode: NotificationMode = .all,
|
||||
notificationSound: Preferences.Sound? = nil,
|
||||
mutedUntilTimestamp: TimeInterval? = nil
|
||||
mutedUntilTimestamp: TimeInterval? = nil,
|
||||
onlyNotifyForMentions: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.variant = variant
|
||||
|
@ -115,9 +109,9 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
self.shouldBeVisible = shouldBeVisible
|
||||
self.isPinned = isPinned
|
||||
self.messageDraft = messageDraft
|
||||
self.notificationMode = notificationMode
|
||||
self.notificationSound = notificationSound
|
||||
self.mutedUntilTimestamp = mutedUntilTimestamp
|
||||
self.onlyNotifyForMentions = onlyNotifyForMentions
|
||||
}
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
@ -146,9 +140,9 @@ public extension SessionThread {
|
|||
shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible),
|
||||
isPinned: (isPinned ?? self.isPinned),
|
||||
messageDraft: messageDraft,
|
||||
notificationMode: notificationMode,
|
||||
notificationSound: notificationSound,
|
||||
mutedUntilTimestamp: mutedUntilTimestamp
|
||||
mutedUntilTimestamp: mutedUntilTimestamp,
|
||||
onlyNotifyForMentions: onlyNotifyForMentions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -303,3 +297,66 @@ public extension SessionThread {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when possible
|
||||
|
||||
@objc(SMKThread)
|
||||
public class SMKThread: NSObject {
|
||||
@objc(isThreadMuted:)
|
||||
public static func isThreadMuted(_ threadId: String) -> Bool {
|
||||
return GRDBStorage.shared.read { db in
|
||||
let mutedUntilTimestamp: TimeInterval? = try SessionThread
|
||||
.select(SessionThread.Columns.mutedUntilTimestamp)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: TimeInterval?.self)
|
||||
.fetchOne(db)
|
||||
|
||||
return (mutedUntilTimestamp != nil)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
@objc(isOnlyNotifyingForMentions:)
|
||||
public static func isOnlyNotifyingForMentions(_ threadId: String) -> Bool {
|
||||
return GRDBStorage.shared.read { db in
|
||||
return try SessionThread
|
||||
.select(SessionThread.Columns.onlyNotifyForMentions == true)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: Bool.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
@objc(setIsOnlyNotifyingForMentions:to:)
|
||||
public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) {
|
||||
GRDBStorage.shared.write { db in
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, SessionThread.Columns.onlyNotifyForMentions.set(to: isEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
@objc(mutedUntilDateFor:)
|
||||
public static func mutedUntilDateFor(_ threadId: String) -> Date? {
|
||||
return GRDBStorage.shared.read { db in
|
||||
return try SessionThread
|
||||
.select(SessionThread.Columns.mutedUntilTimestamp)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.map { Date(timeIntervalSince1970: $0) }
|
||||
}
|
||||
|
||||
@objc(updateWithMutedUntilDateTo:forThreadId:)
|
||||
public static func updateWithMutedUntilDate(to date: Date?, threadId: String) {
|
||||
GRDBStorage.shared.write { db in
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
let (message, proto) = try MessageReceiver.parse(
|
||||
db,
|
||||
data: messageInfo.data,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
isRetry: isRetry
|
||||
)
|
||||
message.serverHash = messageInfo.serverHash
|
||||
|
@ -115,13 +116,16 @@ extension MessageReceiveJob {
|
|||
public struct MessageInfo: Codable {
|
||||
public let data: Data
|
||||
public let serverHash: String?
|
||||
public let serverExpirationTimestamp: TimeInterval?
|
||||
|
||||
public init(
|
||||
data: Data,
|
||||
serverHash: String?
|
||||
serverHash: String?,
|
||||
serverExpirationTimestamp: TimeInterval?
|
||||
) {
|
||||
self.data = data
|
||||
self.serverHash = serverHash
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ public final class DataExtractionNotification : ControlMessage {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
internal init(kind: Kind) {
|
||||
public init(kind: Kind) {
|
||||
super.init()
|
||||
|
||||
self.kind = kind
|
||||
|
|
|
@ -177,9 +177,9 @@ extension MessageReceiver {
|
|||
else { return }
|
||||
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: sender, // TODO: Confirm this
|
||||
authorId: sender,
|
||||
variant: {
|
||||
switch messageKind {
|
||||
case .screenshot: return .infoScreenshotNotification
|
||||
|
@ -209,21 +209,16 @@ extension MessageReceiver {
|
|||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id))
|
||||
.with(
|
||||
// If there is no duration then we should disable the expiration timer
|
||||
isEnabled: (message.duration != nil),
|
||||
isEnabled: ((message.duration ?? 0) > 0),
|
||||
durationSeconds: (
|
||||
message.duration.map { TimeInterval($0) } ??
|
||||
DisappearingMessagesConfiguration.defaultDuration
|
||||
)
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Add an info message for the user
|
||||
//
|
||||
// Note: If it's a duplicate message (which the 'ExpirationTimerUpdate' frequently can be)
|
||||
// then the write transaction will fail meaning the above config update won't be applied
|
||||
// so we don't need to worry about order-of-execution)
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash,
|
||||
serverHash: nil, // Intentionally null so sync messages are seen as duplicates
|
||||
threadId: thread.id,
|
||||
authorId: sender,
|
||||
variant: .infoDisappearingMessagesUpdate,
|
||||
|
@ -235,6 +230,10 @@ extension MessageReceiver {
|
|||
),
|
||||
timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set
|
||||
).inserted(db)
|
||||
|
||||
// Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate
|
||||
// then the interaction unique constraint will prevent the code from getting here)
|
||||
try config.save(db)
|
||||
}
|
||||
|
||||
// MARK: - Configuration Messages
|
||||
|
@ -822,10 +821,12 @@ extension MessageReceiver {
|
|||
let groupAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupPublicKey)) ?? false)
|
||||
let thread: SessionThread = try SessionThread
|
||||
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup)
|
||||
.with(shouldBeVisible: true)
|
||||
.saved(db)
|
||||
let closedGroup: ClosedGroup = try ClosedGroup(
|
||||
threadId: groupPublicKey,
|
||||
name: name,
|
||||
formationTimestamp: Date().timeIntervalSince1970
|
||||
formationTimestamp: (TimeInterval(messageSentTimestamp) / 1000)
|
||||
).saved(db)
|
||||
|
||||
// Clear the zombie list if the group wasn't active (ie. had no keys)
|
||||
|
@ -845,7 +846,7 @@ extension MessageReceiver {
|
|||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .infoClosedGroupCreated,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
timestampMs: Int64(messageSentTimestamp)
|
||||
).inserted(db)
|
||||
}
|
||||
|
||||
|
@ -862,8 +863,6 @@ extension MessageReceiver {
|
|||
)
|
||||
.save(db)
|
||||
|
||||
// Add the group to the user's set of public keys to poll for
|
||||
Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction)
|
||||
// Store the key pair
|
||||
try ClosedGroupKeyPair(
|
||||
threadId: groupPublicKey,
|
||||
|
@ -952,7 +951,7 @@ extension MessageReceiver {
|
|||
guard name != closedGroup.name else { return }
|
||||
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: sender,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
|
@ -1017,7 +1016,7 @@ extension MessageReceiver {
|
|||
guard members != Set(groupMembers.map { $0.profileId }) else { return }
|
||||
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: sender,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
|
@ -1071,6 +1070,7 @@ extension MessageReceiver {
|
|||
_ = try closedGroup
|
||||
.keyPairs
|
||||
.deleteAll(db)
|
||||
|
||||
let _ = PushNotificationAPI.performOperation(
|
||||
.unsubscribe,
|
||||
for: id,
|
||||
|
@ -1090,7 +1090,7 @@ extension MessageReceiver {
|
|||
guard members != Set(groupMembers.map { $0.profileId }) else { return }
|
||||
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: sender,
|
||||
variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated),
|
||||
|
@ -1140,6 +1140,7 @@ extension MessageReceiver {
|
|||
_ = try closedGroup
|
||||
.keyPairs
|
||||
.deleteAll(db)
|
||||
|
||||
let _ = PushNotificationAPI.performOperation(
|
||||
.unsubscribe,
|
||||
for: id,
|
||||
|
@ -1162,7 +1163,7 @@ extension MessageReceiver {
|
|||
guard updatedMemberIds != Set(members.map { $0.profileId }) else { return }
|
||||
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: sender,
|
||||
variant: .infoClosedGroupUpdated,
|
||||
|
@ -1272,7 +1273,7 @@ extension MessageReceiver {
|
|||
// Get the existing thead and notify the user
|
||||
if let thread: SessionThread = try? SessionThread.fetchOne(db, id: senderId) {
|
||||
_ = try Interaction(
|
||||
serverHash: message.serverHash, // TODO: Test this? (make sure it won't break anything?)
|
||||
serverHash: message.serverHash,
|
||||
threadId: thread.id,
|
||||
authorId: senderId,
|
||||
variant: .infoMessageRequestAccepted,
|
||||
|
|
|
@ -11,6 +11,7 @@ public enum MessageReceiver {
|
|||
public static func parse(
|
||||
_ db: Database,
|
||||
data: Data,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
openGroupId: String? = nil,
|
||||
openGroupMessageServerId: UInt64? = nil,
|
||||
isRetry: Bool = false
|
||||
|
@ -159,47 +160,22 @@ public enum MessageReceiver {
|
|||
throw MessageReceiverError.invalidMessage
|
||||
}
|
||||
|
||||
// If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp
|
||||
// will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround
|
||||
// for this issue.
|
||||
|
||||
switch (isRetry, message, (message as? ClosedGroupControlMessage)?.kind) {
|
||||
// Allow duplicates in this case to avoid the following situation:
|
||||
// • The app performed a background poll or received a push notification
|
||||
// • This method was invoked and the received message timestamps table was updated
|
||||
// • Processing wasn't finished
|
||||
// • The user doesn't see the new closed group
|
||||
case (_, _, .new): break
|
||||
// Prevent ControlMessages from being handled multiple times if not supported
|
||||
try ControlMessageProcessRecord(
|
||||
threadId: {
|
||||
if let groupPublicKey: String = groupPublicKey { return groupPublicKey }
|
||||
if let openGroupId: String = openGroupId { return openGroupId }
|
||||
|
||||
// All `VisibleMessage` values will have an associated `Interaction` so just let
|
||||
// the unique constraints on that table prevent duplicate messages
|
||||
case is (Bool, VisibleMessage, ClosedGroupControlMessage.Kind?): break
|
||||
|
||||
// If the message failed to process and we are retrying then there will already
|
||||
// be a `ControlMessageProcessRecord`, so just allow this through
|
||||
case (true, _, _): break
|
||||
|
||||
default:
|
||||
do {
|
||||
try ControlMessageProcessRecord(
|
||||
threadId: {
|
||||
if let openGroupId: String = openGroupId {
|
||||
return openGroupId
|
||||
}
|
||||
|
||||
if let groupPublicKey: String = groupPublicKey {
|
||||
return groupPublicKey
|
||||
}
|
||||
|
||||
return sender
|
||||
}(),
|
||||
sentTimestampMs: Int64(envelope.timestamp),
|
||||
serverHash: (message.serverHash ?? ""),
|
||||
openGroupMessageServerId: (openGroupMessageServerId.map { Int64($0) } ?? 0)
|
||||
).insert(db)
|
||||
switch message {
|
||||
case let message as VisibleMessage: return (message.syncTarget ?? sender)
|
||||
case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender)
|
||||
default: return sender
|
||||
}
|
||||
catch { throw MessageReceiverError.duplicateMessage }
|
||||
}
|
||||
}(),
|
||||
message: message,
|
||||
serverExpirationTimestamp: serverExpirationTimestamp,
|
||||
isRetry: false
|
||||
)?.insert(db)
|
||||
|
||||
// Return
|
||||
return (message, proto)
|
||||
|
|
|
@ -484,31 +484,23 @@ public final class MessageSender : NSObject {
|
|||
)
|
||||
}
|
||||
|
||||
// Prevent the same ExpirationTimerUpdate to be handled twice
|
||||
if message is ControlMessage {
|
||||
try? ControlMessageProcessRecord(
|
||||
threadId: {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .openGroupV2(let room, let server):
|
||||
return OpenGroup.idFor(room: room, server: server)
|
||||
|
||||
// FIXME: Remove support for V1 SOGS
|
||||
case .openGroup: return getUserHexEncodedPublicKey(db)
|
||||
}
|
||||
}(),
|
||||
sentTimestampMs: {
|
||||
if message.openGroupServerMessageId != nil {
|
||||
return (serverTimestampMs.map { Int64($0) } ?? 0)
|
||||
}
|
||||
|
||||
return (message.sentTimestamp.map { Int64($0) } ?? 0)
|
||||
}(),
|
||||
serverHash: (message.serverHash ?? ""),
|
||||
openGroupMessageServerId: (message.openGroupServerMessageId.map { Int64($0) } ?? 0)
|
||||
).insert(db)
|
||||
}
|
||||
// Prevent ControlMessages from being handled multiple times if not supported
|
||||
try? ControlMessageProcessRecord(
|
||||
threadId: {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .openGroupV2(let room, let server):
|
||||
return OpenGroup.idFor(room: room, server: server)
|
||||
|
||||
case .openGroup(_, _): return "" // TODO: Remove this after merge
|
||||
}
|
||||
}(),
|
||||
message: message,
|
||||
serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds),
|
||||
isRetry: false
|
||||
)?.insert(db)
|
||||
|
||||
// Sync the message if:
|
||||
// • it's a visible message or an expiration timer update
|
||||
// • the destination was a contact
|
||||
|
|
|
@ -6,41 +6,57 @@ import PromiseKit
|
|||
import SessionSnodeKit
|
||||
|
||||
@objc(LKClosedGroupPoller)
|
||||
public final class ClosedGroupPoller : NSObject {
|
||||
private var isPolling: [String:Bool] = [:]
|
||||
private var timers: [String:Timer] = [:]
|
||||
private let internalQueue: DispatchQueue = DispatchQueue(label:"isPollingQueue")
|
||||
public final class ClosedGroupPoller: NSObject {
|
||||
private var isPolling: [String: Bool] = [:]
|
||||
private var timers: [String: Timer] = [:]
|
||||
private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue")
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let minPollInterval: Double = 2
|
||||
private static let maxPollInterval: Double = 30
|
||||
|
||||
// MARK: Error
|
||||
private enum Error : LocalizedError {
|
||||
// MARK: - Error
|
||||
|
||||
private enum Error: LocalizedError {
|
||||
case insufficientSnodes
|
||||
case pollingCanceled
|
||||
|
||||
internal var errorDescription: String? {
|
||||
switch self {
|
||||
case .insufficientSnodes: return "No snodes left to poll."
|
||||
case .pollingCanceled: return "Polling canceled."
|
||||
case .insufficientSnodes: return "No snodes left to poll."
|
||||
case .pollingCanceled: return "Polling canceled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
public static let shared = ClosedGroupPoller()
|
||||
|
||||
private override init() { }
|
||||
|
||||
// MARK: Public API
|
||||
// MARK: - Public API
|
||||
|
||||
@objc public func start() {
|
||||
#if DEBUG
|
||||
assert(Thread.current.isMainThread) // Timers don't do well on background queues
|
||||
#endif
|
||||
let storage = SNMessagingKitConfiguration.shared.storage
|
||||
let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys()
|
||||
allGroupPublicKeys.forEach { startPolling(for: $0) }
|
||||
|
||||
// Fetch all closed groups (excluding any which have no key pairs as the user is
|
||||
// no longer a member of those
|
||||
GRDBStorage.shared
|
||||
.read { db in
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.joining(required: ClosedGroup.keyPairs)
|
||||
.asRequest(of: String.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.forEach { [weak self] groupPublicKey in
|
||||
self?.startPolling(for: groupPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func startPolling(for groupPublicKey: String) {
|
||||
|
@ -53,9 +69,17 @@ public final class ClosedGroupPoller : NSObject {
|
|||
}
|
||||
|
||||
@objc public func stop() {
|
||||
let storage = SNMessagingKitConfiguration.shared.storage
|
||||
let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys()
|
||||
allGroupPublicKeys.forEach { stopPolling(for: $0) }
|
||||
GRDBStorage.shared
|
||||
.read { db in
|
||||
try ClosedGroup
|
||||
.select(.threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
.forEach { [weak self] groupPublicKey in
|
||||
self?.stopPolling(for: groupPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func stopPolling(for groupPublicKey: String) {
|
||||
|
@ -63,29 +87,48 @@ public final class ClosedGroupPoller : NSObject {
|
|||
timers[groupPublicKey]?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: Private API
|
||||
// MARK: - Private API
|
||||
|
||||
private func setUpPolling(for groupPublicKey: String) {
|
||||
Threading.pollerQueue.async {
|
||||
self.poll(groupPublicKey).done(on: Threading.pollerQueue) { [weak self] _ in
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}.catch(on: Threading.pollerQueue) { [weak self] error in
|
||||
// The error is logged in poll(_:)
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
self.poll(groupPublicKey)
|
||||
.done(on: Threading.pollerQueue) { [weak self] _ in
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
.catch(on: Threading.pollerQueue) { [weak self] error in
|
||||
// The error is logged in poll(_:)
|
||||
self?.pollRecursively(groupPublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pollRecursively(_ groupPublicKey: String) {
|
||||
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
|
||||
guard isPolling(for: groupPublicKey),
|
||||
let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID)) else { return }
|
||||
guard
|
||||
isPolling(for: groupPublicKey),
|
||||
let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) })
|
||||
else { return }
|
||||
|
||||
// Get the received date of the last message in the thread. If we don't have any messages yet, pick some
|
||||
// reasonable fake time interval to use instead.
|
||||
let lastMessageDate =
|
||||
(thread.numberOfInteractions() > 0) ? thread.lastInteraction.receivedAtDate() : Date().addingTimeInterval(-5 * 60)
|
||||
let timeSinceLastMessage = Date().timeIntervalSince(lastMessageDate)
|
||||
let minPollInterval = ClosedGroupPoller.minPollInterval
|
||||
let limit: Double = 12 * 60 * 60
|
||||
// reasonable fake time interval to use instead
|
||||
|
||||
let lastMessageDate: Date = GRDBStorage.shared
|
||||
.read { db in
|
||||
try thread
|
||||
.interactions
|
||||
.select(.receivedAtTimestampMs)
|
||||
.order(Interaction.Columns.timestampMs.desc)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.map { receivedAtTimestampMs -> Date? in
|
||||
guard receivedAtTimestampMs > 0 else { return nil }
|
||||
|
||||
return Date(timeIntervalSince1970: (TimeInterval(receivedAtTimestampMs) / 1000))
|
||||
}
|
||||
.defaulting(to: Date().addingTimeInterval(-5 * 60))
|
||||
let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate)
|
||||
let minPollInterval: Double = ClosedGroupPoller.minPollInterval
|
||||
let limit: Double = (12 * 60 * 60)
|
||||
let a = (ClosedGroupPoller.maxPollInterval - minPollInterval) / limit
|
||||
let nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval
|
||||
SNLog("Next poll interval for closed group with public key: \(groupPublicKey) is \(nextPollInterval) s.")
|
||||
|
@ -133,7 +176,8 @@ public final class ClosedGroupPoller : NSObject {
|
|||
jobDetailMessages.append(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: try envelope.serializedData(),
|
||||
serverHash: message.info.hash
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -124,7 +124,8 @@ public final class Poller : NSObject {
|
|||
.appending(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: try envelope.serializedData(),
|
||||
serverHash: message.info.hash
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -7,9 +7,9 @@ import SignalUtilitiesKit
|
|||
import SessionMessagingKit
|
||||
|
||||
public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
||||
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
||||
guard thread.notificationMode != .none else { return }
|
||||
guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||
|
||||
let isMessageRequest = thread.isMessageRequest(db)
|
||||
|
||||
|
@ -48,7 +48,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol {
|
|||
var notificationTitle = senderName
|
||||
|
||||
if thread.variant == .closedGroup || thread.variant == .openGroup {
|
||||
if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) {
|
||||
if thread.onlyNotifyForMentions && !interaction.isUserMentioned(db) {
|
||||
// Ignore PNs if the group is set to only notify for mentions
|
||||
return
|
||||
}
|
||||
|
|
|
@ -45,7 +45,11 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension
|
|||
// is added to notification center
|
||||
GRDBStorage.shared.write { db in
|
||||
do {
|
||||
let (message, proto) = try MessageReceiver.parse(db, data: envelopeAsData)
|
||||
let (message, proto) = try MessageReceiver.parse(
|
||||
db,
|
||||
data: envelopeAsData,
|
||||
serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds)
|
||||
)
|
||||
switch message {
|
||||
case let visibleMessage as VisibleMessage:
|
||||
let interactionId: Int64 = try MessageReceiver.handleVisibleMessage(db, message: visibleMessage, associatedWithProto: proto, openGroupId: nil, isBackgroundPoll: false)
|
||||
|
|
|
@ -12,6 +12,15 @@ public extension Set {
|
|||
return updatedSet
|
||||
}
|
||||
|
||||
func inserting(contentsOf value: Set<Element>?) -> Set<Element> {
|
||||
guard let value: Set<Element> = value else { return self }
|
||||
|
||||
var updatedSet: Set<Element> = self
|
||||
value.forEach { updatedSet.insert($0) }
|
||||
|
||||
return updatedSet
|
||||
}
|
||||
|
||||
func removing(_ value: Element?) -> Set<Element> {
|
||||
guard let value: Element = value else { return self }
|
||||
|
||||
|
|
|
@ -330,7 +330,7 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD
|
|||
}
|
||||
}
|
||||
|
||||
@objc public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
|
||||
public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
|
||||
UIView.animate(withDuration: 0.1) { [weak self] in
|
||||
self?.playVideoButton.alpha = 1.0
|
||||
}
|
||||
|
|
|
@ -5,27 +5,21 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
@objc
|
||||
public protocol OWSVideoPlayerDelegate: class {
|
||||
public protocol OWSVideoPlayerDelegate: AnyObject {
|
||||
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class OWSVideoPlayer: NSObject {
|
||||
public class OWSVideoPlayer {
|
||||
|
||||
@objc
|
||||
public let avPlayer: AVPlayer
|
||||
let audioActivity: AudioActivity
|
||||
|
||||
@objc
|
||||
public weak var delegate: OWSVideoPlayerDelegate?
|
||||
|
||||
@objc public init(url: URL) {
|
||||
self.avPlayer = AVPlayer(url: url)
|
||||
self.audioActivity = AudioActivity(audioDescription: "[OWSVideoPlayer] url:\(url)", behavior: .playback)
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(playerItemDidPlayToCompletion(_:)),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
|
|
|
@ -10,15 +10,15 @@ import SessionMessagingKit
|
|||
/// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue
|
||||
///
|
||||
/// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed
|
||||
@objc public static func showBlockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) {
|
||||
@objc public static func showBlockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
|
||||
guard thread.contactSessionID() != userPublicKey else {
|
||||
guard threadId != userPublicKey else {
|
||||
completionBlock?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let displayName: String = Profile.displayName(id: thread.contactSessionID())
|
||||
let displayName: String = Profile.displayName(id: threadId)
|
||||
let actionSheet: UIAlertController = UIAlertController(
|
||||
title: String(
|
||||
format: "BLOCK_LIST_BLOCK_USER_TITLE_FORMAT".localized(),
|
||||
|
@ -35,7 +35,7 @@ import SessionMessagingKit
|
|||
GRDBStorage.shared.writeAsync(
|
||||
updates: { db in
|
||||
try? Contact
|
||||
.fetchOrCreate(db, id: thread.contactSessionID())
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(isBlocked: true)
|
||||
.save(db)
|
||||
},
|
||||
|
@ -68,8 +68,8 @@ import SessionMessagingKit
|
|||
/// This method shows an alert to unblock a contact in a ContactThread and will update the `isBlocked` flag of the contact if the user decides to continue
|
||||
///
|
||||
/// **Note:** Make sure to force a config sync in the `completionBlock` if the blocked state was successfully changed
|
||||
@objc public static func showUnblockThreadActionSheet(_ thread: TSContactThread, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) {
|
||||
let displayName: String = Profile.displayName(id: thread.contactSessionID())
|
||||
@objc public static func showUnblockThreadActionSheet(_ threadId: String, from viewController: UIViewController, completionBlock: ((Bool) -> ())? = nil) {
|
||||
let displayName: String = Profile.displayName(id: threadId)
|
||||
let actionSheet: UIAlertController = UIAlertController(
|
||||
title: String(
|
||||
format: "BLOCK_LIST_UNBLOCK_TITLE_FORMAT".localized(),
|
||||
|
@ -86,7 +86,7 @@ import SessionMessagingKit
|
|||
GRDBStorage.shared.writeAsync(
|
||||
updates: { db in
|
||||
try? Contact
|
||||
.fetchOrCreate(db, id: thread.contactSessionID())
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(isBlocked: false)
|
||||
.save(db)
|
||||
},
|
||||
|
|
|
@ -41,8 +41,14 @@ public class ModalActivityIndicatorViewController: OWSViewController {
|
|||
}
|
||||
|
||||
@objc
|
||||
public class func present(fromViewController: UIViewController, canCancel: Bool = false, message: String? = nil,
|
||||
backgroundBlock : @escaping (ModalActivityIndicatorViewController) -> Void) {
|
||||
public class func present(
|
||||
fromViewController: UIViewController?,
|
||||
canCancel: Bool = false,
|
||||
message: String? = nil,
|
||||
backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void
|
||||
) {
|
||||
guard let fromViewController: UIViewController = fromViewController else { return }
|
||||
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let view = ModalActivityIndicatorViewController(canCancel: canCancel, message: message)
|
||||
|
|
|
@ -5,15 +5,15 @@
|
|||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
public protocol GalleryRailItemProvider: class {
|
||||
public protocol GalleryRailItemProvider: AnyObject {
|
||||
var railItems: [GalleryRailItem] { get }
|
||||
}
|
||||
|
||||
public protocol GalleryRailItem: class {
|
||||
public protocol GalleryRailItem: AnyObject {
|
||||
func buildRailItemView() -> UIView
|
||||
}
|
||||
|
||||
protocol GalleryRailCellViewDelegate: class {
|
||||
protocol GalleryRailCellViewDelegate: AnyObject {
|
||||
func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView)
|
||||
}
|
||||
|
||||
|
|
|
@ -132,26 +132,35 @@ public extension UIView {
|
|||
|
||||
@objc
|
||||
public extension UIViewController {
|
||||
public func presentAlert(_ alert: UIAlertController) {
|
||||
func presentAlert(_ alert: UIAlertController) {
|
||||
self.presentAlert(alert, animated: true)
|
||||
}
|
||||
|
||||
public func presentAlert(_ alert: UIAlertController, animated: Bool) {
|
||||
self.present(alert,
|
||||
animated: animated,
|
||||
completion: {
|
||||
alert.applyAccessibilityIdentifiers()
|
||||
})
|
||||
func presentAlert(_ alert: UIAlertController, animated: Bool) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.presentAlert(alert, animated: animated)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.present(alert, animated: animated) {
|
||||
alert.applyAccessibilityIdentifiers()
|
||||
}
|
||||
}
|
||||
|
||||
public func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) {
|
||||
self.present(alert,
|
||||
animated: true,
|
||||
completion: {
|
||||
alert.applyAccessibilityIdentifiers()
|
||||
|
||||
completion()
|
||||
})
|
||||
func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.presentAlert(alert, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.present(alert, animated: true) {
|
||||
alert.applyAccessibilityIdentifiers()
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue