diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 85572ccba4..4cfc866aa1 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -365,6 +365,7 @@ "room_creation_invite_another_user" = "User ID, name or email"; "room_creation_error_invite_user_by_email_without_identity_server" = "No identity server is configured so you cannot add a participant with an email."; "room_creation_dm_error" = "We couldn't create your DM. Please check the users you want to invite and try again."; +"room_creation_only_one_email_invite" = "You can only invite one email at a time"; // Room recents "room_recents_directory_section" = "ROOM DIRECTORY"; @@ -2295,6 +2296,9 @@ Tap the + to start adding people."; "room_invites_empty_view_title" = "Nothing new."; "room_invites_empty_view_information" = "This is where your invites appear."; +"room_waiting_other_participants_title" = "Waiting for users to join %@"; +"room_waiting_other_participants_message" = "Once invited users have joined %@, you will be able to chat and the room will be end-to-end encrypted"; + // MARK: - Space Selector "space_selector_title" = "My spaces"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 4fcd1c6da6..5f533e974a 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5267,6 +5267,10 @@ public class VectorL10n: NSObject { public static var roomCreationNameTitle: String { return VectorL10n.tr("Vector", "room_creation_name_title") } + /// You can only invite one email at a time + public static var roomCreationOnlyOneEmailInvite: String { + return VectorL10n.tr("Vector", "room_creation_only_one_email_invite") + } /// (e.g. @bob:homeserver1; @john:homeserver2...) public static var roomCreationParticipantsPlaceholder: String { return VectorL10n.tr("Vector", "room_creation_participants_placeholder") @@ -6623,6 +6627,14 @@ public class VectorL10n: NSObject { public static var roomUnsentMessagesUnknownDevicesNotification: String { return VectorL10n.tr("Vector", "room_unsent_messages_unknown_devices_notification") } + /// Once invited users have joined %@, you will be able to chat and the room will be end-to-end encrypted + public static func roomWaitingOtherParticipantsMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "room_waiting_other_participants_message", p1) + } + /// Waiting for users to join %@ + public static func roomWaitingOtherParticipantsTitle(_ p1: String) -> String { + return VectorL10n.tr("Vector", "room_waiting_other_participants_title", p1) + } /// End-to-end encryption is in beta and may not be reliable.\n\nYou should not yet trust it to secure data.\n\nDevices will not yet be able to decrypt history from before they joined the room.\n\nEncrypted messages will not be visible on clients that do not yet implement encryption. public static var roomWarningAboutEncryption: String { return VectorL10n.tr("Vector", "room_warning_about_encryption") diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift index a345077118..badc664904 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift @@ -31,6 +31,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { private let parentSpaceId: String? private let initialSection: RoomInfoSection private let dismissOnCancel: Bool + private let canAddParticipants: Bool private weak var roomSettingsViewController: RoomSettingsViewController? private lazy var segmentedViewController: SegmentedViewController = { @@ -43,6 +44,8 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { participants.parentSpaceId = self.parentSpaceId participants.delegate = self participants.screenTracker = AnalyticsScreenTracker(screen: .roomMembers) + participants.showInviteUserFab = self.canAddParticipants + let files = RoomFilesViewController() files.finalizeInit() @@ -105,6 +108,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { self.room = parameters.room self.parentSpaceId = parameters.parentSpaceId self.initialSection = parameters.initialSection + self.canAddParticipants = parameters.canAddParticipants self.dismissOnCancel = parameters.dismissOnCancel } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift index c1c46fab4a..2478922d66 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift @@ -33,12 +33,14 @@ class RoomInfoCoordinatorParameters: NSObject { let parentSpaceId: String? let initialSection: RoomInfoSection let dismissOnCancel: Bool + let canAddParticipants: Bool - init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection, dismissOnCancel: Bool) { + init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection, canAddParticipants: Bool = true, dismissOnCancel: Bool) { self.session = session self.room = room self.parentSpaceId = parentSpaceId self.initialSection = initialSection + self.canAddParticipants = canAddParticipants self.dismissOnCancel = dismissOnCancel super.init() } @@ -50,4 +52,8 @@ class RoomInfoCoordinatorParameters: NSObject { convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) { self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: initialSection, dismissOnCancel: false) } + + convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection, canAddParticipants: Bool) { + self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: initialSection, canAddParticipants: canAddParticipants, dismissOnCancel: false) + } } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 14bab4c051..072a882a69 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -125,6 +125,8 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; @property (nonatomic, strong, nullable) ComposerLinkActionBridgePresenter *composerLinkActionBridgePresenter; +@property (weak, nonatomic, nullable) UIViewController *waitingOtherParticipantViewController; +@property (nonatomic) BOOL isWaitingForOtherParticipants; /** Retrieve the live data source in cases where the timeline is not live. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index d4fdb6bd71..2e641916a7 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -185,6 +185,9 @@ @interface RoomViewController () = [powerLevels minimumPowerLevelForSendingEventAsMessage:kMXEventTypeStringRoomMessage]); BOOL isRoomObsolete = self.roomDataSource.roomState.isObsolete; - BOOL isResourceLimitExceeded = [self.roomDataSource.mxSession.syncError.errcode isEqualToString:kMXErrCodeStringResourceLimitExceeded]; + BOOL isResourceLimitExceeded = [self.roomDataSource.mxSession.syncError.errcode isEqualToString:kMXErrCodeStringResourceLimitExceeded]; - if (isRoomObsolete || isResourceLimitExceeded) + if (isRoomObsolete || isResourceLimitExceeded || _isWaitingForOtherParticipants) { roomInputToolbarViewClass = nil; shouldDismissContextualMenu = YES; @@ -1532,6 +1542,8 @@ - (void)destroy [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeSentStateNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:nil]; + [self waitForOtherParticipant:NO]; + [super destroy]; } @@ -1638,6 +1650,57 @@ - (BOOL)shouldShowLiveLocationSharingBannerView return self.customizedRoomDataSource.isCurrentUserSharingActiveLocation; } +#pragma mark - Wait for 3rd party invitee + +- (void)setIsWaitingForOtherParticipants:(BOOL)isWaitingForOtherParticipants +{ + if (_isWaitingForOtherParticipants == isWaitingForOtherParticipants) + { + return; + } + + _isWaitingForOtherParticipants = isWaitingForOtherParticipants; + [self updateRoomInputToolbarViewClassIfNeeded]; + + if (_isWaitingForOtherParticipants) + { + if (self->roomMemberEventListener == nil) + { + MXWeakify(self); + self->roomMemberEventListener = [self.roomDataSource.room listenToEventsOfTypes:@[kMXEventTypeStringRoomMember] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + if (direction != MXTimelineDirectionForwards) + { + return; + } + [self refreshWaitForOtherParticipantsState]; + }]; + } + } + else + { + if (self->roomMemberEventListener != nil) + { + [self.roomDataSource.room removeListener:self->roomMemberEventListener]; + self->roomMemberEventListener = nil; + } + } +} + +- (BOOL)shouldWaitForOtherParticipants +{ + MXRoomState *roomState = self.roomDataSource.roomState; + BOOL isDirect = self.roomDataSource.room.isDirect; + + // Wait for the other participant only if it is a direct encrypted room with only one member waiting for a third party guest. + return (isDirect && roomState.isEncrypted && roomState.membersCount.members == 1 && roomState.thirdPartyInvites.count > 0); +} + +- (void)refreshWaitForOtherParticipantsState +{ + [self waitForOtherParticipant:self.shouldWaitForOtherParticipants]; +} + #pragma mark - Internals - (UIBarButtonItem *)videoCallBarButtonItem @@ -1948,7 +2011,7 @@ - (void)refreshRoomTitle [self refreshMissedDiscussionsCount:YES]; - if (RiotSettings.shared.enableThreads) + if (RiotSettings.shared.enableThreads && !_isWaitingForOtherParticipants) { if (self.roomDataSource.threadId) { @@ -2260,8 +2323,8 @@ - (void)showRoomInfo - (void)showRoomInfoWithInitialSection:(RoomInfoSection)roomInfoSection animated:(BOOL)animated { - RoomInfoCoordinatorParameters *parameters = [[RoomInfoCoordinatorParameters alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId initialSection:roomInfoSection]; - + RoomInfoCoordinatorParameters *parameters = [[RoomInfoCoordinatorParameters alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId initialSection:roomInfoSection canAddParticipants: !self.isWaitingForOtherParticipants]; + self.roomInfoCoordinatorBridgePresenter = [[RoomInfoCoordinatorBridgePresenter alloc] initWithParameters:parameters]; self.roomInfoCoordinatorBridgePresenter.delegate = self; @@ -7450,7 +7513,7 @@ - (void)updateThreadListBarButtonBadgeWith:(MXThreadingService *)service - (void)updateThreadListBarButtonItem:(UIBarButtonItem *)barButtonItem with:(MXThreadingService *)service { - if (!service) + if (!service || _isWaitingForOtherParticipants) { return; } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 561cc382a8..a177281f36 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -251,6 +251,50 @@ extension RoomViewController { composerLinkActionBridgePresenter = presenter presenter.present(from: self, animated: true) } + + @objc func showWaitingOtherParticipantHeader() { + let controller = VectorHostingController(rootView: RoomWaitingForMembers()) + guard let headerView = controller.view else { + return + } + self.waitingOtherParticipantViewController = controller + self.addChild(controller) + + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + headerView.translatesAutoresizingMaskIntoConstraints = false + containerView.vc_addSubViewMatchingParent(headerView, withInsets: UIEdgeInsets(top: 9, left: 9, bottom: -9, right: -9)) + + self.bubblesTableView.tableHeaderView = containerView + NSLayoutConstraint.activate([ + containerView.centerXAnchor.constraint(equalTo: self.bubblesTableView.centerXAnchor), + containerView.widthAnchor.constraint(equalTo: self.bubblesTableView.widthAnchor), + containerView.topAnchor.constraint(equalTo: self.bubblesTableView.topAnchor) + ]) + controller.didMove(toParent: self) + + self.bubblesTableView.tableHeaderView?.layoutIfNeeded() + } + + @objc func hideWaitingOtherParticipantHeader() { + guard let waitingOtherParticipantViewController else { + return + } + waitingOtherParticipantViewController.removeFromParent() + self.bubblesTableView.tableHeaderView = nil + waitingOtherParticipantViewController.didMove(toParent: nil) + self.waitingOtherParticipantViewController = nil + } + + @objc func waitForOtherParticipant(_ wait: Bool) { + self.isWaitingForOtherParticipants = wait + if wait { + showWaitingOtherParticipantHeader() + } else { + hideWaitingOtherParticipantHeader() + } + } + } // MARK: - Private Helpers diff --git a/Riot/Modules/StartChat/StartChatViewController.m b/Riot/Modules/StartChat/StartChatViewController.m index c70bbdc833..f2bf8ff538 100644 --- a/Riot/Modules/StartChat/StartChatViewController.m +++ b/Riot/Modules/StartChat/StartChatViewController.m @@ -35,6 +35,9 @@ @interface StartChatViewController () 0) + { + return NO; + } + + // Otherwise, we should be able to add this participant + return YES; +} + +- (void)showAllowOnlyOneInvitByEmailAllowedHeaderView:(BOOL)visible +{ + if (visible) + { + if (!self.onlyOneEmailInvitationView) + { + UIView *headerView = [[UIView alloc] initWithFrame: CGRectZero]; + headerView.translatesAutoresizingMaskIntoConstraints = NO; + + UILabel *label = [[UILabel alloc] initWithFrame: CGRectZero]; + label.numberOfLines = 0; + label.textColor = ThemeService.shared.theme.textSecondaryColor; + label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightLight]; + label.adjustsFontSizeToFitWidth = YES; + + label.text = VectorL10n.roomCreationOnlyOneEmailInvite; + label.translatesAutoresizingMaskIntoConstraints = NO; + [headerView addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:headerView.leadingAnchor constant:16], + [label.trailingAnchor constraintEqualToAnchor:headerView.trailingAnchor constant:-16], + [label.topAnchor constraintEqualToAnchor:headerView.topAnchor constant:8], + [label.bottomAnchor constraintEqualToAnchor:headerView.bottomAnchor constant:-8], + ]]; + [label setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical]; + [headerView setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical]; + + self.onlyOneEmailInvitationView = headerView; + self.contactsTableView.tableHeaderView = self.onlyOneEmailInvitationView; + + [NSLayoutConstraint activateConstraints:@[ + [headerView.leadingAnchor constraintEqualToAnchor:self.contactsTableView.safeAreaLayoutGuide.leadingAnchor], + [headerView.trailingAnchor constraintEqualToAnchor:self.contactsTableView.safeAreaLayoutGuide.trailingAnchor] + ]]; + [self.contactsTableView.tableHeaderView layoutIfNeeded]; + } + } + else if (self.onlyOneEmailInvitationView != nil) + { + if (self.contactsTableView.tableHeaderView == self.onlyOneEmailInvitationView) + { + self.contactsTableView.tableHeaderView = nil; + } + self.onlyOneEmailInvitationView = nil; + } } - (void)showInviteFriendsFromSourceView:(UIView*)sourceView @@ -367,6 +476,14 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N if (_isAddParticipantSearchBarEditing) { cell = [contactsDataSource tableView:tableView cellForRowAtIndexPath:indexPath]; + MXKContact* contact = [contactsDataSource contactAtIndexPath:indexPath]; + if (![self canAddParticipant:contact]) + { + // Prevent to add it + cell.contentView.alpha = 0.5; + cell.userInteractionEnabled = NO; + cell.accessoryView = nil; + } } else if (indexPath.section == participantsSection) { @@ -533,7 +650,7 @@ - (IBAction)onButtonPressed:(id)sender // Prepare the invited participant data NSMutableArray *inviteArray = [NSMutableArray array]; - NSMutableArray *invite3PIDArray = [NSMutableArray array]; + NSMutableArray *invite3PIDArray = [NSMutableArray array]; // Check whether some users must be invited for (MXKContact *contact in participants) @@ -594,7 +711,7 @@ - (IBAction)onButtonPressed:(id)sender // Is it a direct chat? BOOL isDirect = ((inviteArray.count + invite3PIDArray.count == 1) ? YES : NO); - + // In case of a direct chat with only one user id, we open the first available direct chat // or creates a new one (if it doesn't exist). if (isDirect && inviteArray.count) @@ -606,6 +723,19 @@ - (IBAction)onButtonPressed:(id)sender } else { + // We don't want to create a new direct room for a 3rd party invite if we already have one + NSString *first3rdPartyInvitee = invite3PIDArray.firstObject.address; + if (isDirect && first3rdPartyInvitee) + { + MXRoom *existingRoom = [self.mainSession directJoinedRoomWithUserId:first3rdPartyInvitee]; + if (existingRoom) + { + [self stopActivityIndicator]; + [[AppDelegate theDelegate] showRoom:existingRoom.roomId andEventId:nil withMatrixSession:self.mainSession]; + return; + } + } + // Ensure direct chat are created with equal ops on both sides (the trusted_private_chat preset) MXRoomPreset preset = (isDirect ? kMXRoomPresetTrustedPrivateChat : nil); @@ -635,7 +765,7 @@ - (IBAction)onButtonPressed:(id)sender roomCreationParameters.isDirect = isDirect; roomCreationParameters.preset = preset; - if (canEnableE2E && roomCreationParameters.invite3PIDArray == nil) + if (canEnableE2E) { roomCreationParameters.initialStateEvents = @[ [MXRoomCreationParameters initialStateEventForEncryptionWithAlgorithm:kMXCryptoMegolmAlgorithm @@ -644,6 +774,9 @@ - (IBAction)onButtonPressed:(id)sender self->roomCreationRequest = [self.mainSession createRoomWithParameters:roomCreationParameters success:^(MXRoom *room) { + // Update the room summary + [room.summary resetRoomStateData]; + self->roomCreationRequest = nil; [self stopActivityIndicator]; @@ -702,8 +835,28 @@ - (void)refreshSearchBarItemsColor:(UISearchBar *)searchBar - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { - [contactsDataSource searchWithPattern:searchText forceReset:NO]; + self->currentSearch = searchText; + if (searchText != nil && searchText.length > 0) + { + MXKContact *contact = nil; + if ([MXTools isMatrixUserIdentifier:searchText]) + { + contact = [[MXKContact alloc] initMatrixContactWithDisplayName:searchText andMatrixID:searchText]; + + } + else if ([MXTools isEmailAddress:searchText]) + { + contact = [[MXKContact alloc] initContactWithDisplayName:searchText emails:nil phoneNumbers:nil andThumbnail:nil]; + } + + [self showAllowOnlyOneInvitByEmailAllowedHeaderView: ![self canAddParticipant:contact]]; + } + else + { + [self showAllowOnlyOneInvitByEmailAllowedHeaderView:NO]; + } + [contactsDataSource searchWithPattern:searchText forceReset:NO]; self.contactsAreFilteredWithSearch = searchText.length ? YES : NO; } @@ -718,6 +871,7 @@ - (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { searchBar.text = nil; + self->currentSearch = nil; self.isAddParticipantSearchBarEditing = NO; // Reset filtering @@ -725,6 +879,8 @@ - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar // Leave search [searchBar resignFirstResponder]; + + [self showAllowOnlyOneInvitByEmailAllowedHeaderView:NO]; } #pragma mark - ContactsTableViewControllerDelegate @@ -763,14 +919,14 @@ - (void)contactsTableViewController:(ContactsTableViewController *)contactsTable } } - if (contact) + if ([self canAddParticipant:contact]) { // Update here the mutable list of participants [participants addObject:contact]; + + // Refresh display by leaving search session + [self searchBarCancelButtonClicked:_searchBarView]; } - - // Refresh display by leaving search session - [self searchBarCancelButtonClicked:_searchBarView]; } #pragma mark - InviteFriendsHeaderViewDelegate diff --git a/Riot/Modules/StartChat/StartChatViewController.xib b/Riot/Modules/StartChat/StartChatViewController.xib index 958aaf9045..eb4ea3a6a3 100644 --- a/Riot/Modules/StartChat/StartChatViewController.xib +++ b/Riot/Modules/StartChat/StartChatViewController.xib @@ -1,9 +1,9 @@ - + - + @@ -13,6 +13,7 @@ + diff --git a/RiotSwiftUI/Modules/Room/WaitingForMembers/View/RoomWaitingForMembers.swift b/RiotSwiftUI/Modules/Room/WaitingForMembers/View/RoomWaitingForMembers.swift new file mode 100644 index 0000000000..2b4827b819 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/WaitingForMembers/View/RoomWaitingForMembers.swift @@ -0,0 +1,50 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct RoomWaitingForMembers: View { + @Environment(\.theme) private var theme: ThemeSwiftUI + + var body: some View { + ZStack { + HStack(alignment: .top) { + Image(uiImage: Asset.Images.membersListIcon.image) + VStack(alignment: .leading, spacing: 6) { + Text(VectorL10n.roomWaitingOtherParticipantsTitle(AppInfo.current.displayName)) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(VectorL10n.roomWaitingOtherParticipantsMessage(AppInfo.current.displayName)) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(9) + .background(theme.colors.system) + .cornerRadius(4) + } + } +} + +struct RoomWaitingForMembers_Previews: PreviewProvider { + static var previews: some View { + RoomWaitingForMembers() + .padding(16) + } +} diff --git a/changelog.d/6612.change b/changelog.d/6612.change new file mode 100644 index 0000000000..19534ef150 --- /dev/null +++ b/changelog.d/6612.change @@ -0,0 +1 @@ +Direct Message: manage encrypted DM in case of invite by email