diff --git a/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/Contents.json new file mode 100644 index 000000000..bebe0063f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_type_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_type_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_type_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon.png new file mode 100644 index 000000000..67a342a52 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@2x.png new file mode 100644 index 000000000..e13a3dbfc Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@3x.png new file mode 100644 index 000000000..8361c218a Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_type_icon.imageset/space_type_icon@3x.png differ diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index a95972f53..ac9035b30 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -186,6 +186,7 @@ internal enum Asset { internal static let spaceMenuMembers = ImageAsset(name: "space_menu_members") internal static let spaceMenuRooms = ImageAsset(name: "space_menu_rooms") internal static let spaceRoomIcon = ImageAsset(name: "space_room_icon") + internal static let spaceTypeIcon = ImageAsset(name: "space_type_icon") internal static let spaceUserIcon = ImageAsset(name: "space_user_icon") internal static let spacesMore = ImageAsset(name: "spaces_more") internal static let tabFavourites = ImageAsset(name: "tab_favourites") diff --git a/Riot/Generated/Storyboards.swift b/Riot/Generated/Storyboards.swift index 43d3d3429..34615dd94 100644 --- a/Riot/Generated/Storyboards.swift +++ b/Riot/Generated/Storyboards.swift @@ -249,6 +249,11 @@ internal enum StoryboardScene { internal static let initialScene = InitialSceneType(storyboard: SpaceChildRoomDetailViewController.self) } + internal enum SpaceDetailViewController: StoryboardType { + internal static let storyboardName = "SpaceDetailViewController" + + internal static let initialScene = InitialSceneType(storyboard: SpaceDetailViewController.self) + } internal enum SpaceExploreRoomViewController: StoryboardType { internal static let storyboardName = "SpaceExploreRoomViewController" diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m index 1b1c5e0c9..7812a8388 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m @@ -1289,7 +1289,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou } else if (room.summary.membership == MXMembershipInvite) { - if (!MXSDKOptions.sharedInstance.autoAcceptRoomInvites) + if (room.summary.roomType != MXRoomTypeSpace && !MXSDKOptions.sharedInstance.autoAcceptRoomInvites) { [invitesCellDataArray addObject:recentCellDataStoring]; } @@ -1301,8 +1301,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou else if (recentCellDataStoring.isSuggestedRoom) { MXRoomSummary *roomSummary = [mxSession roomSummaryWithRoomId:recentCellDataStoring.spaceChildInfo.childRoomId]; - BOOL isJoined = roomSummary.membership == MXMembershipJoin || roomSummary.membershipTransitionState == MXMembershipTransitionStateJoined; - if (!isJoined) + if (!roomSummary.isJoined) { [suggestedRoomCellDataArray addObject:recentCellDataStoring]; } @@ -1360,7 +1359,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou // Keep only the invites, the favourites and the rooms without tag and room type different from space if (room.summary.membership == MXMembershipInvite) { - if (!MXSDKOptions.sharedInstance.autoAcceptRoomInvites) + if (room.summary.roomType != MXRoomTypeSpace && !MXSDKOptions.sharedInstance.autoAcceptRoomInvites) { [invitesCellDataArray addObject:recentCellDataStoring]; } diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index 36857a3ae..550fcc233 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -60,6 +60,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { private let sideMenuViewController: SideMenuViewController let spaceMenuPresenter = SpaceMenuPresenter() + let spaceDetailPresenter = SpaceDetailPresenter() private var exploreRoomCoordinator: ExploreRoomCoordinator? private var membersCoordinator: SpaceMembersCoordinator? @@ -243,6 +244,14 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { self.spaceMenuPresenter.present(forSpaceWithId: spaceId, from: self.sideMenuViewController, sourceView: sourceView, session: session, animated: true) } + private func showSpaceDetail(forSpaceWithId spaceId: String, from sourceView: UIView?) { + guard let session = self.parameters.userSessionsService.mainUserSession?.matrixSession else { + return + } + self.spaceDetailPresenter.delegate = self + self.spaceDetailPresenter.present(forSpaceWithId: spaceId, from: self.sideMenuViewController, sourceView: sourceView, session: session, animated: true) + } + func navigate(to item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) { if item.childInfo.roomType == .space { self.exploreRoomCoordinator?.pushSpace(with: item) @@ -322,6 +331,10 @@ extension SideMenuCoordinator: SpaceListCoordinatorDelegate { self.parameters.appNavigator.navigate(to: .space(spaceId)) } + func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) { + self.showSpaceDetail(forSpaceWithId: spaceId, from: sourceView) + } + func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.showMenu(forSpaceWithId: spaceId, from: sourceView) } @@ -341,6 +354,12 @@ extension SideMenuCoordinator: SpaceMenuPresenterDelegate { } } +extension SideMenuCoordinator: SpaceDetailPresenterDelegate { + func spaceDetailPresenterDidComplete(_ presenter: SpaceDetailPresenter) { + self.spaceListCoordinator?.revertItemSelection() + } +} + // MARK: - ExploreRoomCoordinatorDelegate extension SideMenuCoordinator: ExploreRoomCoordinatorDelegate { func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType, withSelectedIem item: SpaceExploreRoomListItemViewData?, from sourceView: UIView?) { diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift new file mode 100644 index 000000000..894d99785 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift @@ -0,0 +1,111 @@ +// +// 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 + +/// Presenter for space detail screen +class SpaceDetailPresenter: NSObject { + + // MARK: - Constants + + enum Actions { + case exploreRooms + case exploreMembers + } + + // MARK: - Properties + + public weak var delegate: SpaceDetailPresenterDelegate? + + // MARK: Private + + private weak var presentingViewController: UIViewController? + private var viewModel: SpaceDetailViewModel! + private weak var sourceView: UIView? + private lazy var slidingModalPresenter: SlidingModalPresenter = { + return SlidingModalPresenter() + }() + private weak var selectedSpace: MXSpace? + private var session: MXSession! + private var spaceId: String! + + // MARK: - Public + + func present(forSpaceWithId spaceId: String, + from viewController: UIViewController, + sourceView: UIView?, + session: MXSession, + animated: Bool) { + self.session = session + self.spaceId = spaceId + + self.viewModel = SpaceDetailViewModel(session: session, spaceId: spaceId) + self.viewModel.coordinatorDelegate = self + self.presentingViewController = viewController + self.sourceView = sourceView + self.selectedSpace = session.spaceService.getSpace(withId: spaceId) + + self.show(with: session) + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + self.presentingViewController?.dismiss(animated: animated, completion: completion) + } + + // MARK: - Private + + private func show(with session: MXSession) { + let viewController = SpaceDetailViewController.instantiate(mediaManager: session.mediaManager, viewModel: self.viewModel) + self.present(viewController, animated: true) + } + + private func present(_ viewController: SpaceDetailViewController, animated: Bool) { + + if UIDevice.current.isPhone { + guard let rootViewController = self.presentingViewController else { + MXLog.error("[SpaceDetailPresenter] present no rootViewController found") + return + } + + slidingModalPresenter.present(viewController, from: rootViewController.presentedViewController ?? rootViewController, animated: true, completion: nil) + } else { + // Configure source view when view controller is presented with a popover + viewController.modalPresentationStyle = .popover + if let sourceView = self.sourceView, let popoverPresentationController = viewController.popoverPresentationController { + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + } + + self.presentingViewController?.present(viewController, animated: animated, completion: nil) + } + } +} + +// MARK: - SpaceDetailModelViewModelCoordinatorDelegate + +extension SpaceDetailPresenter: SpaceDetailModelViewModelCoordinatorDelegate { + func spaceDetailViewModelDidCancel(_ viewModel: SpaceDetailViewModelType) { + self.dismiss(animated: true, completion: nil) + } + + func spaceDetailViewModelDidDismiss(_ viewModel: SpaceDetailViewModelType) { + self.delegate?.spaceDetailPresenterDidComplete(self) + } +} + +protocol SpaceDetailPresenterDelegate: AnyObject { + func spaceDetailPresenterDidComplete(_ presenter: SpaceDetailPresenter) +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift new file mode 100644 index 000000000..926afb087 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift @@ -0,0 +1,26 @@ +// +// 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 + +/// `SpaceDetailViewController` view actions exposed to view model +enum SpaceDetailViewAction { + case loadData + case join + case leave + case dismiss + case dismissed +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.storyboard b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.storyboard new file mode 100644 index 000000000..7016df505 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.storyboard @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.swift new file mode 100644 index 000000000..3313b0d38 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.swift @@ -0,0 +1,273 @@ +// +// 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 + + +class SpaceDetailViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let popoverWidth: CGFloat = 320 + static let topicMaxHeight: CGFloat = 105 + } + + // MARK: Private + + private var theme: Theme! + private var mediaManager: MXMediaManager! + private var viewModel: SpaceDetailViewModelType! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + // MARK: Outlets + + @IBOutlet private weak var inviterPanelHeight: NSLayoutConstraint! + @IBOutlet private weak var inviterAvatarView: RoomAvatarView! + @IBOutlet private weak var inviterTitleLabel: UILabel! + @IBOutlet private weak var inviterIdLabel: UILabel! + @IBOutlet private weak var inviterSeparatorView: UIView! + + @IBOutlet private weak var avatarView: SpaceAvatarView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var closeButton: UIButton! + @IBOutlet private weak var spaceTypeIconView: UIImageView! + @IBOutlet private weak var spaceTypeLabel: UILabel! + @IBOutlet private weak var topicLabel: UILabel! + @IBOutlet private weak var topicScrollView: UIScrollView! + + @IBOutlet private weak var joinButtonTopMargin: NSLayoutConstraint! + @IBOutlet private weak var joinButtonBottomMargin: NSLayoutConstraint! + @IBOutlet private weak var joinButton: UIButton! + @IBOutlet private weak var declineButton: UIButton! + @IBOutlet private weak var acceptButton: UIButton! + @IBOutlet private weak var inviteActionPanel: UIView! + + // MARK: - Setup + + class func instantiate(mediaManager: MXMediaManager, viewModel: SpaceDetailViewModelType!) -> SpaceDetailViewController { + let viewController = StoryboardScene.SpaceDetailViewController.initialScene.instantiate() + viewController.mediaManager = mediaManager + viewController.viewModel = viewModel + viewController.theme = ThemeService.shared().theme + return viewController + } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + + self.setupViews() + self.activityPresenter = ActivityIndicatorPresenter() + self.errorPresenter = MXKErrorAlertPresentation() + + self.registerThemeServiceDidChangeThemeNotification() + self.update(theme: self.theme) + + self.viewModel.viewDelegate = self + self.viewModel.process(viewAction: .loadData) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + override var preferredContentSize: CGSize { + get { + return CGSize(width: Constants.popoverWidth, height: self.intrisicHeight(with: Constants.popoverWidth)) + } + set { + super.preferredContentSize = newValue + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + self.viewModel.process(viewAction: .dismissed) + } + + // MARK: - IBActions + + @IBAction private func closeAction(sender: UIButton) { + self.viewModel.process(viewAction: .dismiss) + } + + @IBAction private func joinAction(sender: UIButton) { + self.viewModel.process(viewAction: .join) + } + + @IBAction private func leaveAction(sender: UIButton) { + self.viewModel.process(viewAction: .leave) + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.colors.background + + self.inviterAvatarView.update(theme: theme) + self.inviterTitleLabel.textColor = theme.colors.secondaryContent + self.inviterTitleLabel.font = theme.fonts.calloutSB + self.inviterIdLabel.textColor = theme.colors.secondaryContent + self.inviterIdLabel.font = theme.fonts.footnote + self.inviterSeparatorView.backgroundColor = theme.colors.navigation + + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.title3SB + self.closeButton.backgroundColor = theme.roomInputTextBorder + self.closeButton.tintColor = theme.noticeSecondaryColor + self.avatarView.update(theme: theme) + + self.spaceTypeIconView.tintColor = theme.colors.tertiaryContent + self.spaceTypeLabel.font = theme.fonts.callout + self.spaceTypeLabel.textColor = theme.colors.tertiaryContent + self.topicLabel.font = theme.fonts.caption1 + self.topicLabel.textColor = theme.colors.tertiaryContent + + apply(theme: theme, on: self.joinButton) + apply(theme: theme, on: self.declineButton) + apply(theme: theme, on: self.acceptButton) + } + + private func apply(theme: Theme, on button: UIButton) { + button.backgroundColor = theme.colors.accent + button.tintColor = theme.colors.background + button.setTitleColor(theme.colors.background, for: .normal) + button.titleLabel?.font = theme.fonts.bodySB + } + + private func registerThemeServiceDidChangeThemeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil) + } + + @objc private func themeDidChange() { + self.update(theme: ThemeService.shared().theme) + } + + private func setupViews() { + self.closeButton.layer.masksToBounds = true + self.closeButton.layer.cornerRadius = self.closeButton.bounds.height / 2 + + self.setup(button: self.joinButton, withTitle: VectorL10n.join) + self.setup(button: self.acceptButton, withTitle: VectorL10n.accept) + self.setup(button: self.declineButton, withTitle: VectorL10n.decline) + } + + private func setup(button: UIButton, withTitle title: String) { + button.layer.masksToBounds = true + button.layer.cornerRadius = 8.0 + button.setTitle(title.uppercased(), for: .normal) + } + + private func render(viewState: SpaceDetailViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let space, let joinRule, let inviterId, let inviter, let membersCount): + self.renderLoaded(space: space, joinRule: joinRule, inviterId: inviterId, inviter: inviter, membersCount: membersCount) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(space: MXSpace, joinRule: MXRoomJoinRule?, inviterId: String?, inviter: MXUser?, membersCount: UInt) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + + guard let summary = space.summary else { + MXLog.error("[SpaceDetailViewController] setupViews: no summary found") + return + } + + if summary.membership != .invite { + self.inviterPanelHeight.constant = 0 + } else { + self.joinButton.isHidden = true + self.inviteActionPanel.isHidden = false + } + + let avatarViewData = AvatarViewData(avatarUrl: summary.avatar, mediaManager: self.mediaManager, fallbackImage: .matrixItem(summary.roomId, summary.displayname)) + + self.titleLabel.text = summary.displayname + self.avatarView.fill(with: avatarViewData) + self.topicLabel.text = summary.topic + + var joinRuleString = "" + switch joinRule { + case .invite: joinRuleString = "invite" + case .knock: joinRuleString = "knock" + case .none: joinRuleString = "none" + case .private: joinRuleString = "private" + case .public: joinRuleString = "public" + } + + let membersCount = summary.membersCount.members + let membersString = membersCount == 1 ? VectorL10n.roomTitleOneMember : VectorL10n.roomTitleMembers("\(membersCount)") + self.spaceTypeLabel.text = "\(joinRuleString) ยท \(membersString)" + + self.inviterIdLabel.text = inviterId + if let inviterId = inviterId { + self.inviterTitleLabel.text = "\(inviter?.displayname ?? inviterId) invited you" + + if let inviter = inviter { + let avatarViewData = AvatarViewData(avatarUrl: inviter.avatarUrl, mediaManager: self.mediaManager, fallbackImage: .matrixItem(inviter.userId, inviter.displayname)) + self.inviterAvatarView.fill(with: avatarViewData) + } + } + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + private func intrisicHeight(with width: CGFloat) -> CGFloat { + let topicHeight = min(self.topicLabel.sizeThatFits(CGSize(width: width - self.topicScrollView.frame.minX * 2, height: 0)).height, Constants.topicMaxHeight) + return self.topicScrollView.frame.minY + topicHeight + self.joinButton.frame.height + self.joinButtonTopMargin.constant + self.joinButtonBottomMargin.constant + } +} + +// MARK: - SlidingModalPresentable + +extension SpaceDetailViewController: SlidingModalPresentable { + + func allowsDismissOnBackgroundTap() -> Bool { + return true + } + + func layoutHeightFittingWidth(_ width: CGFloat) -> CGFloat { + return self.intrisicHeight(with: width) + self.joinButtonTopMargin.constant + self.joinButtonBottomMargin.constant + } + +} + +// MARK: - SpaceDetailViewModelViewDelegate + +extension SpaceDetailViewController: SpaceDetailViewModelViewDelegate { + func spaceDetailViewModel(_ viewModel: SpaceDetailViewModelType, didUpdateViewState viewSate: SpaceDetailViewState) { + self.render(viewState: viewSate) + } +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModel.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModel.swift new file mode 100644 index 000000000..488921e48 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModel.swift @@ -0,0 +1,114 @@ +// +// 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 + +/// View model used by `SpaceDetailViewController` +class SpaceDetailViewModel: SpaceDetailViewModelType { + + // MARK: - Properties + + weak var coordinatorDelegate: SpaceDetailModelViewModelCoordinatorDelegate? + weak var viewDelegate: SpaceDetailViewModelViewDelegate? + + private let session: MXSession + private let spaceId: String + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + } + + // MARK: - Public + + func process(viewAction: SpaceDetailViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .join: + self.join() + case .leave: + self.leave() + case .dismiss: + self.coordinatorDelegate?.spaceDetailViewModelDidCancel(self) + case .dismissed: + self.coordinatorDelegate?.spaceDetailViewModelDidDismiss(self) + } + } + + // MARK: - Private + + private func update(viewState: SpaceDetailViewState) { + self.viewDelegate?.spaceDetailViewModel(self, didUpdateViewState: viewState) + } + + private func loadData() { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId), let summary = space.summary else { + MXLog.error("[SpaceDetailViewModel] setupViews: no space found") + return + } + + self.update(viewState: .loaded(space, nil, nil, nil, 0)) + + self.update(viewState: .loading) + space.room.state { state in + let joinRule = state?.joinRule + let membersCount = summary.membersCount.members + + var inviterId: String? + var inviter: MXUser? + state?.stateEvents.forEach({ event in + if event.wireEventType == .roomMember && event.stateKey == self.session.myUserId { + guard let userId = event.sender else { + return + } + inviterId = userId + inviter = self.session.user(withUserId: userId) + } + }) + + self.update(viewState: .loaded(space, joinRule, inviterId, inviter, membersCount)) + } + } + + private func join() { + self.update(viewState: .loading) + self.session.joinRoom(self.spaceId) { [weak self] (response) in + guard let self = self else { return } + switch response { + case .success: + self.process(viewAction: .dismiss) + case .failure(let error): + self.update(viewState: .error(error)) + } + } + } + + private func leave() { + self.update(viewState: .loading) + self.session.leaveRoom(self.spaceId) { [weak self] (response) in + guard let self = self else { return } + switch response { + case .success: + self.process(viewAction: .dismiss) + case .failure(let error): + self.update(viewState: .error(error)) + } + } + } +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModelType.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModelType.swift new file mode 100644 index 000000000..3d54e49ed --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModelType.swift @@ -0,0 +1,35 @@ +// +// 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 SpaceDetailViewModelViewDelegate: AnyObject { + func spaceDetailViewModel(_ viewModel: SpaceDetailViewModelType, didUpdateViewState viewSate: SpaceDetailViewState) +} + +protocol SpaceDetailModelViewModelCoordinatorDelegate: AnyObject { + func spaceDetailViewModelDidCancel(_ viewModel: SpaceDetailViewModelType) + func spaceDetailViewModelDidDismiss(_ viewModel: SpaceDetailViewModelType) +} + +/// Protocol describing the view model used by `SpaceDetailViewController` +protocol SpaceDetailViewModelType { + + var viewDelegate: SpaceDetailViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceDetailModelViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceDetailViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewState.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewState.swift new file mode 100644 index 000000000..c448b4c9d --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewState.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 + +/// SpaceDetailViewController view state +enum SpaceDetailViewState { + case loading + case loaded(_ space: MXSpace, _ joinRule: MXRoomJoinRule?, _ inviterId: String?, _ inviter: MXUser?, _ membersCount: UInt) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift index 9aa281fe2..d9e3fa67a 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift @@ -57,6 +57,10 @@ final class SpaceListCoordinator: SpaceListCoordinatorType { func toPresentable() -> UIViewController { return self.spaceListViewController } + + func revertItemSelection() { + self.spaceListViewModel.revertItemSelection() + } } // MARK: - SpaceListViewModelCoordinatorDelegate @@ -70,6 +74,10 @@ extension SpaceListCoordinator: SpaceListViewModelCoordinatorDelegate { self.delegate?.spaceListCoordinator(self, didSelectSpaceWithId: spaceId) } + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) { + self.delegate?.spaceListCoordinator(self, didSelectInviteWithId: spaceId, from: sourceView) + } + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.delegate?.spaceListCoordinator(self, didPressMoreForSpaceWithId: spaceId, from: sourceView) } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift index f5cbc64ca..f7f05f8ac 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift @@ -21,10 +21,12 @@ import Foundation protocol SpaceListCoordinatorDelegate: AnyObject { func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectSpaceWithId spaceId: String) + func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) } /// `SpaceListCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. protocol SpaceListCoordinatorType: Coordinator, Presentable { var delegate: SpaceListCoordinatorDelegate? { get } + func revertItemSelection() } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift b/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift index 6912e9444..76a168fb4 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift @@ -21,4 +21,5 @@ struct SpaceListItemViewData { let spaceId: String let title: String let avatarViewData: AvatarViewDataProtocol + let isInvite: Bool } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift index a0f210b4c..3db44f5f4 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift @@ -21,6 +21,6 @@ import Foundation /// SpaceListViewController view actions exposed to view model enum SpaceListViewAction { case loadData - case selectRow(at: IndexPath) + case selectRow(at: IndexPath, from: UIView?) case moreAction(at: IndexPath, from: UIView) } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift index 9366d2a8c..513bbccec 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift @@ -29,6 +29,7 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { @IBOutlet private weak var titleLabel: UILabel! @IBOutlet private weak var selectionView: UIView! @IBOutlet private weak var moreButton: UIButton! + @IBOutlet private weak var badgeLabel: BadgeLabel! public weak var delegate: SpaceListViewCellDelegate? @@ -56,7 +57,15 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { func fill(with viewData: SpaceListItemViewData) { self.avatarView.fill(with: viewData.avatarViewData) self.titleLabel.text = viewData.title - self.moreButton.isHidden = viewData.spaceId == SpaceListViewModel.Constants.homeSpaceId + self.moreButton.isHidden = viewData.spaceId == SpaceListViewModel.Constants.homeSpaceId || viewData.isInvite + if viewData.isInvite { + self.badgeLabel.isHidden = false + self.badgeLabel.badgeColor = ThemeService.shared().theme.colors.alert + self.badgeLabel.borderColor = ThemeService.shared().theme.colors.background + self.badgeLabel.text = "!" + } else { + self.badgeLabel.isHidden = true + } } func update(theme: Theme) { diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib index 6494a7fe1..facf89a8c 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib @@ -30,6 +30,17 @@ +