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:
Morgan Pretty 2022-05-08 22:01:39 +10:00
parent f4ca219030
commit 0db74ce1e3
50 changed files with 2089 additions and 1170 deletions

View File

@ -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 */,

View File

@ -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)
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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

View File

@ -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];
});
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation

View File

@ -0,0 +1,3 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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 }
}
}

View File

@ -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() {

View File

@ -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)
)
)

View File

@ -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"

View File

@ -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])
}
}
}

View File

@ -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

View File

@ -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 }

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -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 dont have an associated interaction - this table provides
/// a de-duping mechanism for those messages
///
/// **Note:** Its 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
}
}

View File

@ -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
)
}
}
}

View File

@ -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)
}
}

View File

@ -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
)

View File

@ -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
)
}
}
}
}

View File

@ -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: "")
}
}

View File

@ -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))
}
}
}

View File

@ -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
}
}

View File

@ -27,7 +27,7 @@ public final class DataExtractionNotification : ControlMessage {
// MARK: - Initialization
internal init(kind: Kind) {
public init(kind: Kind) {
super.init()
self.kind = kind

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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)
)
)

View File

@ -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)
)
)

View File

@ -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
}

View File

@ -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)

View File

@ -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 }

View File

@ -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
}

View File

@ -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,

View File

@ -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)
},

View File

@ -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)

View File

@ -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)
}

View File

@ -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()
}
}
}