Merge pull request #280 from loki-project/closed-group-editing

Closed Group Editing
pull/289/head
Niels Andriesse 5 years ago committed by GitHub
commit 4eb734a0c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -568,6 +568,10 @@
BFF3FB9730634F37D25903F4 /* Pods_Signal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D17BB5C25D615AB49813100C /* Pods_Signal.framework */; };
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; };
C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; };
C31D1DD325216101005D4DA8 /* UIView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DD225216101005D4DA8 /* UIView+Utilities.swift */; };
C31D1DDD25217014005D4DA8 /* UserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DDC25217014005D4DA8 /* UserCell.swift */; };
C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; };
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; };
C329FEEC24F7277900B1C64C /* LightModeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C329FEEB24F7277900B1C64C /* LightModeSheet.swift */; };
C329FEEF24F7743F00B1C64C /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C329FEED24F7742E00B1C64C /* UIViewController+Utilities.swift */; };
C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; };
@ -596,6 +600,7 @@
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; };
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */; };
C3DFFAC823E970080058DAF8 /* OpenGroupSuggestionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DFFAC723E970080058DAF8 /* OpenGroupSuggestionSheet.swift */; };
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */; };
CC875800737563D6891B741D /* Pods_SignalTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */; };
D202868116DBE0E7009068E9 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; };
D202868216DBE0F4009068E9 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; };
@ -1362,6 +1367,10 @@
B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; };
C31A6C59247F214E001123EF /* UIView+Glow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Glow.swift"; sourceTree = "<group>"; };
C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = "<group>"; };
C31D1DD225216101005D4DA8 /* UIView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Utilities.swift"; sourceTree = "<group>"; };
C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = "<group>"; };
C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionVC.swift; sourceTree = "<group>"; };
C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = "<group>"; };
C329FEEB24F7277900B1C64C /* LightModeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightModeSheet.swift; sourceTree = "<group>"; };
C329FEED24F7742E00B1C64C /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Utilities.swift"; sourceTree = "<group>"; };
C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = "<group>"; };
@ -1394,6 +1403,7 @@
C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = "<group>"; };
C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = "<group>"; };
C3DFFAC723E970080058DAF8 /* OpenGroupSuggestionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSuggestionSheet.swift; sourceTree = "<group>"; };
C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditClosedGroupVC.swift; sourceTree = "<group>"; };
D17BB5C25D615AB49813100C /* Pods_Signal.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Signal.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; };
D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
@ -2654,6 +2664,7 @@
B8544E3023D16CA500299F14 /* DeviceUtilities.swift */,
C329FEED24F7742E00B1C64C /* UIViewController+Utilities.swift */,
B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */,
C31D1DD225216101005D4DA8 /* UIView+Utilities.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -2678,6 +2689,7 @@
B8CCF638239721E20091D419 /* TabBar.swift */,
B8BB82B423947F2D00BA5194 /* TextField.swift */,
C3C3CF8824D8EED300E1CCE7 /* TextView.swift */,
C31D1DDC25217014005D4DA8 /* UserCell.swift */,
);
path = Components;
sourceTree = "<group>";
@ -2688,6 +2700,7 @@
B8544E3223D50E4900299F14 /* AppearanceUtilities.swift */,
C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */,
C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */,
C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */,
C35E8AAD2485E51D00ACB629 /* IP2Country.swift */,
B84664F4235022F30083A1CD /* MentionUtilities.swift */,
B886B4A82398BA1500211ABE /* QRCode.swift */,
@ -2709,6 +2722,7 @@
B80C6B582384C4E700FDBC8B /* DeviceNameModal.swift */,
B80C6B5A2384C7F900FDBC8B /* DeviceNameModalDelegate.swift */,
B82B408D239DC00D00A248E7 /* DisplayNameVC.swift */,
C3E5C2F9251DBABB0040DFFC /* EditClosedGroupVC.swift */,
B88847BB23E10BC6009836D2 /* GroupMembersVC.swift */,
B8BB82A4238F627000BA5194 /* HomeVC.swift */,
B8CCF63E23975CFB0091D419 /* JoinPublicChatVC.swift */,
@ -2732,6 +2746,7 @@
B85357C223A1BD1200AAF6CD /* SeedVC.swift */,
B8CCF6422397711F0091D419 /* SettingsVC.swift */,
C3DFFAC523E96F0D0058DAF8 /* Sheet.swift */,
C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */,
);
path = "View Controllers";
sourceTree = "<group>";
@ -3705,6 +3720,7 @@
4541B71D209D3B7A0008608F /* ContactShareViewModel.swift in Sources */,
4C618199219DF03A009BD6B5 /* OWSButton.swift in Sources */,
34AC09F3211B39B100997B47 /* NewNonContactConversationViewController.m in Sources */,
C31D1DD325216101005D4DA8 /* UIView+Utilities.swift in Sources */,
4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */,
34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */,
34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */,
@ -3770,6 +3786,7 @@
45C9DEB81DF4E35A0065CA84 /* WebRTCCallMessageHandler.swift in Sources */,
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */,
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */,
B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */,
B80C6B5B2384C7F900FDBC8B /* DeviceNameModalDelegate.swift in Sources */,
@ -3783,6 +3800,7 @@
340FC8B4204DAC8D007AEB0F /* OWSBackupSettingsViewController.m in Sources */,
34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */,
451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */,
C31D1DDD25217014005D4DA8 /* UserCell.swift in Sources */,
34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */,
348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */,
450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */,
@ -3949,6 +3967,7 @@
B8BB82B523947F2D00BA5194 /* TextField.swift in Sources */,
34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */,
457F671B20746193000EABCD /* QuotedReplyPreview.swift in Sources */,
C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */,
34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */,
C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */,
4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */,
@ -3966,6 +3985,7 @@
340FC8B9204DAC8D007AEB0F /* UpdateGroupViewController.m in Sources */,
B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */,
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */,
C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */,
3448E1662215B313004B052E /* OnboardingCaptchaViewController.swift in Sources */,
4574A5D61DD6704700C6B692 /* CallService.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
@ -4123,7 +4143,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -4185,7 +4205,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -4239,7 +4259,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = dwarf;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
@ -4309,7 +4329,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
@ -4371,7 +4391,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -4434,7 +4454,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -4635,7 +4655,7 @@
CODE_SIGN_ENTITLEMENTS = Signal/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -4702,7 +4722,7 @@
CODE_SIGN_ENTITLEMENTS = Signal/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 122;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",

@ -0,0 +1,94 @@
final class UserCell : UITableViewCell {
var accessory = Accessory.none
var publicKey = ""
// MARK: Accessory
enum Accessory {
case none
case tick(isSelected: Bool)
}
// MARK: Components
private lazy var profilePictureView = ProfilePictureView()
private lazy var displayNameLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var tickImageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFit
let size: CGFloat = 24
result.set(.width, to: size)
result.set(.height, to: size)
return result
}()
private lazy var separator: UIView = {
let result = UIView()
result.backgroundColor = Colors.separator
result.set(.height, to: Values.separatorThickness)
return result
}()
// MARK: Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
// Set the cell background color
backgroundColor = Colors.cellBackground
// Set up the highlight color
let selectedBackgroundView = UIView()
selectedBackgroundView.backgroundColor = .clear // Disabled for now
self.selectedBackgroundView = selectedBackgroundView
// Set up the profile picture image view
let profilePictureViewSize = Values.smallProfilePictureSize
profilePictureView.set(.width, to: profilePictureViewSize)
profilePictureView.set(.height, to: profilePictureViewSize)
profilePictureView.size = profilePictureViewSize
// Set up the main stack view
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, tickImageView ])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = Values.mediumSpacing
contentView.addSubview(stackView)
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
stackView.pin(.top, to: .top, of: contentView, withInset: Values.mediumSpacing)
contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.mediumSpacing)
contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.mediumSpacing)
stackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
// Set up the separator
contentView.addSubview(separator)
separator.pin(.leading, to: .leading, of: contentView)
contentView.pin(.trailing, to: .trailing, of: separator)
separator.pin(.bottom, to: .bottom, of: contentView)
separator.set(.width, to: UIScreen.main.bounds.width)
}
// MARK: Updating
func update() {
profilePictureView.hexEncodedPublicKey = publicKey
profilePictureView.update()
displayNameLabel.text = UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey) ?? publicKey
switch accessory {
case .none: tickImageView.isHidden = true
case .tick(let isSelected):
tickImageView.isHidden = false
let icon = isSelected ? #imageLiteral(resourceName: "CircleCheck") : #imageLiteral(resourceName: "Circle")
tickImageView.image = isDarkMode ? icon : icon.asTintedImage(color: Colors.text)!
}
}
}

@ -0,0 +1,17 @@
enum ContactUtilities {
static func getAllContacts() -> [String] {
var result: [String] = []
Storage.read { transaction in
TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in
guard let thread = object as? TSContactThread, thread.shouldThreadBeVisible else { return }
result.append(thread.contactIdentifier())
}
}
func getDisplayName(for publicKey: String) -> String {
return UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey) ?? publicKey
}
return result.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
}
}

@ -150,7 +150,8 @@ final class DeviceLinksVC : BaseVC, UITableViewDataSource, UITableViewDelegate,
storage.removePreKeyBundle(forContact: linkedDevicePublicKey, transaction: transaction)
storage.deleteAllSessions(forContact: linkedDevicePublicKey, protocolContext: transaction)
for groupPublicKey in Storage.getUserClosedGroupPublicKeys() {
ClosedGroupsProtocol.removeMembers([ linkedDevicePublicKey ], from: groupPublicKey, using: transaction)
// TODO: Possibly re-implement in the future
// ClosedGroupsProtocol.removeMembers([ linkedDevicePublicKey ], from: groupPublicKey, using: transaction)
}
}
}, failure: { _ in
@ -160,7 +161,8 @@ final class DeviceLinksVC : BaseVC, UITableViewDataSource, UITableViewDelegate,
storage.removePreKeyBundle(forContact: linkedDevicePublicKey, transaction: transaction)
storage.deleteAllSessions(forContact: linkedDevicePublicKey, protocolContext: transaction)
for groupPublicKey in Storage.getUserClosedGroupPublicKeys() {
ClosedGroupsProtocol.removeMembers([ linkedDevicePublicKey ], from: groupPublicKey, using: transaction)
// TODO: Possibly re-implement in the future
// ClosedGroupsProtocol.removeMembers([ linkedDevicePublicKey ], from: groupPublicKey, using: transaction)
}
}
})

@ -0,0 +1,261 @@
@objc(LKEditClosedGroupVC)
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
private let thread: TSGroupThread
private var name = ""
private var members: [String] = [] { didSet { tableView.reloadData() } }
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
// MARK: Components
private lazy var groupNameLabel: UILabel = {
let result = 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)
result.textAlignment = .center
return result
}()
@objc private lazy var tableView: UITableView = {
let result = UITableView()
result.dataSource = self
result.delegate = self
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
result.separatorStyle = .none
result.backgroundColor = .clear
result.isScrollEnabled = false
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
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(with:) instead.")
}
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
setUpViewHierarchy()
updateNavigationBarButtons()
name = thread.groupModel.groupName!
func getDisplayName(for publicKey: String) -> String {
return UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey) ?? publicKey
}
members = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
}
private func setUpViewHierarchy() {
// Group name container
groupNameLabel.text = thread.groupModel.groupName
let groupNameContainer = UIView()
groupNameContainer.addSubview(groupNameLabel)
groupNameLabel.pin(to: groupNameContainer)
groupNameContainer.addSubview(groupNameTextField)
groupNameTextField.pin(to: groupNameContainer)
groupNameContainer.set(.height, to: 40)
groupNameTextField.alpha = 0
// Top container
let topContainer = UIView()
topContainer.addSubview(groupNameContainer)
groupNameContainer.center(in: topContainer)
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 addMembersButton = Button(style: .prominentOutline, size: .large)
addMembersButton.setTitle("Add Members", for: UIControl.State.normal)
addMembersButton.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
addMembersButton.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
if (Set(ContactUtilities.getAllContacts()).subtracting(members).isEmpty) {
addMembersButton.isUserInteractionEnabled = false
let disabledColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
addMembersButton.layer.borderColor = disabledColor.cgColor
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
}
// Middle stack view
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
middleStackView.axis = .horizontal
middleStackView.alignment = .center
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
middleStackView.isLayoutMarginsRelativeArrangement = true
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [
UIView.vSpacer(Values.veryLargeSpacing),
topContainer,
UIView.vSpacer(Values.veryLargeSpacing),
UIView.separator(),
middleStackView,
UIView.separator(),
tableView
])
mainStackView.axis = .vertical
mainStackView.alignment = .fill
mainStackView.set(.width, to: UIScreen.main.bounds.width)
// Scroll view
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.addSubview(mainStackView)
mainStackView.pin(to: scrollView)
view.addSubview(scrollView)
scrollView.pin(to: view)
mainStackView.pin(.bottom, to: .bottom, of: view)
}
// MARK: Table View Data Source / Delegate
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return members.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
let publicKey = members[indexPath.row]
cell.publicKey = publicKey
cell.update()
return cell
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
let publicKey = members[indexPath.row]
return publicKey != getUserHexEncodedPublicKey()
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let publicKey = members[indexPath.row]
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
guard let self = self, let index = self.members.firstIndex(of: publicKey) else { return }
self.members.remove(at: index)
}
removeAction.backgroundColor = Colors.destructive
return [ removeAction ]
}
// 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 {
navigationItem.leftBarButtonItem = nil
}
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
doneButton.tintColor = Colors.text
navigationItem.rightBarButtonItem = doneButton
}
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 {
groupNameTextField.resignFirstResponder()
}
}
// MARK: Interaction
@objc private func showEditGroupNameUI() {
isEditingGroupName = true
}
@objc private func handleCancelGroupNameEditingButtonTapped() {
isEditingGroupName = false
}
@objc private func handleDoneButtonTapped() {
if isEditingGroupName {
updateGroupName()
} 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: ""))
}
guard name.count < ClosedGroupsProtocol.maxNameSize else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
}
isEditingGroupName = false
self.name = name
groupNameLabel.text = name
}
@objc private func addMembers() {
let title = "Add Members"
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(members)) { [weak self] selectedUsers in
guard let self = self else { return }
var members = self.members
members.append(contentsOf: selectedUsers)
func getDisplayName(for publicKey: String) -> String {
return UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey) ?? publicKey
}
self.members = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
}
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 ConversationViewController }) {
editVC.navigationController!.popToViewController(conversationVC, animated: true)
} else {
editVC.navigationController!.popViewController(animated: true)
}
}
let groupID = thread.groupModel.groupId
let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID)
let members = Set(self.members)
let name = self.name
guard members != Set(thread.groupModel.groupMemberIds) || name != thread.groupModel.groupName else {
return popToConversationVC(self)
}
try! Storage.writeSync { [weak self] transaction in
ClosedGroupsProtocol.update(groupPublicKey, with: members, name: name, transaction: transaction).done(on: DispatchQueue.main) {
guard let self = self else { return }
popToConversationVC(self)
}.catch(on: DispatchQueue.main) { error in
guard let self = self else { return }
self.showError(title: "Couldn't Update Group", message: "Please check your internet connection and try again.")
}
}
}
// MARK: Convenience
private func showError(title: String, message: String = "") {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
presentAlert(alert)
}
}

@ -169,7 +169,7 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
guard let name = nameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), name.count > 0 else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
}
guard name.count < 64 else {
guard name.count < ClosedGroupsProtocol.maxNameSize else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
}
guard selectedContacts.count >= 1 else {

@ -63,7 +63,7 @@ final class SeedModal : Modal {
let disclaimerLabel = UILabel()
disclaimerLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
disclaimerLabel.font = .systemFont(ofSize: 10)
disclaimerLabel.text = NSLocalizedString("modal_seed_disclaimer", comment: "")
disclaimerLabel.text = "It is not possible to use the same Session ID on multiple devices simultaneously"
disclaimerLabel.numberOfLines = 0
disclaimerLabel.lineBreakMode = .byWordWrapping
disclaimerLabel.textAlignment = .center

@ -0,0 +1,77 @@
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 = ContactUtilities.getAllContacts()
result.removeAll { usersToExclude.contains($0) }
return result
}()
// MARK: Components
@objc private lazy var tableView: UITableView = {
let result = 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
return result
}()
// MARK: Lifecycle
@objc init(with title: String, excluding usersToExclude: Set<String>, completion: @escaping (Set<String>) -> Void) {
self.navBarTitle = title
self.usersToExclude = usersToExclude
self.completion = completion
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { preconditionFailure("Use UserSelectionVC.init(excluding:) instead.") }
override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use UserSelectionVC.init(excluding:) instead.") }
override func viewDidLoad() {
super.viewDidLoad()
setUpGradientBackground()
setUpNavBarStyle()
setNavBarTitle(navBarTitle)
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
view.addSubview(tableView)
tableView.pin(to: view)
}
// MARK: Data
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()
return cell
}
// 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()
}
@objc private func handleDoneButtonTapped() {
completion(selectedUsers)
navigationController!.popViewController(animated: true)
}
}

@ -649,55 +649,62 @@ const CGFloat kIconViewLength = 24;
// Group settings section.
if (self.isGroupThread && self.isPrivateGroupChat) {
// [OWSTableItem
__block BOOL isUserMember = NO;
if (self.isGroupThread) {
NSString *userPublicKey = OWSIdentityManager.sharedManager.identityKeyPair.hexEncodedPublicKey;
[LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) {
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey transaction:transaction];
}];
}
if (self.isGroupThread && self.isPrivateGroupChat && isUserMember) {
if (((TSGroupThread *)self.thread).usesSharedSenderKeys) {
[mainSection addItem:[OWSTableItem
itemWithCustomCellBlock:^{
UITableViewCell *cell =
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION",
@"table cell label in conversation settings")
iconName:@"table_ic_group_edit"
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
OWSConversationSettingsViewController, @"edit_group")];
cell.userInteractionEnabled = !weakSelf.hasLeftGroup;
return cell;
}
actionBlock:^{
[weakSelf editGroup];
}]
];
}
// [mainSection addItem:[OWSTableItem
// itemWithCustomCellBlock:^{
// UITableViewCell *cell =
// [weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION",
// [weakSelf disclosureCellWithName:NSLocalizedString(@"LIST_GROUP_MEMBERS_ACTION",
// @"table cell label in conversation settings")
// iconName:@"table_ic_group_edit"
// iconName:@"table_ic_group_members"
// accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
// OWSConversationSettingsViewController, @"edit_group")];
// cell.userInteractionEnabled = !weakSelf.hasLeftGroup;
// OWSConversationSettingsViewController, @"group_members")];
// return cell;
// }
// actionBlock:^{
// [weakSelf showUpdateGroupView:UpdateGroupMode_Default];
// }],
// [weakSelf showGroupMembersView];
// }]
// ];
[mainSection addItem:[OWSTableItem
itemWithCustomCellBlock:^{
UITableViewCell *cell =
[weakSelf disclosureCellWithName:NSLocalizedString(@"LIST_GROUP_MEMBERS_ACTION",
[weakSelf disclosureCellWithName:NSLocalizedString(@"LEAVE_GROUP_ACTION",
@"table cell label in conversation settings")
iconName:@"table_ic_group_members"
iconName:@"table_ic_group_leave"
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
OWSConversationSettingsViewController, @"group_members")];
OWSConversationSettingsViewController, @"leave_group")];
cell.userInteractionEnabled = !weakSelf.hasLeftGroup;
return cell;
}
actionBlock:^{
[weakSelf showGroupMembersView];
[weakSelf didTapLeaveGroup];
}]
];
NSString *userPublicKey = OWSIdentityManager.sharedManager.identityKeyPair.hexEncodedPublicKey;
if ([((TSGroupThread *)self.thread).groupModel.groupMemberIds containsObject:userPublicKey]) {
[mainSection addItem:[OWSTableItem
itemWithCustomCellBlock:^{
UITableViewCell *cell =
[weakSelf disclosureCellWithName:NSLocalizedString(@"LEAVE_GROUP_ACTION",
@"table cell label in conversation settings")
iconName:@"table_ic_group_leave"
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
OWSConversationSettingsViewController, @"leave_group")];
cell.userInteractionEnabled = !weakSelf.hasLeftGroup;
return cell;
}
actionBlock:^{
[weakSelf didTapLeaveGroup];
}]
];
}
}
@ -1144,6 +1151,12 @@ const CGFloat kIconViewLength = 24;
[self presentContactViewController];
}
- (void)editGroup
{
LKEditClosedGroupVC *editClosedGroupVC = [[LKEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId];
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
}
- (void)didTapLeaveGroup
{
UIAlertController *alert =

@ -2728,6 +2728,3 @@
"vc_contact_selection_contacts_title" = "Contacts";
"vc_contact_selection_closed_groups_title" = "Closed Groups";
"vc_contact_selection_open_groups_title" = "Open Groups";
// MARK: Next round of translation
"modal_seed_disclaimer" = "It is not possible to use the same Session ID on multiple devices simultaneously";

@ -0,0 +1,22 @@
public extension UIView {
static func hSpacer(_ width: CGFloat) -> UIView {
let result = UIView()
result.set(.width, to: width)
return result
}
static func vSpacer(_ height: CGFloat) -> UIView {
let result = UIView()
result.set(.height, to: height)
return result
}
static func separator() -> UIView {
let result = UIView()
result.set(.height, to: Values.separatorThickness)
result.backgroundColor = Colors.separator
return result
}
}

@ -14,6 +14,21 @@ import PromiseKit
public final class ClosedGroupsProtocol : NSObject {
public static let isSharedSenderKeysEnabled = true
public static let groupSizeLimit = 20
public static let maxNameSize = 64
public enum Error : LocalizedError {
case noThread
case noPrivateKey
case invalidUpdate
public var errorDescription: String? {
switch self {
case .noThread: return "Couldn't find a thread associated with the given group public key."
case .noPrivateKey: return "Couldn't find a private key associated with the given group public key."
case .invalidUpdate: return "Invalid group update."
}
}
}
// MARK: - Sending
@ -74,45 +89,86 @@ public final class ClosedGroupsProtocol : NSObject {
return when(fulfilled: promises).map2 { thread }
}
public static func addMembers(_ newMembers: Set<String>, to groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
// Prepare
/// - Note: The returned promise is only relevant for group leaving.
public static func update(_ groupPublicKey: String, with members: Set<String>, name: String, transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
let userPublicKey = getUserHexEncodedPublicKey()
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
guard let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
return print("[Loki] Can't add users to nonexistent closed group.")
print("[Loki] Can't update nonexistent closed group.")
return Promise(error: Error.noThread)
}
let group = thread.groupModel
let name = group.groupName!
let oldMembers = Set(group.groupMemberIds)
let newMembers = members.subtracting(oldMembers)
let membersAsData = members.map { Data(hex: $0) }
let admins = group.groupAdminIds
let adminsAsData = admins.map { Data(hex: $0) }
guard let groupPrivateKey = Storage.getClosedGroupPrivateKey(for: groupPublicKey) else {
return print("[Loki] Can't get private key for closed group.")
}
// Add the members to the member list
var members = group.groupMemberIds
members.append(contentsOf: newMembers)
let membersAsData = members.map { Data(hex: $0) }
// Generate ratchets for the new members (and their linked devices)
var newMembersAndLinkedDevices: Set<String> = newMembers
for member in newMembers {
let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction)
newMembersAndLinkedDevices.formUnion(deviceLinks.flatMap { [ $0.master.publicKey, $0.slave.publicKey ] })
}
let senderKeys: [ClosedGroupSenderKey] = newMembersAndLinkedDevices.map { publicKey in
let ratchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: publicKey, using: transaction)
return ClosedGroupSenderKey(chainKey: Data(hex: ratchet.chainKey), keyIndex: ratchet.keyIndex, publicKey: Data(hex: publicKey))
print("[Loki] Couldn't get private key for closed group.")
return Promise(error: Error.noPrivateKey)
}
let wasAnyUserRemoved = Set(members).intersection(oldMembers) != oldMembers
let removedMembers = oldMembers.subtracting(members)
let isUserLeaving = removedMembers.contains(userPublicKey)
var newSenderKeys: [ClosedGroupSenderKey] = []
if wasAnyUserRemoved {
if isUserLeaving && removedMembers.count != 1 {
print("[Loki] Can't remove self and others simultaneously.")
return Promise(error: Error.invalidUpdate)
}
// Send the update to the group (don't include new ratchets as everyone should regenerate new ratchets individually)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: [],
members: membersAsData, admins: adminsAsData)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
SSKEnvironment.shared.messageSender.send(closedGroupUpdateMessage, success: { seal.fulfill(()) }, failure: { seal.reject($0) })
promise.done {
try! Storage.writeSync { transaction in
// Delete all ratchets (it's important that this happens * after * sending out the update)
Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction)
// Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and
// send it out to all members (minus the removed ones) using established channels.
if isUserLeaving {
Storage.removeClosedGroupPrivateKey(for: groupPublicKey, using: transaction)
// Notify the PN server
LokiPushNotificationManager.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey)
} else {
// Establish sessions if needed
establishSessionsIfNeeded(with: [String](members), using: transaction)
// Send out the user's new ratchet to all members (minus the removed ones) using established channels
let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
for member in members {
guard member != userPublicKey else { continue }
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
thread.save(with: transaction)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
}
}
}
}
} else {
seal.fulfill(())
// Generate ratchets for any new members
newSenderKeys = newMembers.map { publicKey in
let ratchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: publicKey, using: transaction)
return ClosedGroupSenderKey(chainKey: Data(hex: ratchet.chainKey), keyIndex: ratchet.keyIndex, publicKey: Data(hex: publicKey))
}
// Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: newSenderKeys,
members: membersAsData, admins: adminsAsData)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
}
// Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: senderKeys,
members: membersAsData, admins: adminsAsData)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
// Establish sessions if needed
establishSessionsIfNeeded(with: [String](newMembers), using: transaction) // Not `newMembersAndLinkedDevices` as this internally takes care of multi device already
// Send closed group update messages to the new members (and their linked devices) using established channels
establishSessionsIfNeeded(with: [String](newMembers), using: transaction)
// Send closed group update messages to the new members using established channels
var allSenderKeys = Storage.getAllClosedGroupSenderKeys(for: groupPublicKey)
allSenderKeys.formUnion(senderKeys)
for member in newMembers { // Not `newMembersAndLinkedDevices` as this internally takes care of multi device already
allSenderKeys.formUnion(newSenderKeys)
for member in newMembers {
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
thread.save(with: transaction)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name,
@ -121,95 +177,34 @@ public final class ClosedGroupsProtocol : NSObject {
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
}
// Update the group
let newGroupModel = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
let newGroupModel = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
thread.setGroupModel(newGroupModel, with: transaction)
// Notify the user
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate)
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel, contactsManager: SSKEnvironment.shared.contactsManager)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate, customMessage: updateInfo)
infoMessage.save(with: transaction)
// Return
return promise
}
/// The returned promise is fulfilled when the message has been sent **to the group**. It doesn't wait for the user's new ratchet to be distributed.
@objc(leaveGroupWithPublicKey:transaction:)
public static func objc_leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise {
return AnyPromise.from(leave(groupPublicKey, using: transaction))
}
public static func leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
let userPublicKey = UserDefaults.standard[.masterHexEncodedPublicKey] ?? getUserHexEncodedPublicKey()
return removeMembers([ userPublicKey ], from: groupPublicKey, using: transaction)
}
/// The returned promise is fulfilled when the message has been sent **to the group**. It doesn't wait for the user's new ratchet to be distributed.
public static func removeMembers(_ membersToRemove: Set<String>, from groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
// Prepare
public static func leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
let userPublicKey = UserDefaults.standard[.masterHexEncodedPublicKey] ?? getUserHexEncodedPublicKey()
let isUserLeaving = membersToRemove.contains(userPublicKey)
guard !isUserLeaving || membersToRemove.count == 1 else {
print("[Loki] Can't remove self and others simultaneously.")
return Promise.value(())
}
let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
guard let thread = TSGroupThread.fetch(uniqueId: TSGroupThread.threadId(fromGroupId: groupID), transaction: transaction) else {
print("[Loki] Can't remove users from nonexistent closed group.")
return Promise.value(())
print("[Loki] Can't leave nonexistent closed group.")
return Promise(error: Error.noThread)
}
let group = thread.groupModel
let name = group.groupName!
let admins = group.groupAdminIds
let adminsAsData = admins.map { Data(hex: $0) }
// Remove the members from the member list
var members = group.groupMemberIds
let indexes = membersToRemove.compactMap { members.firstIndex(of: $0) }
guard indexes.count == membersToRemove.count else {
print("[Loki] Can't remove users from group.")
return Promise.value(())
}
indexes.forEach { members.remove(at: $0) }
let membersAsData = members.map { Data(hex: $0) }
// Send the update to the group (don't include new ratchets as everyone should generate new ratchets individually)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: [],
members: membersAsData, admins: adminsAsData)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
let (promise, seal) = Promise<Void>.pending()
SSKEnvironment.shared.messageSender.send(closedGroupUpdateMessage, success: { seal.fulfill(()) }, failure: { seal.reject($0) })
promise.done {
try! Storage.writeSync { transaction in
// Delete all ratchets (it's important that this happens after sending out the update)
Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction)
// Remove the group from the user's set of public keys to poll for
if isUserLeaving {
Storage.removeClosedGroupPrivateKey(for: groupPublicKey, using: transaction)
// Notify the PN server
LokiPushNotificationManager.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey)
}
}
}
// Generate a new ratchet and send it out to all members (minus the removed ones) and their linked devices using established channels if needed.
if !isUserLeaving {
// Establish sessions if needed
establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device
// Send out the user's new ratchet to all members (minus the removed ones) and their linked devices using established channels
let userPublicKey = getUserHexEncodedPublicKey()
let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction)
let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex, publicKey: Data(hex: userPublicKey))
for member in members { // This internally takes care of multi device
guard member != userPublicKey else { continue }
let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction)
thread.save(with: transaction)
let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey)
let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind)
messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction)
}
}
// Update the group
let newGroupModel = TSGroupModel(title: name, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
thread.setGroupModel(newGroupModel, with: transaction)
// Notify the user
let infoMessageType: TSInfoMessageType = isUserLeaving ? .typeGroupQuit : .typeGroupUpdate
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: infoMessageType)
infoMessage.save(with: transaction)
// Return
return promise
var newMembers = Set(group.groupMemberIds)
newMembers.remove(userPublicKey)
return update(groupPublicKey, with: newMembers, name: group.groupName!, transaction: transaction)
}
public static func requestSenderKey(for groupPublicKey: String, senderPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
@ -331,7 +326,6 @@ public final class ClosedGroupsProtocol : NSObject {
}
// Store the ratchets for any new members (it's important that this happens before the code below)
senderKeys.forEach { senderKey in
guard membersAndLinkedDevices.contains(senderKey.publicKey.toHexString()) else { return }
let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: [])
Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderKey.publicKey.toHexString(), ratchet: ratchet, using: transaction)
}
@ -367,7 +361,8 @@ public final class ClosedGroupsProtocol : NSObject {
// Notify the user if needed (don't notify them if the message just contained linked device sender keys)
if Set(members) != Set(oldMembers) || Set(admins) != Set(group.groupAdminIds) || name != group.groupName {
let infoMessageType: TSInfoMessageType = wasUserRemoved ? .typeGroupQuit : .typeGroupUpdate
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: infoMessageType)
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel, contactsManager: SSKEnvironment.shared.contactsManager)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: infoMessageType, customMessage: updateInfo)
infoMessage.save(with: transaction)
}
}

@ -133,7 +133,7 @@ const int32_t kGroupIdLength = 16;
if ([membersWhoLeft count] > 0) {
NSArray *oldMembersNames = [[membersWhoLeft allObjects] map:^NSString*(NSString* item) {
return [contactsManager displayNameForPhoneIdentifier:item];
return [LKUserDisplayNameUtilities getPrivateChatDisplayNameFor:item];
}];
updatedGroupInfoString = [updatedGroupInfoString
stringByAppendingString:[NSString

Loading…
Cancel
Save