diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 359e321bb..0e6209163 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -235,7 +235,7 @@ final class BuildSettings: NSObject { static let allowInviteExernalUsers: Bool = true // MARK: - Side Menu - static let enableSideMenu: Bool = true + static let enableSideMenu: Bool = true && !newAppLayoutEnabled static let sideMenuShowInviteFriends: Bool = true /// Whether to read the `io.element.functional_members` state event and exclude any service members when computing a room's name and avatar. diff --git a/Riot/Assets/Images.xcassets/Home/RoomContextualMenu/Contents.json b/Riot/Assets/Images.xcassets/Home/RoomContextualMenu/Contents.json index da4a164c9..73c00596a 100644 --- a/Riot/Assets/Images.xcassets/Home/RoomContextualMenu/Contents.json +++ b/Riot/Assets/Images.xcassets/Home/RoomContextualMenu/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/Contents.json b/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/Contents.json deleted file mode 100644 index 5b6d4a656..000000000 --- a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "filename" : "home_fab_create_room.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "home_fab_create_room@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "home_fab_create_room@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room.png b/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room.png deleted file mode 100644 index ec25c6d82..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@2x.png b/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@2x.png deleted file mode 100644 index 47d024dc1..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@3x.png b/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@3x.png deleted file mode 100644 index fef140a3b..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_create_room.imageset/home_fab_create_room@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room.png b/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room.png deleted file mode 100644 index e74a2d4db..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@2x.png b/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@2x.png deleted file mode 100644 index 38cf26a4e..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@3x.png b/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@3x.png deleted file mode 100644 index ac964984c..000000000 Binary files a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/home_fab_join_room@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/Contents.json b/Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/Contents.json similarity index 70% rename from Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/Contents.json rename to Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/Contents.json index b0b8fb364..d50c95b02 100644 --- a/Riot/Assets/Images.xcassets/Home/home_fab_join_room.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/Contents.json @@ -1,17 +1,15 @@ { "images" : [ { - "filename" : "home_fab_join_room.png", + "filename" : "home_my_spaces_action.svg", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "home_fab_join_room@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "home_fab_join_room@3x.png", "idiom" : "universal", "scale" : "3x" } @@ -21,6 +19,7 @@ "version" : 1 }, "properties" : { + "preserves-vector-representation" : true, "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/home_my_spaces_action.svg b/Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/home_my_spaces_action.svg new file mode 100644 index 000000000..3b956bb6b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Home/home_my_spaces_action.imageset/home_my_spaces_action.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index b0e17ec47..7ff7f0697 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1998,6 +1998,7 @@ Tap the + to start adding people."; "spaces_add_space_title" = "Create space"; "spaces_add_subspace_title" = "Create space within %@"; "spaces_create_space_title" = "Create a space"; +"spaces_create_subspace_title" = "Create a subspace"; "spaces_left_panel_title" = "Spaces"; "leave_space_title" = "Leave %@"; "leave_space_message" = "Are you sure you want to leave %@? Do you also want to leave all rooms and spaces of this space?"; @@ -2043,6 +2044,8 @@ Tap the + to start adding people."; "spaces_creation_hint" = "Spaces are a new way to group rooms and people."; "spaces_creation_visibility_title" = "What type of space do you want to create?"; "spaces_creation_visibility_message" = "To join an existing space, you need an invite."; +"spaces_subspace_creation_visibility_title" = "What type of subspace do you want to create?"; +"spaces_subspace_creation_visibility_message" = "The created space will be added to %@."; "spaces_creation_footer" = "You can change this later"; "spaces_creation_settings_message" = "Add some details to help it stand out. You can change these at any point."; "spaces_creation_address" = "Address"; @@ -2158,7 +2161,7 @@ Tap the + to start adding people."; "all_chats_title" = "All chats"; "all_chats_section_title" = "Chats"; -"all_chats_edit_layout" = "Edit layout"; +"all_chats_edit_layout" = "Layout preferences"; "all_chats_edit_layout_recents" = "Recents"; "all_chats_edit_layout_unreads" = "Unreads"; "all_chats_edit_layout_add_section_title" = "Add section to home"; @@ -2176,9 +2179,14 @@ Tap the + to start adding people."; "room_recents_recently_viewed_section" = "Recently viewed"; +"all_chats_user_menu_settings" = "User settings"; + +"all_chats_edit_menu_leave_space" = "Leave %@"; +"all_chats_edit_menu_space_settings" = "Space settings"; + // Mark: - Space Selector -"space_selector_title" = "Choose space"; +"space_selector_title" = "My spaces"; // Mark: - Polls diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 511cdb1d3..d0373b94f 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -115,8 +115,7 @@ internal class Asset: NSObject { internal static let roomActionPriorityLow = ImageAsset(name: "room_action_priority_low") internal static let homeEmptyScreenArtwork = ImageAsset(name: "home_empty_screen_artwork") internal static let homeEmptyScreenArtworkDark = ImageAsset(name: "home_empty_screen_artwork_dark") - internal static let homeFabCreateRoom = ImageAsset(name: "home_fab_create_room") - internal static let homeFabJoinRoom = ImageAsset(name: "home_fab_join_room") + internal static let homeMySpacesAction = ImageAsset(name: "home_my_spaces_action") internal static let plusFloatingAction = ImageAsset(name: "plus_floating_action") internal static let versionCheckCloseIcon = ImageAsset(name: "version_check_close_icon") internal static let versionCheckInfoIcon = ImageAsset(name: "version_check_info_icon") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 36c46a9ac..d4323c05d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -119,7 +119,7 @@ public class VectorL10n: NSObject { public static var allChatsAllFilter: String { return VectorL10n.tr("Vector", "all_chats_all_filter") } - /// Edit layout + /// Layout preferences public static var allChatsEditLayout: String { return VectorL10n.tr("Vector", "all_chats_edit_layout") } @@ -171,6 +171,14 @@ public class VectorL10n: NSObject { public static var allChatsEditLayoutUnreads: String { return VectorL10n.tr("Vector", "all_chats_edit_layout_unreads") } + /// Leave %@ + public static func allChatsEditMenuLeaveSpace(_ p1: String) -> String { + return VectorL10n.tr("Vector", "all_chats_edit_menu_leave_space", p1) + } + /// Space settings + public static var allChatsEditMenuSpaceSettings: String { + return VectorL10n.tr("Vector", "all_chats_edit_menu_space_settings") + } /// Chats public static var allChatsSectionTitle: String { return VectorL10n.tr("Vector", "all_chats_section_title") @@ -179,6 +187,10 @@ public class VectorL10n: NSObject { public static var allChatsTitle: String { return VectorL10n.tr("Vector", "all_chats_title") } + /// User settings + public static var allChatsUserMenuSettings: String { + return VectorL10n.tr("Vector", "all_chats_user_menu_settings") + } /// Help us identify issues and improve %@ by sharing anonymous usage data. To understand how people use multiple devices, we’ll generate a random identifier, shared by your devices. public static func analyticsPromptMessageNewUser(_ p1: String) -> String { return VectorL10n.tr("Vector", "analytics_prompt_message_new_user", p1) @@ -7779,7 +7791,7 @@ public class VectorL10n: NSObject { public static var spacePublicJoinRuleDetail: String { return VectorL10n.tr("Vector", "space_public_join_rule_detail") } - /// Choose space + /// My spaces public static var spaceSelectorTitle: String { return VectorL10n.tr("Vector", "space_selector_title") } @@ -7839,6 +7851,10 @@ public class VectorL10n: NSObject { public static var spacesCreateSpaceTitle: String { return VectorL10n.tr("Vector", "spaces_create_space_title") } + /// Create a subspace + public static var spacesCreateSubspaceTitle: String { + return VectorL10n.tr("Vector", "spaces_create_subspace_title") + } /// As this space is just for you, no one will be informed. You can add more later. public static var spacesCreationAddRoomsMessage: String { return VectorL10n.tr("Vector", "spaces_creation_add_rooms_message") @@ -8071,6 +8087,14 @@ public class VectorL10n: NSObject { public static var spacesNoRoomFoundDetail: String { return VectorL10n.tr("Vector", "spaces_no_room_found_detail") } + /// The created space will be added to %@. + public static func spacesSubspaceCreationVisibilityMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_subspace_creation_visibility_message", p1) + } + /// What type of subspace do you want to create? + public static var spacesSubspaceCreationVisibilityTitle: String { + return VectorL10n.tr("Vector", "spaces_subspace_creation_visibility_title") + } /// Suggested public static var spacesSuggestedRoom: String { return VectorL10n.tr("Vector", "spaces_suggested_room") diff --git a/Riot/Managers/Theme/Themes/DarkTheme.swift b/Riot/Managers/Theme/Themes/DarkTheme.swift index fbf265729..fb4a2a3d7 100644 --- a/Riot/Managers/Theme/Themes/DarkTheme.swift +++ b/Riot/Managers/Theme/Themes/DarkTheme.swift @@ -146,8 +146,16 @@ class DarkTheme: NSObject, Theme { navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = modernScrollEdgeAppearance || BuildSettings.newAppLayoutEnabled ? nil : appearance - } else { + if BuildSettings.newAppLayoutEnabled { + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = baseColor + appearance.shadowColor = nil + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textPrimaryColor + ] + } + navigationBar.scrollEdgeAppearance = modernScrollEdgeAppearance ? nil : appearance + } else { navigationBar.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textPrimaryColor ] diff --git a/Riot/Managers/Theme/Themes/DefaultTheme.swift b/Riot/Managers/Theme/Themes/DefaultTheme.swift index 289aabf0f..d1207330a 100644 --- a/Riot/Managers/Theme/Themes/DefaultTheme.swift +++ b/Riot/Managers/Theme/Themes/DefaultTheme.swift @@ -149,7 +149,15 @@ class DefaultTheme: NSObject, Theme { ] navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = modernScrollEdgeAppearance || BuildSettings.newAppLayoutEnabled ? nil : appearance + if BuildSettings.newAppLayoutEnabled { + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = baseColor + appearance.shadowColor = nil + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: textPrimaryColor + ] + } + navigationBar.scrollEdgeAppearance = modernScrollEdgeAppearance ? nil : appearance } else { navigationBar.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textPrimaryColor diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m index 1d59793d4..29aac7808 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m @@ -183,7 +183,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou [types addObject:@(RecentsDataSourceSectionTypeSecureBackupBanner)]; } - if (self.invitesCellDataArray.count > 0) + if (!BuildSettings.newAppLayoutEnabled && self.invitesCellDataArray.count > 0) { [types addObject:@(RecentsDataSourceSectionTypeInvites)]; } @@ -229,7 +229,12 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou [types addObject:@(RecentsDataSourceSectionTypeAllChats)]; } - if (self.suggestedRoomCellDataArray.count > 0) + if (self.currentSpace == nil && BuildSettings.newAppLayoutEnabled && self.invitesCellDataArray.count > 0) + { + [types addObject:@(RecentsDataSourceSectionTypeInvites)]; + } + + if (self.currentSpace != nil && self.suggestedRoomCellDataArray.count > 0) { [types addObject:@(RecentsDataSourceSectionTypeSuggestedRooms)]; } diff --git a/Riot/Modules/Common/Recents/RecentsViewController.m b/Riot/Modules/Common/Recents/RecentsViewController.m index aeacb789e..ba23d57c4 100644 --- a/Riot/Modules/Common/Recents/RecentsViewController.m +++ b/Riot/Modules/Common/Recents/RecentsViewController.m @@ -1118,166 +1118,6 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro #pragma mark - Swipe actions -- (void)tableView:(UITableView*)tableView didEndEditingRowAtIndexPath:(NSIndexPath *)indexPath -{ - [self cancelEditionMode:isRefreshPending]; -} - -- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath -{ - return UITableViewCellEditingStyleNone; -} - -- (nullable UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath -{ - MXRoom *room = [self.dataSource getRoomAtIndexPath:indexPath]; - - if (!room) - { - return nil; - } - - // Display no action for the invited room - if (room.summary.membership == MXMembershipInvite) - { - return nil; - } - - // Store the identifier of the room related to the edited cell. - editedRoomId = room.roomId; - - UIColor *selectedColor = ThemeService.shared.theme.tintColor; - UIColor *unselectedColor = ThemeService.shared.theme.tabBarUnselectedItemTintColor; - UIColor *actionBackgroundColor = ThemeService.shared.theme.baseColor; - - NSString* title = @" "; - - // Direct chat toggle - - BOOL isDirect = room.isDirect; - - UIContextualAction *directChatAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:title - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - [self makeDirectEditedRoom:!isDirect]; - completionHandler(YES); - }]; - directChatAction.backgroundColor = actionBackgroundColor; - - UIImage *directChatImage = AssetImages.roomActionDirectChat.image; - directChatImage = [directChatImage vc_tintedImageUsingColor:isDirect ? selectedColor : unselectedColor]; - directChatAction.image = [directChatImage vc_notRenderedImage]; - - // Notification toggle - - BOOL isMuted = room.isMute || room.isMentionsOnly; - - UIContextualAction *muteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:title - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - - if ([BuildSettings showNotificationsV2]) - { - [self changeEditedRoomNotificationSettings]; - } - else - { - [self muteEditedRoomNotifications:!isMuted]; - } - - - completionHandler(YES); - }]; - muteAction.backgroundColor = actionBackgroundColor; - - UIImage *notificationImage; - if([BuildSettings showNotificationsV2] && isMuted) - { - notificationImage = AssetImages.roomActionNotificationMuted.image; - } - else - { - notificationImage = AssetImages.roomActionNotification.image; - } - - notificationImage = [notificationImage vc_tintedImageUsingColor:isMuted ? unselectedColor : selectedColor]; - muteAction.image = [notificationImage vc_notRenderedImage]; - - // Favorites management - - MXRoomTag* currentTag = nil; - - // Get the room tag (use only the first one). - if (room.accountData.tags) - { - NSArray* tags = room.accountData.tags.allValues; - if (tags.count) - { - currentTag = tags[0]; - } - } - - BOOL isFavourite = (currentTag && [kMXRoomTagFavourite isEqualToString:currentTag.name]); - - UIContextualAction *favouriteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:title - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - NSString *favouriteTag = isFavourite ? nil : kMXRoomTagFavourite; - [self updateEditedRoomTag:favouriteTag]; - completionHandler(YES); - }]; - favouriteAction.backgroundColor = actionBackgroundColor; - - UIImage *favouriteImage = AssetImages.roomActionFavourite.image; - favouriteImage = [favouriteImage vc_tintedImageUsingColor:isFavourite ? selectedColor : unselectedColor]; - favouriteAction.image = [favouriteImage vc_notRenderedImage]; - - // Priority toggle - - BOOL isInLowPriority = (currentTag && [kMXRoomTagLowPriority isEqualToString:currentTag.name]); - - UIContextualAction *priorityAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:title - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - NSString *priorityTag = isInLowPriority ? nil : kMXRoomTagLowPriority; - [self updateEditedRoomTag:priorityTag]; - completionHandler(YES); - }]; - priorityAction.backgroundColor = actionBackgroundColor; - - UIImage *priorityImage = isInLowPriority ? AssetImages.roomActionPriorityHigh.image : AssetImages.roomActionPriorityLow.image; - priorityImage = [priorityImage vc_tintedImageUsingColor:unselectedColor]; - priorityAction.image = [priorityImage vc_notRenderedImage]; - - // Leave action - - UIContextualAction *leaveAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:title - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - [self leaveEditedRoom]; - completionHandler(YES); - }]; - leaveAction.backgroundColor = actionBackgroundColor; - - UIImage *leaveImage = AssetImages.roomActionLeave.image; - leaveImage = [leaveImage vc_tintedImageUsingColor:unselectedColor]; - leaveAction.image = [leaveImage vc_notRenderedImage]; - - // Create swipe action configuration - - NSArray *actions = @[ - leaveAction, - priorityAction, - favouriteAction, - muteAction, - directChatAction - ]; - - UISwipeActionsConfiguration *swipeActionConfiguration = [UISwipeActionsConfiguration configurationWithActions:actions]; - swipeActionConfiguration.performsFirstActionWithFullSwipe = NO; - return swipeActionConfiguration; -} - - (void)leaveEditedRoom { if (editedRoomId) diff --git a/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift b/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift index e0820fa7e..c0474a17b 100644 --- a/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift +++ b/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift @@ -35,7 +35,7 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { private var invitedRoomListDataFetcher: MXRoomListDataFetcher? { switch mode { - case .home: + case .home, .allChats: return invitedRoomListDataFetcherForHome case .people: return invitedRoomListDataFetcherForPeople @@ -548,7 +548,7 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { private func createCommonRoomListDataFetcher(withDataTypes dataTypes: MXRoomSummaryDataTypes = [], onlySuggested: Bool = false, - onlyRecents: Bool = false, + onlyBreadcrumbs: Bool = false, paginate: Bool = true, strictMatches: Bool = false) -> MXRoomListDataFetcher { guard let session = session else { @@ -556,14 +556,14 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { } let filterOptions = MXRoomListDataFilterOptions(dataTypes: dataTypes, onlySuggested: onlySuggested, - onlyBreadcrumbs: onlyRecents, + onlyBreadcrumbs: onlyBreadcrumbs, query: query, space: space, showAllRoomsInHomeSpace: showAllRoomsInHomeSpace, strictMatches: strictMatches) let fetchOptions = MXRoomListDataFetchOptions(filterOptions: filterOptions, - sortOptions: onlyRecents ? noSortOptions : sortOptions, + sortOptions: onlyBreadcrumbs ? noSortOptions : sortOptions, async: true) let fetcher = session.roomListDataManager.fetcher(withOptions: fetchOptions) if paginate { @@ -653,7 +653,7 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { lowPriorityRoomListDataFetcher = createCommonRoomListDataFetcher(withDataTypes: [.lowPriority]) serverNoticeRoomListDataFetcher = createCommonRoomListDataFetcher(withDataTypes: [.serverNotice]) suggestedRoomListDataFetcher = createCommonRoomListDataFetcher(onlySuggested: true) - breadcrumbsRoomListDataFetcher = createCommonRoomListDataFetcher(onlyRecents: true) + breadcrumbsRoomListDataFetcher = createCommonRoomListDataFetcher(onlyBreadcrumbs: true) allChatsRoomListDataFetcher = createConversationRoomListDataFetcherForAllChats() fetchersCreated = true diff --git a/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift b/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift index 5d1f67190..f0c681636 100644 --- a/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift +++ b/Riot/Modules/Common/SectionHeaders/AllChatsFilterOptionListView.swift @@ -74,7 +74,7 @@ class AllChatsFilterOptionListView: UIView, Themable { // MARK: - Themable func update(theme: Theme) { - backgroundColor = theme.colors.background.withAlphaComponent(0.3) + backgroundColor = theme.colors.background.withAlphaComponent(0.7) tabListView.itemFont = theme.fonts.callout tabListView.tintColor = theme.colors.accent diff --git a/Riot/Modules/ContextMenu/ActionProviders/AllChatsActionProvider.swift b/Riot/Modules/ContextMenu/ActionProviders/AllChatsActionProvider.swift index be1ad4f8c..620d903ea 100644 --- a/Riot/Modules/ContextMenu/ActionProviders/AllChatsActionProvider.swift +++ b/Riot/Modules/ContextMenu/ActionProviders/AllChatsActionProvider.swift @@ -32,8 +32,8 @@ class AllChatsActionProvider { self.recentsAction, self.filtersAction, UIMenu(title: "", options: .displayInline, children: [ - alphabeticalOrderAction, - activityOrderAction + activityOrderAction, + alphabeticalOrderAction ]) ]) } diff --git a/Riot/Modules/ContextMenu/ActionProviders/AllChatsEditActionProvider.swift b/Riot/Modules/ContextMenu/ActionProviders/AllChatsEditActionProvider.swift new file mode 100644 index 000000000..2b2c74e4c --- /dev/null +++ b/Riot/Modules/ContextMenu/ActionProviders/AllChatsEditActionProvider.swift @@ -0,0 +1,198 @@ +// +// Copyright 2022 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 UIKit +import MatrixSDK + +enum AllChatsEditActionProviderOption { + case exploreRooms + case createRoom + case startChat + case invitePeople + case spaceMembers + case spaceSettings + case leaveSpace + case createSpace +} + +protocol AllChatsEditActionProviderDelegate: AnyObject { + func allChatsEditActionProvider(_ actionProvider: AllChatsEditActionProvider, didSelect option: AllChatsEditActionProviderOption) +} + +/// `AllChatsEditActionProvider` provides the menu for accessing edit screens according to the current parent space +class AllChatsEditActionProvider { + + // MARK: - Properties + + weak var delegate: AllChatsEditActionProviderDelegate? + + // MARK: - Private + + private var parentSpace: MXSpace? { + didSet { + parentName = parentSpace?.summary?.displayname ?? VectorL10n.spaceTag + } + } + private var parentName: String = VectorL10n.spaceTag + private var isInviteAvailable: Bool = false + private var isAddRoomAvailable: Bool = true + + // MARK: - RoomActionProviderProtocol + + var menu: UIMenu { + guard parentSpace != nil else { + return UIMenu(title: VectorL10n.allChatsTitle, children: [ + self.exploreRoomsAction, + UIMenu(title: "", options: .displayInline, children: [ + self.startChatAction, + self.createRoomAction, + self.createSpaceAction + ]) + ]) + } + + return UIMenu(title: parentName, children: [ + UIMenu(title: "", options: .displayInline, children: [ + self.spaceMembersAction, + self.exploreRoomsAction, + self.spaceSettingsAction + ]), + UIMenu(title: "", options: .displayInline, children: [ + self.invitePeopleAction, + self.createRoomAction, + self.createSpaceAction + ]), + self.leaveSpaceAction + ]) + } + + // MARK: - Public + + /// Returns an instance of the updated menu accordingly to the given parameters. + /// + /// Some menu items can be disabled depending on the required power levels of the `parentSpace`. Therefore, `updateMenu()` first returns a temporary context menu + /// with all sensible items disabled, asynchronously fetches power levels of the `parentSpace`, then gives a new instance of the menu with, potentially, all sensible items + /// enabled via the `completion` callback. + /// + /// - Parameters: + /// - session: The current `MXSession` instance + /// - parentSpace: The current parent space (`nil` for home space) + /// - completion: callback called once the power levels of the `parentSpace` have been fetched and the menu items have been computed accordingly. + /// - Returns: If the `parentSpace` is `nil`, the context menu, the temporary context menu otherwise. + func updateMenu(with session: MXSession?, parentSpace: MXSpace?, completion: @escaping (UIMenu) -> Void) -> UIMenu { + self.parentSpace = parentSpace + isInviteAvailable = false + isAddRoomAvailable = parentSpace == nil + + guard let parentSpace = parentSpace, let spaceRoom = parentSpace.room, let session = session else { + return self.menu + } + + spaceRoom.state { [weak self] roomState in + guard let self = self else { return } + + guard let powerLevels = roomState?.powerLevels, let userId = session.myUserId else { + return + } + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: userId) + + self.isInviteAvailable = userPowerLevel >= powerLevels.invite + self.isAddRoomAvailable = userPowerLevel >= parentSpace.minimumPowerLevelForAddingRoom(with: powerLevels) + + completion(self.menu) + } + + return self.menu + } + + // MARK: - Private + + private var exploreRoomsAction: UIAction { + UIAction(title: VectorL10n.spacesExploreRooms, + image: parentSpace == nil ? UIImage(systemName: "list.bullet") : UIImage(systemName: "square.fill.text.grid.1x2")) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .exploreRooms) + } + } + + private var createRoomAction: UIAction { + UIAction(title: parentSpace == nil ? VectorL10n.roomRecentsCreateEmptyRoom : VectorL10n.spacesAddRoom, + image: UIImage(systemName: "number"), + attributes: isAddRoomAvailable ? [] : .disabled) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .createRoom) + } + } + + private var startChatAction: UIAction { + UIAction(title: VectorL10n.roomRecentsStartChatWith, + image: UIImage(systemName: "person")) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .startChat) + } + } + + private var createSpaceAction: UIAction { + UIAction(title: parentSpace == nil ? VectorL10n.spacesCreateSpaceTitle : VectorL10n.spacesCreateSubspaceTitle, + image: UIImage(systemName: "plus"), + attributes: isAddRoomAvailable ? [] : .disabled) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .createSpace) + } + } + + private var invitePeopleAction: UIAction { + UIAction(title: VectorL10n.spacesInvitePeople, + image: UIImage(systemName: "person.badge.plus"), + attributes: isInviteAvailable ? [] : .disabled) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .invitePeople) + } + } + + private var spaceMembersAction: UIAction { + UIAction(title: VectorL10n.roomDetailsPeople, + image: UIImage(systemName: "person.3")) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .spaceMembers) + } + } + + private var spaceSettingsAction: UIAction { + UIAction(title: VectorL10n.allChatsEditMenuSpaceSettings, + image: UIImage(systemName: "gearshape")) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .spaceSettings) + } + } + + private var leaveSpaceAction: UIAction { + UIAction(title: VectorL10n.allChatsEditMenuLeaveSpace(parentName), + image: UIImage(systemName: "rectangle.portrait.and.arrow.right.fill"), + attributes: .destructive) { [weak self] action in + guard let self = self else { return } + + self.delegate?.allChatsEditActionProvider(self, didSelect: .leaveSpace) + } + } +} diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.swift b/Riot/Modules/Home/AllChats/AllChatsViewController.swift index 5d8f5ab6c..fe22bdb40 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.swift +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.swift @@ -25,12 +25,6 @@ class AllChatsViewController: HomeViewController { return UINib(nibName: String(describing: self), bundle: Bundle(for: self.classForCoder())) } - // MARK: - Private - - @IBOutlet private weak var toolbar: UIToolbar! - - private let searchController = UISearchController(searchResultsController: nil) - static override func instantiate() -> Self { let storyboard = UIStoryboard(name: "Main", bundle: .main) guard let viewController = storyboard.instantiateViewController(withIdentifier: "AllChatsViewController") as? Self else { @@ -39,30 +33,52 @@ class AllChatsViewController: HomeViewController { return viewController } + // MARK: - Private + + private let searchController = UISearchController(searchResultsController: nil) + + private let editActionProvider = AllChatsEditActionProvider() + + private var spaceSelectorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter? + + private var childCoordinators: [Coordinator] = [] + + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + editActionProvider.delegate = self + recentsTableView.tag = RecentsDataSourceMode.allChats.rawValue recentsTableView.clipsToBounds = false - self.tabBarController?.title = VectorL10n.allChatsTitle + updateUI() vc_setLargeTitleDisplayMode(.automatic) searchController.obscuresBackgroundDuringPresentation = false searchController.searchResultsUpdater = self + self.setupEditOptions() NotificationCenter.default.addObserver(self, selector: #selector(self.setupEditOptions), name: AllChatsLayoutSettingsManager.didUpdateSettings, object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - toolbar.tintColor = ThemeService.shared().theme.colors.accent + self.navigationController?.isToolbarHidden = false + self.navigationController?.toolbar.tintColor = ThemeService.shared().theme.colors.accent if self.tabBarController?.navigationItem.searchController == nil { self.tabBarController?.navigationItem.searchController = searchController } } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + self.navigationController?.isToolbarHidden = true + } + // MARK: - HomeViewController override var recentsDataSourceMode: RecentsDataSourceMode { @@ -70,7 +86,7 @@ class AllChatsViewController: HomeViewController { } @objc private func addFabButton() { - self.setupEditOptions() + // Nothing to do. We don't need FAB } @objc private func sections() -> Array { @@ -87,39 +103,227 @@ class AllChatsViewController: HomeViewController { ] } + // MARK: - Actions + + @objc private func showSpaceSelectorAction(sender: AnyObject) { + let currentSpaceId = self.dataSource.currentSpace?.spaceId ?? SpaceSelectorConstants.homeSpaceId + let spaceSelectorBridgePresenter = SpaceSelectorBottomSheetCoordinatorBridgePresenter(session: self.mainSession, selectedSpaceId: currentSpaceId, showHomeSpace: true) + spaceSelectorBridgePresenter.present(from: self, animated: true) + spaceSelectorBridgePresenter.delegate = self + self.spaceSelectorBridgePresenter = spaceSelectorBridgePresenter + } + + // MARK: - Toolbar animation + + private var lastScrollPosition: Double = 0 + + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + lastScrollPosition = self.recentsTableView.contentOffset.y + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + super.scrollViewDidScroll(scrollView) + + if self.recentsTableView.contentOffset.y == 0 { + self.navigationController?.setToolbarHidden(false, animated: true) + } + + guard self.recentsTableView.isDragging else { + return + } + + let scrollPosition = max(self.recentsTableView.contentOffset.y, 0) + guard scrollPosition < self.recentsTableView.contentSize.height - self.recentsTableView.bounds.height else { + return + } + + self.navigationController?.setToolbarHidden(scrollPosition - lastScrollPosition > 0, animated: true) + lastScrollPosition = scrollPosition + } + // MARK: - Private @objc private func setupEditOptions() { - // Note: updating toolbar items doesn't work as expected and has weird behaviour - // Also this piece of code is going to be updated in the next PR - - let editMenu = UIMenu(children: [ - UIAction(title: VectorL10n.roomRecentsJoinRoom, - image: Asset.Images.homeFabJoinRoom.image, - discoverabilityTitle: VectorL10n.roomRecentsJoinRoom, - handler: { [weak self] action in - self?.joinARoom() - }), - UIAction(title: VectorL10n.roomRecentsCreateEmptyRoom, - image: Asset.Images.homeFabCreateRoom.image, - discoverabilityTitle: VectorL10n.roomRecentsCreateEmptyRoom, - handler: { [weak self] action in - self?.createNewRoom() - }), - UIAction(title: VectorL10n.roomRecentsStartChatWith, - image: Asset.Images.sideMenuActionIconFeedback.image, - discoverabilityTitle: VectorL10n.roomRecentsStartChatWith, - handler: { [weak self] action in - self?.startChat() - }) - ]) - - toolbar.items = [ - UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), menu: AllChatsActionProvider().menu), - UIBarButtonItem.flexibleSpace(), - UIBarButtonItem(image: UIImage(systemName: "square.and.pencil"), menu: editMenu) - ] + self.tabBarController?.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: AllChatsActionProvider().menu) } + + private func updateUI() { + let currentSpace = self.dataSource?.currentSpace + self.tabBarController?.title = currentSpace?.summary?.displayname ?? VectorL10n.allChatsTitle + + updateToolbar(with: editActionProvider.updateMenu(with: mainSession, parentSpace: currentSpace, completion: { [weak self] menu in + self?.updateToolbar(with: menu) + })) + } + + private func updateToolbar(with menu: UIMenu) { + let currentSpace = self.dataSource?.currentSpace + self.navigationController?.isToolbarHidden = false + self.tabBarController?.setToolbarItems([ + UIBarButtonItem(image: Asset.Images.homeMySpacesAction.image, style: .done, target: self, action: #selector(self.showSpaceSelectorAction(sender: ))), + UIBarButtonItem.flexibleSpace(), + UIBarButtonItem(image: UIImage(systemName: currentSpace == nil ? "square.and.pencil" : "ellipsis.circle"), menu: menu) + ], animated: true) + } + + private func showCreateSpace(parentSpaceId: String?) { + let coordinator = SpaceCreationCoordinator(parameters: SpaceCreationCoordinatorParameters(session: self.mainSession, parentSpaceId: parentSpaceId)) + let presentable = coordinator.toPresentable() + self.present(presentable, animated: true, completion: nil) + coordinator.callback = { [weak self] result in + guard let self = self else { + return + } + + coordinator.toPresentable().dismiss(animated: true) { + self.remove(childCoordinator: coordinator) + switch result { + case .cancel: + break + case .done(let spaceId): + self.switchSpace(withId: spaceId) + } + } + } + add(childCoordinator: coordinator) + coordinator.start() + } + + private func switchSpace(withId spaceId: String?) { + searchController.isActive = false + + guard let spaceId = spaceId else { + self.dataSource.currentSpace = nil + updateUI() + + return + } + + guard let space = self.mainSession.spaceService.getSpace(withId: spaceId) else { + MXLog.warning("[AllChatsViewController] switchSpace: no space found with id \(spaceId)") + return + } + + self.dataSource.currentSpace = space + updateUI() + + self.recentsTableView.setContentOffset(.zero, animated: true) + } + + private func add(childCoordinator: Coordinator) { + self.childCoordinators.append(childCoordinator) + } + + private func remove(childCoordinator: Coordinator) { + self.childCoordinators.append(childCoordinator) + } + + private func showSpaceInvite() { + guard let session = mainSession, let spaceRoom = dataSource.currentSpace?.room else { + return + } + + let coordinator = ContactsPickerCoordinator(session: session, room: spaceRoom, initialSearchText: nil, actualParticipants: nil, invitedParticipants: nil, userParticipant: nil) + coordinator.delegate = self + coordinator.start() + add(childCoordinator: coordinator) + present(coordinator.toPresentable(), animated: true) + } + + private func showSpaceMembers() { + guard let session = mainSession, let spaceId = dataSource.currentSpace?.spaceId else { + return + } + + let coordinator = SpaceMembersCoordinator(parameters: SpaceMembersCoordinatorParameters(userSessionsService: UserSessionsService.shared, session: session, spaceId: spaceId)) + coordinator.delegate = self + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + coordinator.start() + add(childCoordinator: coordinator) + present(presentable, animated: true, completion: nil) + } + + private func showSpaceSettings() { + guard let session = mainSession, let spaceId = dataSource.currentSpace?.spaceId else { + return + } + + let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: spaceId, parentSpaceId: nil)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + + coordinator.toPresentable().dismiss(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + present(presentable, animated: true, completion: nil) + coordinator.start() + add(childCoordinator: coordinator) + } + + private func showLeaveSpace() { + guard let session = mainSession, let spaceSummary = dataSource.currentSpace?.summary else { + return + } + + let name = spaceSummary.displayname ?? VectorL10n.spaceTag + + let selectionHeader = MatrixItemChooserSelectionHeader(title: VectorL10n.leaveSpaceSelectionTitle, + selectAllTitle: VectorL10n.leaveSpaceSelectionAllRooms, + selectNoneTitle: VectorL10n.leaveSpaceSelectionNoRooms) + let paramaters = MatrixItemChooserCoordinatorParameters(session: session, + title: VectorL10n.leaveSpaceTitle(name), + detail: VectorL10n.leaveSpaceMessage(name), + selectionHeader: selectionHeader, + viewProvider: LeaveSpaceViewProvider(navTitle: nil), + itemsProcessor: LeaveSpaceItemsProcessor(spaceId: spaceSummary.roomId, session: session)) + let coordinator = MatrixItemChooserCoordinator(parameters: paramaters) + coordinator.toPresentable().presentationController?.delegate = self + coordinator.start() + add(childCoordinator: coordinator) + coordinator.completion = { [weak self] result in + coordinator.toPresentable().dismiss(animated: true) { + self?.remove(childCoordinator: coordinator) + } + } + present(coordinator.toPresentable(), animated: true) + } +} + +// MARK: - SpaceSelectorBottomSheetCoordinatorBridgePresenterDelegate +extension AllChatsViewController: SpaceSelectorBottomSheetCoordinatorBridgePresenterDelegate { + + func spaceSelectorBottomSheetCoordinatorBridgePresenterDidCancel(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter) { + self.spaceSelectorBridgePresenter = nil + } + + func spaceSelectorBottomSheetCoordinatorBridgePresenterDidSelectHome(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.spaceSelectorBridgePresenter = nil + } + + switchSpace(withId: nil) + } + + func spaceSelectorBottomSheetCoordinatorBridgePresenter(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter, didSelectSpaceWithId spaceId: String) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.spaceSelectorBridgePresenter = nil + } + + switchSpace(withId: spaceId) + } + + func spaceSelectorBottomSheetCoordinatorBridgePresenter(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter, didCreateSpaceWithinSpaceWithId parentSpaceId: String?) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.spaceSelectorBridgePresenter = nil + } + self.showCreateSpace(parentSpaceId: parentSpaceId) + } + } // MARK: - UISearchResultsUpdating @@ -135,3 +339,67 @@ extension AllChatsViewController: UISearchResultsUpdating { } } + +// MARK: - UIAdaptivePresentationControllerDelegate +extension AllChatsViewController: UIAdaptivePresentationControllerDelegate { + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard let coordinator = childCoordinators.last else { + return + } + + remove(childCoordinator: coordinator) + } +} + +// MARK: - AllChatsEditActionProviderDelegate +extension AllChatsViewController: AllChatsEditActionProviderDelegate { + + func allChatsEditActionProvider(_ actionProvider: AllChatsEditActionProvider, didSelect option: AllChatsEditActionProviderOption) { + switch option { + case .exploreRooms: + joinARoom() + case .createRoom: + createNewRoom() + case .startChat: + startChat() + case .invitePeople: + showSpaceInvite() + case .spaceMembers: + showSpaceMembers() + case .spaceSettings: + showSpaceSettings() + case .leaveSpace: + showLeaveSpace() + case .createSpace: + showCreateSpace(parentSpaceId: dataSource.currentSpace?.spaceId) + } + } + +} + +// MARK: - ContactsPickerCoordinatorDelegate +extension AllChatsViewController: ContactsPickerCoordinatorDelegate { + + func contactsPickerCoordinatorDidStartLoading(_ coordinator: ContactsPickerCoordinatorProtocol) { + } + + func contactsPickerCoordinatorDidEndLoading(_ coordinator: ContactsPickerCoordinatorProtocol) { + } + + func contactsPickerCoordinatorDidClose(_ coordinator: ContactsPickerCoordinatorProtocol) { + remove(childCoordinator: coordinator) + } + +} + +// MARK: - SpaceMembersCoordinatorDelegate +extension AllChatsViewController: SpaceMembersCoordinatorDelegate { + + func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) { + coordinator.toPresentable().dismiss(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + +} diff --git a/Riot/Modules/Home/AllChats/AllChatsViewController.xib b/Riot/Modules/Home/AllChats/AllChatsViewController.xib index f13d8779d..20803b426 100644 --- a/Riot/Modules/Home/AllChats/AllChatsViewController.xib +++ b/Riot/Modules/Home/AllChats/AllChatsViewController.xib @@ -8,14 +8,13 @@ - + - @@ -25,7 +24,7 @@ - + @@ -44,37 +43,22 @@ - - - - - - - - - + - - - - + - - - - diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index 76ad8e548..a0ffbd62c 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -265,7 +265,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { return } - let coordinator = SpaceCreationCoordinator(parameters: SpaceCreationCoordinatorParameters(session: session)) + let coordinator = SpaceCreationCoordinator(parameters: SpaceCreationCoordinatorParameters(session: session, parentSpaceId: nil)) let presentable = coordinator.toPresentable() presentable.presentationController?.delegate = self self.sideMenuViewController.present(presentable, animated: true, completion: nil) diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index b7ea062e0..f0ee0b435 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -737,6 +737,11 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { private weak var rightMenuAvatarView: AvatarView? private func createLeftButtonItem(for viewController: UIViewController) { + guard !BuildSettings.newAppLayoutEnabled else { + createAvatarButtonItem(for: viewController) + return + } + guard BuildSettings.enableSideMenu else { let settingsBarButtonItem: MXKBarButtonItem = MXKBarButtonItem(image: Asset.Images.settingsIcon.image, style: .plain) { [weak self] in self?.showSettings() @@ -756,23 +761,21 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { } private func createRightButtonItem(for viewController: UIViewController) { - guard BuildSettings.newAppLayoutEnabled else { - let searchBarButtonItem: MXKBarButtonItem = MXKBarButtonItem(image: Asset.Images.searchIcon.image, style: .plain) { [weak self] in - self?.showUnifiedSearch() - } - searchBarButtonItem.accessibilityLabel = VectorL10n.searchDefaultPlaceholder - viewController.navigationItem.rightBarButtonItem = searchBarButtonItem - + guard !BuildSettings.newAppLayoutEnabled else { return } - - createAvatarButtonItem(for: viewController) + + let searchBarButtonItem: MXKBarButtonItem = MXKBarButtonItem(image: Asset.Images.searchIcon.image, style: .plain) { [weak self] in + self?.showUnifiedSearch() + } + searchBarButtonItem.accessibilityLabel = VectorL10n.searchDefaultPlaceholder + viewController.navigationItem.rightBarButtonItem = searchBarButtonItem } private func createAvatarButtonItem(for viewController: UIViewController) { var actions: [UIMenuElement] = [] - actions.append(UIAction(title: VectorL10n.settings, image: UIImage(systemName: "gearshape")) { [weak self] action in + actions.append(UIAction(title: VectorL10n.allChatsUserMenuSettings, image: UIImage(systemName: "gearshape")) { [weak self] action in self?.showSettings() }) @@ -788,11 +791,6 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { }) actions.append(UIMenu(title: "", options: .displayInline, children: subMenuActions)) - - actions.append(UIAction(title: VectorL10n.roomAccessibilitySearch, image: UIImage(systemName: "magnifyingglass")) { [weak self] action in - self?.showUnifiedSearch() - }) - actions.append(UIMenu(title: "", options: .displayInline, children: [ UIAction(title: VectorL10n.settingsSignOut, image: UIImage(systemName: "rectangle.portrait.and.arrow.right.fill"), attributes: .destructive) { [weak self] action in self?.signOut() @@ -822,7 +820,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { avatarView.fill(with: avatar) } - viewController.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: view) + viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: view) } private func updateAvatarButtonItem() { @@ -924,6 +922,11 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { private var windowOverlay: WindowOverlayPresenter? func showCoachMessageIfNeeded(with session: MXSession) { + guard !BuildSettings.newAppLayoutEnabled else { + // Showing coach message makes no sense with the new App Layout + return + } + if !RiotSettings.shared.slideMenuRoomsCoachMessageHasBeenDisplayed { let isAuthenticated = MXKAccountManager.shared().activeAccounts.first != nil || MXKAccountManager.shared().accounts.first?.isSoftLogout == false diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index c53ee2bdc..a2f2b6602 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -56,7 +56,8 @@ enum MockAppScreens { MockTemplateSimpleScreenScreenState.self, MockTemplateUserProfileScreenState.self, MockTemplateRoomListScreenState.self, - MockTemplateRoomChatScreenState.self + MockTemplateRoomChatScreenState.self, + MockSpaceSelectorScreenState.self ] } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift index c71a085ff..12f021459 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -46,14 +46,24 @@ final class SpaceCreationCoordinator: Coordinator { // MARK: - Setup init(parameters: SpaceCreationCoordinatorParameters) { + let title: String + let message: String + if let parentSpaceId = parameters.parentSpaceId, let parentSpaceName = parameters.session.spaceService.getSpace(withId: parentSpaceId)?.summary?.displayname { + title = VectorL10n.spacesSubspaceCreationVisibilityTitle + message = VectorL10n.spacesSubspaceCreationVisibilityMessage(parentSpaceName) + } else { + title = VectorL10n.spacesCreationVisibilityTitle + message = VectorL10n.spacesCreationVisibilityMessage + } + self.parameters = parameters self.spaceVisibilityMenuParameters = SpaceCreationMenuCoordinatorParameters( session: parameters.session, creationParams: parameters.creationParameters, navTitle: VectorL10n.spacesCreateSpaceTitle, showBackButton: false, - title: VectorL10n.spacesCreationVisibilityTitle, - detail: VectorL10n.spacesCreationVisibilityMessage, + title: title, + detail: message, options: [ SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceCreationPublic.image, title: VectorL10n.public, detail: VectorL10n.spacePublicJoinRuleDetail), SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spaceCreationPrivate.image, title: VectorL10n.private, detail: VectorL10n.spacePrivateJoinRuleDetail) @@ -245,7 +255,7 @@ final class SpaceCreationCoordinator: Coordinator { } private func createPostProcessCoordinator() -> SpaceCreationPostProcessCoordinator { - let coordinator = SpaceCreationPostProcessCoordinator(parameters: SpaceCreationPostProcessCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + let coordinator = SpaceCreationPostProcessCoordinator(parameters: SpaceCreationPostProcessCoordinatorParameters(session: parameters.session, parentSpaceId: parameters.parentSpaceId, creationParams: parameters.creationParameters)) coordinator.callback = { [weak self] result in guard let self = self else { return } switch result { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift index 0106cef34..f965b81ab 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift @@ -26,6 +26,9 @@ struct SpaceCreationCoordinatorParameters { /// The Matrix session let session: MXSession + /// The identifier of the parent space. `nil` for creating a root space + let parentSpaceId: String? + /// Parameters needed to create the new space let creationParameters: SpaceCreationParameters = SpaceCreationParameters() @@ -33,8 +36,10 @@ struct SpaceCreationCoordinatorParameters { let navigationRouter: NavigationRouterType init(session: MXSession, + parentSpaceId: String?, navigationRouter: NavigationRouterType? = nil) { self.session = session + self.parentSpaceId = parentSpaceId self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift index 92848c116..f5c46f7e6 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift @@ -40,7 +40,7 @@ final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable { init(parameters: SpaceCreationPostProcessCoordinatorParameters) { self.parameters = parameters - let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessService(session: parameters.session, creationParams: parameters.creationParams)) + let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessService(session: parameters.session, parentSpaceId: parameters.parentSpaceId, creationParams: parameters.creationParams)) let view = SpaceCreationPostProcess(viewModel: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationPostProcessViewModel = viewModel diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift index be2857b70..68a99c879 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift @@ -20,5 +20,6 @@ import Foundation struct SpaceCreationPostProcessCoordinatorParameters { let session: MXSession + let parentSpaceId: String? let creationParams: SpaceCreationParameters } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index 0dae6f360..99a45c3cd 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -27,6 +27,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { // MARK: Private private let session: MXSession + private let parentSpaceId: String? private let creationParams: SpaceCreationParameters private var tasks: [SpaceCreationPostProcessTask] = [] @@ -66,8 +67,9 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { // MARK: - Setup - init(session: MXSession, creationParams: SpaceCreationParameters) { + init(session: MXSession, parentSpaceId: String?, creationParams: SpaceCreationParameters) { self.session = session + self.parentSpaceId = parentSpaceId self.creationParams = creationParams self.tasks = Self.tasks(with: creationParams) self.tasksSubject = CurrentValueSubject(tasks) @@ -168,13 +170,25 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { let userIdInvites = creationParams.inviteType == .userId ? creationParams.userIdInvites : [] session.spaceService.createSpace(withName: creationParams.name, topic: creationParams.topic, isPublic: creationParams.isPublic, aliasLocalPart: alias, inviteArray: userIdInvites) { [weak self] response in guard let self = self else { return } + if response.isFailure { self.updateCurrentTask(with: .failure) } else { self.creationParams.isModified = false self.createdSpace = response.value - self.updateCurrentTask(with: .success) - self.runNextTask() + + guard let createdSpaceId = self.createdSpace?.spaceId, let parentSpaceId = self.parentSpaceId, let parentSpace = self.session.spaceService.getSpace(withId: parentSpaceId) else { + self.updateCurrentTask(with: .success) + self.runNextTask() + return + } + + parentSpace.addChild(roomId: createdSpaceId) { [weak self] response in + guard let self = self else { return } + + self.updateCurrentTask(with: .success) + self.runNextTask() + } } } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinator.swift new file mode 100644 index 000000000..c649ed558 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinator.swift @@ -0,0 +1,152 @@ +// +// Copyright 2021 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 + +enum SpaceSelectorBottomSheetCoordinatorResult { + case cancel + case homeSelected + case spaceSelected(_ item: SpaceSelectorListItemData) + case createSpace(_ parentSpaceId: String?) +} + +struct SpaceSelectorBottomSheetCoordinatorParameters { + let session: MXSession + let selectedSpaceId: String? + let showHomeSpace: Bool + + init(session: MXSession, + selectedSpaceId: String? = nil, + showHomeSpace: Bool = false) { + self.session = session + self.selectedSpaceId = selectedSpaceId + self.showHomeSpace = showHomeSpace + } +} + +final class SpaceSelectorBottomSheetCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + private let parameters: SpaceSelectorBottomSheetCoordinatorParameters + + private let navigationRouter: NavigationRouterType + private var spaceIdStack: [String] + + private weak var roomDetailCoordinator: SpaceChildRoomDetailCoordinator? + private weak var currentSpaceSelectorCoordinator: SpaceSelectorCoordinator? + + // MARK: - Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((SpaceSelectorBottomSheetCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: SpaceSelectorBottomSheetCoordinatorParameters, + navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) { + self.parameters = parameters + self.navigationRouter = navigationRouter + self.spaceIdStack = [] + self.setupNavigationRouter() + } + + // MARK: - Public + + func start() { + pushSpace(withId: nil) + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private + + private func setupNavigationRouter() { + guard #available(iOS 15.0, *) else { return } + + guard let sheetController = self.navigationRouter.toPresentable().sheetPresentationController else { + MXLog.debug("[SpaceSelectorBottomSheetCoordinator] setup: no sheetPresentationController found") + return + } + + sheetController.detents = [.medium(), .large()] + sheetController.prefersGrabberVisible = true + sheetController.selectedDetentIdentifier = .medium + sheetController.prefersScrollingExpandsWhenScrolledToEdge = true + } + + private func createSpaceSelectorCoordinator(parentSpaceId: String?) -> SpaceSelectorCoordinator { + let parameters = SpaceSelectorCoordinatorParameters(session: parameters.session, + parentSpaceId: parentSpaceId, + selectedSpaceId: parameters.selectedSpaceId, + showHomeSpace: parameters.showHomeSpace) + let coordinator = SpaceSelectorCoordinator(parameters: parameters) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel: + self.completion?(.cancel) + case .homeSelected: + self.trackSpaceSelection(with: nil) + self.completion?(.homeSelected) + case .spaceSelected(let item): + self.trackSpaceSelection(with: item.id) + self.completion?(.spaceSelected(item)) + case .spaceDisclosure(let item): + self.pushSpace(withId: item.id) + case .createSpace(let parentSpaceId): + self.completion?(.createSpace(parentSpaceId)) + } + } + + return coordinator + } + + private func pushSpace(withId spaceId: String?) { + let coordinator = self.createSpaceSelectorCoordinator(parentSpaceId: spaceId) + + coordinator.start() + + self.add(childCoordinator: coordinator) + self.currentSpaceSelectorCoordinator = coordinator + + if let spaceId = spaceId { + self.spaceIdStack.append(spaceId) + } + + if self.navigationRouter.modules.isEmpty { + self.navigationRouter.setRootModule(coordinator) + } else { + self.navigationRouter.push(coordinator.toPresentable(), animated: true) { + self.remove(childCoordinator: coordinator) + self.spaceIdStack.removeLast() + } + } + } + + private func trackSpaceSelection(with spaceId: String?) { + guard parameters.selectedSpaceId != spaceId else { + Analytics.shared.trackInteraction(.spacePanelSelectedSpace) + return + } + + Analytics.shared.trackInteraction(.spacePanelSwitchSpace) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinatorBridgePresenter.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..6226cd7a9 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/Coordinator/SpaceSelectorBottomCoordinatorBridgePresenter.swift @@ -0,0 +1,93 @@ +// +// Copyright 2022 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 Foundation + +@objc protocol SpaceSelectorBottomSheetCoordinatorBridgePresenterDelegate { + func spaceSelectorBottomSheetCoordinatorBridgePresenterDidCancel(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter) + func spaceSelectorBottomSheetCoordinatorBridgePresenterDidSelectHome(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter) + func spaceSelectorBottomSheetCoordinatorBridgePresenter(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter, didSelectSpaceWithId spaceId: String) + func spaceSelectorBottomSheetCoordinatorBridgePresenter(_ coordinatorBridgePresenter: SpaceSelectorBottomSheetCoordinatorBridgePresenter, didCreateSpaceWithinSpaceWithId parentSpaceId: String?) +} + +/// `SpaceSelectorBottomSheetCoordinatorBridgePresenter` enables to start `SpaceSelectorBottomSheetCoordinator` from a view controller. +/// This bridge is used while waiting for global usage of coordinator pattern. +/// It breaks the Coordinator abstraction and it has been introduced for Objective-C compatibility (mainly for integration in legacy view controllers). +/// Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator. +@objcMembers +final class SpaceSelectorBottomSheetCoordinatorBridgePresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let selectedSpaceId: String? + private let showHomeSpace: Bool + private var coordinator: SpaceSelectorBottomSheetCoordinator? + + // MARK: Public + + weak var delegate: SpaceSelectorBottomSheetCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + init(session: MXSession, selectedSpaceId: String?, showHomeSpace: Bool) { + self.session = session + self.selectedSpaceId = selectedSpaceId + self.showHomeSpace = showHomeSpace + + super.init() + } + + // MARK: - Public + + func present(from viewController: UIViewController, animated: Bool) { + let parameters = SpaceSelectorBottomSheetCoordinatorParameters(session: session, selectedSpaceId: selectedSpaceId, showHomeSpace: showHomeSpace) + let coordinator = SpaceSelectorBottomSheetCoordinator(parameters: parameters) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel: + self.delegate?.spaceSelectorBottomSheetCoordinatorBridgePresenterDidCancel(self) + case .homeSelected: + self.delegate?.spaceSelectorBottomSheetCoordinatorBridgePresenterDidSelectHome(self) + case .spaceSelected(let item): + self.delegate?.spaceSelectorBottomSheetCoordinatorBridgePresenter(self, didSelectSpaceWithId: item.id) + case .createSpace(let parentSpaceId): + self.delegate?.spaceSelectorBottomSheetCoordinatorBridgePresenter(self, didCreateSpaceWithinSpaceWithId: parentSpaceId) + } + } + let presentable = coordinator.toPresentable() + viewController.present(presentable, animated: animated, completion: nil) + coordinator.start() + + self.coordinator = coordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = self.coordinator else { + return + } + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + completion?() + } + } +} + diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Coordinator/SpaceSelectorCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Coordinator/SpaceSelectorCoordinator.swift new file mode 100644 index 000000000..51766d9f5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Coordinator/SpaceSelectorCoordinator.swift @@ -0,0 +1,112 @@ +// +// Copyright 2021 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 +import CommonKit + +struct SpaceSelectorCoordinatorParameters { + let session: MXSession + let parentSpaceId: String? + let selectedSpaceId: String? + let showHomeSpace: Bool + + init(session: MXSession, + parentSpaceId: String? = nil, + selectedSpaceId: String? = nil, + showHomeSpace: Bool = false) { + self.session = session + self.parentSpaceId = parentSpaceId + self.selectedSpaceId = selectedSpaceId + self.showHomeSpace = showHomeSpace + } +} + +final class SpaceSelectorCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceSelectorCoordinatorParameters + private let hostingViewController: UIViewController + private var viewModel: SpaceSelectorViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((SpaceSelectorCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: SpaceSelectorCoordinatorParameters) { + self.parameters = parameters + let service = SpaceSelectorService(session: parameters.session, parentSpaceId: parameters.parentSpaceId, showHomeSpace: parameters.showHomeSpace, selectedSpaceId: parameters.selectedSpaceId) + let viewModel = SpaceSelectorViewModel.makeViewModel(service: service) + let view = SpaceSelector(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + self.viewModel = viewModel + let hostingViewController = VectorHostingController(rootView: view) + hostingViewController.hidesBackTitleWhenPushed = true + self.hostingViewController = hostingViewController + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: self.hostingViewController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceSelectorCoordinator] did start.") + viewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[SpaceSheetCoordinator] SpaceSelectorViewModel did complete with result: \(result).") + switch result { + case .cancel: + self.completion?(.cancel) + case .homeSelected: + self.completion?(.homeSelected) + case .spaceSelected(let item): + self.completion?(.spaceSelected(item)) + case .spaceDisclosure(let item): + self.completion?(.spaceDisclosure(item)) + case .createSpace: + self.completion?(.createSpace(self.parameters.parentSpaceId)) + } + } + } + + func toPresentable() -> UIViewController { + return self.hostingViewController + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) { + loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/MockSpaceSelectorScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/MockSpaceSelectorScreenState.swift new file mode 100644 index 000000000..261c39a90 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/MockSpaceSelectorScreenState.swift @@ -0,0 +1,60 @@ +// +// Copyright 2021 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 Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockSpaceSelectorScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case initialList + case emptyList + case selection + + /// The associated screen + var screenType: Any.Type { + SpaceSelector.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceSelectorScreenState] { + [.initialList, .emptyList, .selection] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceSelectorService + switch self { + case .initialList: + service = MockSpaceSelectorService() + case .emptyList: + service = MockSpaceSelectorService(spaceList: [MockSpaceSelectorService.homeItem]) + case .selection: + service = MockSpaceSelectorService(selectedSpaceId: MockSpaceSelectorService.defaultSpaceList[2].id) + } + let viewModel = SpaceSelectorViewModel.makeViewModel(service: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceSelector(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/MatrixSDK/SpaceSelectorService.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/MatrixSDK/SpaceSelectorService.swift new file mode 100644 index 000000000..da40b076f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/MatrixSDK/SpaceSelectorService.swift @@ -0,0 +1,92 @@ +// +// Copyright 2021 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 Foundation +import Combine +import MatrixSDK + +class SpaceSelectorService: SpaceSelectorServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let parentSpaceId: String? + private let showHomeSpace: Bool + + private var spaceList: [SpaceSelectorListItemData] { + var itemList = showHomeSpace && parentSpaceId == nil ? [SpaceSelectorListItemData(id: SpaceSelectorConstants.homeSpaceId, icon: Asset.Images.sideMenuActionIconFeedback.image, displayName: VectorL10n.allChatsTitle)] : [] + + let notificationCounter = session.spaceService.notificationCounter + + if let parentSpaceId = parentSpaceId, let parentSpace = session.spaceService.getSpace(withId: parentSpaceId) { + itemList.append(contentsOf: parentSpace.childSpaces.compactMap { space in + SpaceSelectorListItemData.itemData(with: space, notificationCounter: notificationCounter) + }) + } else { + itemList.append(contentsOf: session.spaceService.rootSpaces.compactMap { space in + SpaceSelectorListItemData.itemData(with: space, notificationCounter: notificationCounter) + }) + } + return itemList + } + + private var parentSpaceName: String? { + guard let parentSpaceId = parentSpaceId, let summary = session.roomSummary(withRoomId: parentSpaceId) else { + return nil + } + + return summary.displayname + } + + // MARK: Public + + private(set) var spaceListSubject: CurrentValueSubject<[SpaceSelectorListItemData], Never> + private(set) var parentSpaceNameSubject: CurrentValueSubject + private(set) var selectedSpaceId: String? + + // MARK: - Setup + + init(session: MXSession, parentSpaceId: String?, showHomeSpace: Bool, selectedSpaceId: String?) { + self.session = session + self.parentSpaceId = parentSpaceId + self.showHomeSpace = showHomeSpace + self.spaceListSubject = CurrentValueSubject([]) + self.parentSpaceNameSubject = CurrentValueSubject(nil) + self.selectedSpaceId = selectedSpaceId + + spaceListSubject.send(spaceList) + parentSpaceNameSubject.send(parentSpaceName) + } +} + +fileprivate extension SpaceSelectorListItemData { + static func itemData(with space: MXSpace, notificationCounter: MXSpaceNotificationCounter) -> SpaceSelectorListItemData? { + guard let summary = space.summary else { + return nil + } + + let notificationState = notificationCounter.notificationState(forSpaceWithId: space.spaceId) + + return SpaceSelectorListItemData(id:summary.roomId, + avatar: summary.room.avatarData, + displayName: summary.displayname, + notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, + highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0, + hasSubItems: !space.childSpaces.isEmpty) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/Mock/MockSpaceSelectorService.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/Mock/MockSpaceSelectorService.swift new file mode 100644 index 000000000..d5bf023b3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/Mock/MockSpaceSelectorService.swift @@ -0,0 +1,41 @@ +// +// Copyright 2021 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 Foundation +import Combine +import UIKit + +class MockSpaceSelectorService: SpaceSelectorServiceProtocol { + + static let homeItem = SpaceSelectorListItemData(id: SpaceSelectorConstants.homeSpaceId, avatar: nil, icon: UIImage(systemName: "house"), displayName: "All Chats", notificationCount: 0, highlightedNotificationCount: 0, hasSubItems: false) + static let defaultSpaceList = [ + homeItem, + SpaceSelectorListItemData(id: "!aaabaa:matrix.org", avatar: nil, icon: UIImage(systemName: "number"), displayName: "Default Space", notificationCount: 0, highlightedNotificationCount: 0, hasSubItems: false), + SpaceSelectorListItemData(id: "!zzasds:matrix.org", avatar: nil, icon: UIImage(systemName: "number"), displayName: "Space with sub items", notificationCount: 0, highlightedNotificationCount: 0, hasSubItems: true), + SpaceSelectorListItemData(id: "!scthve:matrix.org", avatar: nil, icon: UIImage(systemName: "number"), displayName: "Space with notifications", notificationCount: 55, highlightedNotificationCount: 0, hasSubItems: true), + SpaceSelectorListItemData(id: "!ferggs:matrix.org", avatar: nil, icon: UIImage(systemName: "number"), displayName: "Space with highlight", notificationCount: 99, highlightedNotificationCount: 50, hasSubItems: false) + ] + + var spaceListSubject: CurrentValueSubject<[SpaceSelectorListItemData], Never> + var parentSpaceNameSubject: CurrentValueSubject + var selectedSpaceId: String? + + init(spaceList: [SpaceSelectorListItemData] = defaultSpaceList, parentSpaceName: String? = nil, selectedSpaceId: String = SpaceSelectorConstants.homeSpaceId) { + self.spaceListSubject = CurrentValueSubject(spaceList) + self.parentSpaceNameSubject = CurrentValueSubject(parentSpaceName) + self.selectedSpaceId = selectedSpaceId + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/SpaceSelectorServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/SpaceSelectorServiceProtocol.swift new file mode 100644 index 000000000..48ebec3e6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Service/SpaceSelectorServiceProtocol.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 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 Foundation +import Combine + +protocol SpaceSelectorServiceProtocol { + var spaceListSubject: CurrentValueSubject<[SpaceSelectorListItemData], Never> { get } + var parentSpaceNameSubject: CurrentValueSubject { get } + var selectedSpaceId: String? { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorModels.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorModels.swift new file mode 100644 index 000000000..ade9a78f0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorModels.swift @@ -0,0 +1,110 @@ +// +// Copyright 2021 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 Foundation + +// MARK: - Coordinator + +enum SpaceSelectorCoordinatorResult { + /// Cancel button has been presed + case cancel + /// Home Space (aka "All Chats") has been selected -> the app should switch to the home space + case homeSelected + /// A space has been selected -> the app should switch to this space + case spaceSelected(_ item: SpaceSelectorListItemData) + /// The disclosure button of a space has been pressed -> the parent coordinator should navigate to its sub-spaces + case spaceDisclosure(_ item: SpaceSelectorListItemData) + /// The create space button has been pressed + case createSpace(_ parentSpaceId: String?) +} + +// MARK: View model + +enum SpaceSelectorConstants { + /// Arbitrary ID for the home space (aka "All Chats") + static let homeSpaceId = "SpaceSelectorListItemDataHomeSpaceId" +} + +/// This structure contains all the data to display the information about a space +struct SpaceSelectorListItemData { + /// Id of the space (`SpaceSelectorConstants.homeSpaceId` for the home space) + let id: String + /// avatar data of the space: set this property to `nil` if you want to display a space with a hardcoded icon + let avatar: AvatarInput? + /// hardcoded icon: only used if the avatar is not set + let icon: UIImage? + /// Displayname of the space + let displayName: String? + /// total number of notifications for this space + let notificationCount: UInt + /// total number of highlights for this space + let highlightedNotificationCount: UInt + /// Indicates if the space has sub spaces (condition the display of the disclosure button) + let hasSubItems: Bool + + init(id: String, + avatar: AvatarInput? = nil, + icon: UIImage? = nil, + displayName: String?, + notificationCount: UInt = 0, + highlightedNotificationCount: UInt = 0, + hasSubItems: Bool = false) { + self.id = id + self.avatar = avatar + self.icon = icon + self.displayName = displayName + self.notificationCount = notificationCount + self.highlightedNotificationCount = highlightedNotificationCount + self.hasSubItems = hasSubItems + } +} + +extension SpaceSelectorListItemData: Identifiable, Equatable {} + +enum SpaceSelectorViewModelResult { + /// Cancel button has been presed + case cancel + /// Home Space (aka "All Chats") has been selected -> the app should switch to the home space + case homeSelected + /// A space has been selected -> the app should switch to this space + case spaceSelected(_ item: SpaceSelectorListItemData) + /// The disclosure button of a space has been pressed -> the parent coordinator should navigate to its sub-spaces + case spaceDisclosure(_ item: SpaceSelectorListItemData) + /// The create space button has been pressed + case createSpace +} + +// MARK: View + +struct SpaceSelectorViewState: BindableState { + /// List of items that represents the list of sub space of the current space + var items: [SpaceSelectorListItemData] + /// Id of the currently selected space if there is a current space in the app + var selectedSpaceId: String? + /// String to be displayed as title for the navigation bar + var navigationTitle: String +} + +enum SpaceSelectorViewAction { + /// Cancel button has been presed + case cancel + /// A space has been selected + case spaceSelected(_ item: SpaceSelectorListItemData) + /// The disclosure button of a space has been pressed + case spaceDisclosure(_ item: SpaceSelectorListItemData) + /// The create space button has been pressed + case createSpace +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModel.swift new file mode 100644 index 000000000..fe3abd531 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModel.swift @@ -0,0 +1,72 @@ +// +// Copyright 2021 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 +import Combine + +typealias SpaceSelectorViewModelType = StateStoreViewModel + +class SpaceSelectorViewModel: SpaceSelectorViewModelType, SpaceSelectorViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let service: SpaceSelectorServiceProtocol + + // MARK: Public + + var completion: ((SpaceSelectorViewModelResult) -> Void)? + + // MARK: - Setup + + static func makeViewModel(service: SpaceSelectorServiceProtocol) -> SpaceSelectorViewModelProtocol { + return SpaceSelectorViewModel(service: service) + } + + private init(service: SpaceSelectorServiceProtocol) { + self.service = service + super.init(initialViewState: Self.defaultState(service: service)) + } + + private static func defaultState(service: SpaceSelectorServiceProtocol) -> SpaceSelectorViewState { + let parentName = service.parentSpaceNameSubject.value + return SpaceSelectorViewState(items: service.spaceListSubject.value, + selectedSpaceId: service.selectedSpaceId, + navigationTitle: parentName ?? VectorL10n.spaceSelectorTitle) + } + + // MARK: - Public + + override func process(viewAction: SpaceSelectorViewAction) { + switch viewAction { + case .cancel: + completion?(.cancel) + case .spaceSelected(let item): + if item.id == SpaceSelectorConstants.homeSpaceId { + completion?(.homeSelected) + } else { + completion?(.spaceSelected(item)) + } + case .spaceDisclosure(let item): + completion?(.spaceDisclosure(item)) + case .createSpace: + completion?(.createSpace) + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModelProtocol.swift new file mode 100644 index 000000000..eb53edc93 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/SpaceSelectorViewModelProtocol.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 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 Foundation + +protocol SpaceSelectorViewModelProtocol { + + var completion: ((SpaceSelectorViewModelResult) -> Void)? { get set } + static func makeViewModel(service: SpaceSelectorServiceProtocol) -> SpaceSelectorViewModelProtocol + var context: SpaceSelectorViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/UI/SpaceSelectorUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/UI/SpaceSelectorUITests.swift new file mode 100644 index 000000000..4770a7602 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/UI/SpaceSelectorUITests.swift @@ -0,0 +1,42 @@ +// +// Copyright 2021 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 XCTest +import RiotSwiftUI + +class SpaceSelectorUITests: MockScreenTestCase { + + func testInitialDisplay() { + app.goToScreenWithIdentifier(MockSpaceSelectorScreenState.initialList.title) + + let disclosureButtons = app.buttons.matching(identifier: "disclosureButton").allElementsBoundByIndex + XCTAssertEqual(disclosureButtons.count, MockSpaceSelectorService.defaultSpaceList.filter { $0.hasSubItems }.count) + + let notificationBadges = app.staticTexts.matching(identifier: "notificationBadge").allElementsBoundByIndex + let itemsWithNotifications = MockSpaceSelectorService.defaultSpaceList.filter { $0.notificationCount > 0 } + XCTAssertEqual(notificationBadges.count, itemsWithNotifications.count) + for (index, notificationBadge) in notificationBadges.enumerated() { + XCTAssertEqual("\(itemsWithNotifications[index].notificationCount)", notificationBadge.label) + } + + let spaceItemNameList = app.staticTexts.matching(identifier: "itemName").allElementsBoundByIndex + XCTAssertEqual(spaceItemNameList.count, MockSpaceSelectorService.defaultSpaceList.count) + for (index, item) in MockSpaceSelectorService.defaultSpaceList.enumerated() { + XCTAssertEqual(item.displayName, spaceItemNameList[index].label) + } + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/Unit/SpaceSelectorViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/Unit/SpaceSelectorViewModelTests.swift new file mode 100644 index 000000000..4d590a8ad --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/Test/Unit/SpaceSelectorViewModelTests.swift @@ -0,0 +1,41 @@ +// +// Copyright 2021 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 XCTest +import Combine + +@testable import RiotSwiftUI + +class SpaceSelectorViewModelTests: XCTestCase { + + var service: MockSpaceSelectorService! + var viewModel: SpaceSelectorViewModelProtocol! + var context: SpaceSelectorViewModelType.Context! + var cancellables = Set() + + override func setUpWithError() throws { + service = MockSpaceSelectorService() + viewModel = SpaceSelectorViewModel.makeViewModel(service: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.selectedSpaceId, MockSpaceSelectorService.homeItem.id) + XCTAssertEqual(context.viewState.items, MockSpaceSelectorService.defaultSpaceList) + XCTAssertEqual(context.viewState.navigationTitle, VectorL10n.spaceSelectorTitle) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelector.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelector.swift new file mode 100644 index 000000000..f87f4eff1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelector.swift @@ -0,0 +1,76 @@ +// +// Copyright 2021 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 SpaceSelector: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @ViewBuilder + private var rightButton: some View { + Button(VectorL10n.create) { + viewModel.send(viewAction: .createSpace) + } + } + + // MARK: Public + + @ObservedObject var viewModel: SpaceSelectorViewModel.Context + + var body: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.viewState.items) { item in + SpaceSelectorListRow(avatar: item.avatar, + icon: item.icon, + displayName: item.displayName, + hasSubItems: item.hasSubItems, + isSelected: item.id == viewModel.viewState.selectedSpaceId, + notificationCount: item.notificationCount, + highlightedNotificationCount: item.highlightedNotificationCount, + disclosureAction: { + viewModel.send(viewAction: .spaceDisclosure(item)) + } + ) + .onTapGesture { + viewModel.send(viewAction: .spaceSelected(item)) + } + } + } + } + .frame(maxHeight: .infinity) + .background(theme.colors.background.edgesIgnoringSafeArea(.all)) + .navigationTitle(viewModel.viewState.navigationTitle) + .navigationBarItems( + trailing: rightButton + ) + .accentColor(theme.colors.accent) + } +} + +// MARK: - Previews + +struct SpaceSelector_Previews: PreviewProvider { + static let stateRenderer = MockSpaceSelectorScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift new file mode 100644 index 000000000..611475c52 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift @@ -0,0 +1,115 @@ +// +// Copyright 2022 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 SpaceSelectorListRow: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let avatar: AvatarInputProtocol? + let icon: UIImage? + let displayName: String? + let hasSubItems: Bool + let isSelected: Bool + let notificationCount: UInt + let highlightedNotificationCount: UInt + let disclosureAction: (() -> Void)? + + @ViewBuilder + var body: some View { + ZStack { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(theme.colors.system) + .padding(.horizontal, 8) + } + VStack { + HStack { + if let avatar = avatar { + SpaceAvatarImage(avatarData: avatar, size: .xSmall) + } + if let icon = icon { + Image(uiImage: icon) + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + .frame(width: 32, height: 32) + .background(theme.colors.quinaryContent) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + Text(displayName ?? "") + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.bodySB) + .accessibility(identifier: "itemName") + Spacer() + if notificationCount > 0 { + Text("\(notificationCount)") + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.background) + .font(theme.fonts.footnote) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(highlightedNotificationCount > 0 ? theme.colors.alert : theme.colors.secondaryContent) + .clipShape(Capsule()) + .accessibility(identifier: "notificationBadge") + } + if hasSubItems { + Button { + disclosureAction?() + } label: { + Image(systemName: "chevron.right") + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + } + .accessibility(identifier: "disclosureButton") + } + } + .padding(.vertical, 8) + } + .padding(.horizontal) + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .background(theme.colors.background) + } + +} + +// MARK: - Previews + +struct SpaceSelectorListRow_Previews: PreviewProvider { + + static var previews: some View { + sampleView.theme(.light).preferredColorScheme(.light) + sampleView.theme(.dark).preferredColorScheme(.dark) + } + + static var sampleView: some View { + VStack(spacing: 8) { + SpaceSelectorListRow(avatar: nil, icon: UIImage(systemName: "house"), displayName: "Space name", hasSubItems: false, isSelected: false, notificationCount: 0, highlightedNotificationCount: 0, disclosureAction: nil) + SpaceSelectorListRow(avatar: nil, icon: UIImage(systemName: "house"), displayName: "Space name", hasSubItems: true, isSelected: false, notificationCount: 0, highlightedNotificationCount: 0, disclosureAction: nil) + SpaceSelectorListRow(avatar: nil, icon: UIImage(systemName: "house"), displayName: "Space name", hasSubItems: true, isSelected: false, notificationCount: 99, highlightedNotificationCount: 0, disclosureAction: nil) + SpaceSelectorListRow(avatar: nil, icon: UIImage(systemName: "house"), displayName: "Space name", hasSubItems: false, isSelected: false, notificationCount: 99, highlightedNotificationCount: 1, disclosureAction: nil) + SpaceSelectorListRow(avatar: nil, icon: UIImage(systemName: "house"), displayName: "Space name", hasSubItems: true, isSelected: true, notificationCount: 99, highlightedNotificationCount: 1, disclosureAction: nil) + } + } + +} diff --git a/changelog.d/6410.change b/changelog.d/6410.change new file mode 100644 index 000000000..9ea313d80 --- /dev/null +++ b/changelog.d/6410.change @@ -0,0 +1 @@ +App Layout: Implemented the new Space selector bottom sheet