diff --git a/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/Contents.json new file mode 100644 index 000000000..609c747aa --- /dev/null +++ b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "side_menu_notif_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "side_menu_notif_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "side_menu_notif_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon.png b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon.png new file mode 100644 index 000000000..107f1974f Binary files /dev/null and b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon.png differ diff --git a/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@2x.png b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@2x.png new file mode 100644 index 000000000..4774e3f50 Binary files /dev/null and b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@3x.png b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@3x.png new file mode 100644 index 000000000..4370b7d2e Binary files /dev/null and b/Riot/Assets/Images.xcassets/SideMenu/side_menu_notif_icon.imageset/side_menu_notif_icon@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json new file mode 100644 index 000000000..9b395447b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_home_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_home_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_home_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png new file mode 100644 index 000000000..75af27227 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png new file mode 100644 index 000000000..7c4243805 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png new file mode 100644 index 000000000..1076124e2 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/Contents.json new file mode 100644 index 000000000..80b50680c --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_menu_close.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_menu_close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_menu_close@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_menu_close.imageset/space_menu_close.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close.png new file mode 100644 index 000000000..0061d762c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@2x.png new file mode 100644 index 000000000..c12787397 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@3x.png new file mode 100644 index 000000000..95217f24c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_close.imageset/space_menu_close@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/Contents.json new file mode 100644 index 000000000..f978b29cb --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_menu_leave.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_menu_leave@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_menu_leave@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_menu_leave.imageset/space_menu_leave.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave.png new file mode 100644 index 000000000..d0912e8a9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@2x.png new file mode 100644 index 000000000..efb27f64d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@3x.png new file mode 100644 index 000000000..9520be186 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_leave.imageset/space_menu_leave@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/Contents.json new file mode 100644 index 000000000..fe443be4e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_menu_members.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_menu_members@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_menu_members@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_menu_members.imageset/space_menu_members.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members.png new file mode 100644 index 000000000..6301462a5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@2x.png new file mode 100644 index 000000000..6c47dda76 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@3x.png new file mode 100644 index 000000000..9d7abd9f2 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_members.imageset/space_menu_members@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/Contents.json new file mode 100644 index 000000000..0f8ee6eb6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_menu_rooms.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_menu_rooms@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_menu_rooms@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_menu_rooms.imageset/space_menu_rooms.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms.png new file mode 100644 index 000000000..19dacc850 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@2x.png new file mode 100644 index 000000000..30f6aa9be Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@3x.png new file mode 100644 index 000000000..4e5ea7f34 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_menu_rooms.imageset/space_menu_rooms@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/Contents.json new file mode 100644 index 000000000..cb13835ce --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_room_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_room_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_room_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_room_icon.imageset/space_room_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon.png new file mode 100644 index 000000000..ab35d1e4e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@2x.png new file mode 100644 index 000000000..d6e6caee7 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@3x.png new file mode 100644 index 000000000..a42b73d91 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_room_icon.imageset/space_room_icon@3x.png differ 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/Assets/Images.xcassets/Spaces/space_user_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/Contents.json new file mode 100644 index 000000000..6fed9b760 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_user_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_user_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_user_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_user_icon.imageset/space_user_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon.png new file mode 100644 index 000000000..05f37704d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@2x.png new file mode 100644 index 000000000..452651076 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@3x.png new file mode 100644 index 000000000..7ebc9b1c7 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_user_icon.imageset/space_user_icon@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/Contents.json new file mode 100644 index 000000000..c6cfd0cb7 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "spaces_more.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more.png b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more.png new file mode 100644 index 000000000..38be792b9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@2x.png new file mode 100644 index 000000000..d52cdd258 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@3x.png new file mode 100644 index 000000000..505b25f4e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_more.imageset/spaces_more@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 3f32ac1dc..e0eb0c489 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -63,6 +63,7 @@ "switch" = "Switch"; "more" = "More"; "less" = "Less"; +"open" = "Open"; "done" = "Done"; // Call Bar @@ -190,6 +191,7 @@ "room_recents_low_priority_section" = "LOW PRIORITY"; "room_recents_server_notice_section" = "SYSTEM ALERTS"; "room_recents_invites_section" = "INVITES"; +"room_recents_suggested_rooms_section" = "SUGGESTED ROOMS"; "room_recents_start_chat_with" = "Start chat"; "room_recents_create_empty_room" = "Create room"; "room_recents_join_room" = "Join room"; @@ -1695,6 +1697,36 @@ Tap the + to start adding people."; "space_beta_announce_subtitle" = "The new version of communities"; "space_beta_announce_information" = "Spaces are a new way to group rooms and people. They’re not on iOS yet, but you can use them now on Web and Desktop."; +"spaces_home_space_title" = "Home"; +"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?"; +"leave_space_message_admin_warning" = "You are admin of this space, ensure that you have transferred admin right to another member before leaving."; +"leave_space_only_action" = "Don't leave any rooms"; +"leave_space_and_all_rooms_action" = "Leave all rooms and spaces"; +"spaces_explore_rooms" = "Explore rooms"; +"spaces_suggested_room" = "Suggested"; +"space_tag" = "space"; +"spaces_empty_space_title" = "This space has no rooms (yet)"; +"spaces_empty_space_detail" = "Some rooms may be hidden because they’re private and you need an invite."; +"spaces_no_result_found_title" = "No results found"; +"spaces_no_room_found_detail" = "Some results may be hidden because they’re private and you need an invite to join them."; +"spaces_no_member_found_detail" = "Looking for someone not in %@? For now, you can invite them on web or desktop."; +"spaces_coming_soon_title" = "Coming soon"; +"spaces_add_rooms_coming_soon_title" = "Adding rooms coming soon"; +"spaces_invites_coming_soon_title" = "Invites coming soon"; +"spaces_coming_soon_detail" = "This feature hasn’t been implemented here, but it’s on the way. For now, you can do that with Element on your computer."; +"space_participants_action_remove" = "Remove from this space"; +"space_participants_action_ban" = "Ban from this space"; + +"space_private_join_rule" = "Private space"; +"space_public_join_rule" = "Public space"; + +// Mark: Avatar + +"space_avatar_view_accessibility_label" = "avatar"; +"space_avatar_view_accessibility_hint" = "Change space avatar"; + // Mark: - User avatar view "user_avatar_view_accessibility_label" = "avatar"; diff --git a/Riot/Categories/MXKImageView.swift b/Riot/Categories/MXKImageView.swift new file mode 100644 index 000000000..5f32960ed --- /dev/null +++ b/Riot/Categories/MXKImageView.swift @@ -0,0 +1,32 @@ +// +// 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 + +extension MXKImageView { + @objc func vc_setRoomAvatarImage(with url: String?, displayName: String, mediaManager: MXMediaManager) { + // Use the display name to prepare the default avatar image. + let avatarImage = AvatarGenerator.generateAvatar(forText: displayName) + + if let avatarUrl = url { + self.enableInMemoryCache = true + self.setImageURI(avatarUrl, withType: nil, andImageOrientation: .up, toFitViewSize: self.frame.size, with: MXThumbnailingMethodCrop, previewImage: avatarImage, mediaManager: mediaManager) + } else { + self.image = avatarImage + } + self.contentMode = .scaleAspectFill + } +} diff --git a/Riot/Categories/MXRoomSummary+Riot.h b/Riot/Categories/MXRoomSummary+Riot.h index c69617bf5..0ae7c7359 100644 --- a/Riot/Categories/MXRoomSummary+Riot.h +++ b/Riot/Categories/MXRoomSummary+Riot.h @@ -32,6 +32,8 @@ typedef NS_ENUM(NSUInteger, RoomEncryptionTrustLevel) { */ @interface MXRoomSummary (Riot) +@property(nonatomic, readonly) BOOL isJoined; + /** Set the room avatar in the dedicated MXKImageView. The riot style implies to use in order : diff --git a/Riot/Categories/MXRoomSummary+Riot.m b/Riot/Categories/MXRoomSummary+Riot.m index 688f9d2c8..961a51a6e 100644 --- a/Riot/Categories/MXRoomSummary+Riot.m +++ b/Riot/Categories/MXRoomSummary+Riot.m @@ -77,4 +77,9 @@ return roomEncryptionTrustLevel; } +- (BOOL)isJoined +{ + return self.membership == MXMembershipJoin || self.membershipTransitionState == MXMembershipTransitionStateJoined; +} + @end diff --git a/Riot/Categories/UIView.swift b/Riot/Categories/UIView.swift index b36d73bb6..47b99e93d 100644 --- a/Riot/Categories/UIView.swift +++ b/Riot/Categories/UIView.swift @@ -32,6 +32,16 @@ extension UIView { } } + /// Add a subview matching the safe area of the parent view using autolayout + @objc func vc_addSubViewMatchingParentSafeArea(_ subView: UIView) { + self.addSubview(subView) + subView.translatesAutoresizingMaskIntoConstraints = false + subView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor).isActive = true + subView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor).isActive = true + subView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor).isActive = true + subView.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor).isActive = true + } + @objc func vc_removeAllSubviews() { for subView in self.subviews { subView.removeFromSuperview() diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 0d0b9a9ab..2af5367f5 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -184,8 +184,18 @@ internal enum Asset { internal static let sideMenuActionIconSettings = ImageAsset(name: "side_menu_action_icon_settings") internal static let sideMenuActionIconShare = ImageAsset(name: "side_menu_action_icon_share") internal static let sideMenuIcon = ImageAsset(name: "side_menu_icon") + internal static let sideMenuNotifIcon = ImageAsset(name: "side_menu_notif_icon") internal static let featureUnavaibleArtwork = ImageAsset(name: "feature_unavaible_artwork") internal static let featureUnavaibleArtworkDark = ImageAsset(name: "feature_unavaible_artwork_dark") + internal static let spaceHomeIcon = ImageAsset(name: "space_home_icon") + internal static let spaceMenuClose = ImageAsset(name: "space_menu_close") + internal static let spaceMenuLeave = ImageAsset(name: "space_menu_leave") + 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") internal static let tabGroups = ImageAsset(name: "tab_groups") internal static let tabHome = ImageAsset(name: "tab_home") diff --git a/Riot/Generated/Storyboards.swift b/Riot/Generated/Storyboards.swift index 0b77710ce..34615dd94 100644 --- a/Riot/Generated/Storyboards.swift +++ b/Riot/Generated/Storyboards.swift @@ -244,11 +244,36 @@ internal enum StoryboardScene { internal static let initialScene = InitialSceneType(storyboard: SimpleScreenTemplateViewController.self) } + internal enum SpaceChildRoomDetailViewController: StoryboardType { + internal static let storyboardName = "SpaceChildRoomDetailViewController" + + 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" + + internal static let initialScene = InitialSceneType(storyboard: SpaceExploreRoomViewController.self) + } internal enum SpaceFeatureUnaivableViewController: StoryboardType { internal static let storyboardName = "SpaceFeatureUnaivableViewController" internal static let initialScene = InitialSceneType(storyboard: SpaceFeatureUnaivableViewController.self) } + internal enum SpaceListViewController: StoryboardType { + internal static let storyboardName = "SpaceListViewController" + + internal static let initialScene = InitialSceneType(storyboard: SpaceListViewController.self) + } + internal enum SpaceMenuViewController: StoryboardType { + internal static let storyboardName = "SpaceMenuViewController" + + internal static let initialScene = InitialSceneType(storyboard: SpaceMenuViewController.self) + } internal enum TemplateScreenViewController: StoryboardType { internal static let storyboardName = "TemplateScreenViewController" diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 83c91f122..6891446ae 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2083,6 +2083,26 @@ public class VectorL10n: NSObject { public static var leave: String { return VectorL10n.tr("Vector", "leave") } + /// Leave all rooms and spaces + public static var leaveSpaceAndAllRoomsAction: String { + return VectorL10n.tr("Vector", "leave_space_and_all_rooms_action") + } + /// Are you sure you want to leave %@? Do you also want to leave all rooms and spaces of this space? + public static func leaveSpaceMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "leave_space_message", p1) + } + /// You are admin of this space, ensure that you have transferred admin right to another member before leaving. + public static var leaveSpaceMessageAdminWarning: String { + return VectorL10n.tr("Vector", "leave_space_message_admin_warning") + } + /// Don't leave any rooms + public static var leaveSpaceOnlyAction: String { + return VectorL10n.tr("Vector", "leave_space_only_action") + } + /// Leave %@ + public static func leaveSpaceTitle(_ p1: String) -> String { + return VectorL10n.tr("Vector", "leave_space_title", p1) + } /// Less public static var less: String { return VectorL10n.tr("Vector", "less") @@ -2191,6 +2211,10 @@ public class VectorL10n: NSObject { public static var on: String { return VectorL10n.tr("Vector", "on") } + /// Open + public static var `open`: String { + return VectorL10n.tr("Vector", "open") + } /// or public static var or: String { return VectorL10n.tr("Vector", "or") @@ -3419,6 +3443,10 @@ public class VectorL10n: NSObject { public static var roomRecentsStartChatWith: String { return VectorL10n.tr("Vector", "room_recents_start_chat_with") } + /// SUGGESTED ROOMS + public static var roomRecentsSuggestedRoomsSection: String { + return VectorL10n.tr("Vector", "room_recents_suggested_rooms_section") + } /// Can't find this room. Make sure it exists public static var roomRecentsUnknownRoomErrorMessage: String { return VectorL10n.tr("Vector", "room_recents_unknown_room_error_message") @@ -4791,6 +4819,14 @@ public class VectorL10n: NSObject { public static var socialLoginListTitleSignUp: String { return VectorL10n.tr("Vector", "social_login_list_title_sign_up") } + /// Change space avatar + public static var spaceAvatarViewAccessibilityHint: String { + return VectorL10n.tr("Vector", "space_avatar_view_accessibility_hint") + } + /// avatar + public static var spaceAvatarViewAccessibilityLabel: String { + return VectorL10n.tr("Vector", "space_avatar_view_accessibility_label") + } /// BETA public static var spaceBetaAnnounceBadge: String { return VectorL10n.tr("Vector", "space_beta_announce_badge") @@ -4819,6 +4855,78 @@ public class VectorL10n: NSObject { public static var spaceFeatureUnavailableTitle: String { return VectorL10n.tr("Vector", "space_feature_unavailable_title") } + /// Ban from this space + public static var spaceParticipantsActionBan: String { + return VectorL10n.tr("Vector", "space_participants_action_ban") + } + /// Remove from this space + public static var spaceParticipantsActionRemove: String { + return VectorL10n.tr("Vector", "space_participants_action_remove") + } + /// Private space + public static var spacePrivateJoinRule: String { + return VectorL10n.tr("Vector", "space_private_join_rule") + } + /// Public space + public static var spacePublicJoinRule: String { + return VectorL10n.tr("Vector", "space_public_join_rule") + } + /// space + public static var spaceTag: String { + return VectorL10n.tr("Vector", "space_tag") + } + /// Adding rooms coming soon + public static var spacesAddRoomsComingSoonTitle: String { + return VectorL10n.tr("Vector", "spaces_add_rooms_coming_soon_title") + } + /// This feature hasn’t been implemented here, but it’s on the way. For now, you can do that with Element on your computer. + public static var spacesComingSoonDetail: String { + return VectorL10n.tr("Vector", "spaces_coming_soon_detail") + } + /// Coming soon + public static var spacesComingSoonTitle: String { + return VectorL10n.tr("Vector", "spaces_coming_soon_title") + } + /// Some rooms may be hidden because they’re private and you need an invite. + public static var spacesEmptySpaceDetail: String { + return VectorL10n.tr("Vector", "spaces_empty_space_detail") + } + /// This space has no rooms (yet) + public static var spacesEmptySpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_empty_space_title") + } + /// Explore rooms + public static var spacesExploreRooms: String { + return VectorL10n.tr("Vector", "spaces_explore_rooms") + } + /// Home + public static var spacesHomeSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_home_space_title") + } + /// Invites coming soon + public static var spacesInvitesComingSoonTitle: String { + return VectorL10n.tr("Vector", "spaces_invites_coming_soon_title") + } + /// Spaces + public static var spacesLeftPanelTitle: String { + return VectorL10n.tr("Vector", "spaces_left_panel_title") + } + /// Looking for someone not in %@? For now, you can invite them on web or desktop. + public static func spacesNoMemberFoundDetail(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_no_member_found_detail", p1) + } + /// No results found + public static var spacesNoResultFoundTitle: String { + return VectorL10n.tr("Vector", "spaces_no_result_found_title") + } + /// Some results may be hidden because they’re private and you need an invite to join them. + public static var spacesNoRoomFoundDetail: String { + return VectorL10n.tr("Vector", "spaces_no_room_found_detail") + } + /// Suggested + public static var spacesSuggestedRoom: String { + return VectorL10n.tr("Vector", "spaces_suggested_room") + } /// Start public static var start: String { return VectorL10n.tr("Vector", "start") diff --git a/Riot/Managers/UserSessions/UserSessionsService.swift b/Riot/Managers/UserSessions/UserSessionsService.swift index bf513d305..b8618abb0 100644 --- a/Riot/Managers/UserSessions/UserSessionsService.swift +++ b/Riot/Managers/UserSessions/UserSessionsService.swift @@ -34,6 +34,10 @@ extension UserSessionsService { @objcMembers class UserSessionsService: NSObject { + // MARK: - Singleton + + static public let shared: UserSessionsService = UserSessionsService() + // MARK: - Properties // MARK: Private diff --git a/Riot/Model/Room/RoomPreviewData.h b/Riot/Model/Room/RoomPreviewData.h index c10295617..f6f51bdca 100644 --- a/Riot/Model/Room/RoomPreviewData.h +++ b/Riot/Model/Room/RoomPreviewData.h @@ -97,6 +97,14 @@ */ - (instancetype)initWithPublicRoom:(MXPublicRoom*)publicRoom andSession:(MXSession*)mxSession; +/** + Contructors. + + @param childInfo MXSpaceChildInfo instance that describes the child. + @param mxSession the session to open the room preview with. + */ +- (instancetype)initWithSpaceChildInfo:(MXSpaceChildInfo*)childInfo andSession:(MXSession*)mxSession; + /** Attempt to peek into the room to get room data (state, messages history, etc). diff --git a/Riot/Model/Room/RoomPreviewData.m b/Riot/Model/Room/RoomPreviewData.m index 74738367a..07ece4510 100644 --- a/Riot/Model/Room/RoomPreviewData.m +++ b/Riot/Model/Room/RoomPreviewData.m @@ -16,6 +16,7 @@ */ #import "RoomPreviewData.h" +#import @implementation RoomPreviewData @@ -79,6 +80,21 @@ return self; } +- (instancetype)initWithSpaceChildInfo:(MXSpaceChildInfo*)childInfo andSession:(MXSession*)mxSession +{ + self = [self init]; + if (self) + { + _roomId = childInfo.childRoomId; + _roomName = childInfo.name; + _roomAvatarUrl = childInfo.avatarUrl; + _roomTopic = childInfo.topic; + _numJoinedMembers = childInfo.activeMemberCount; + _mxSession = mxSession; + } + return self; +} + - (void)dealloc { if (_roomDataSource) diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index 34964f23c..2fa364f52 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -55,6 +55,8 @@ final class AppCoordinator: NSObject, AppCoordinatorType { private var mainMatrixSession: MXSession? { return self.userSessionsService.mainUserSession?.matrixSession } + + private var currentSpaceId: String? // MARK: Public @@ -65,7 +67,7 @@ final class AppCoordinator: NSObject, AppCoordinatorType { init(router: RootRouterType, window: UIWindow) { self.rootRouter = router self.customSchemeURLParser = CustomSchemeURLParser() - self.userSessionsService = UserSessionsService() + self.userSessionsService = UserSessionsService.shared super.init() @@ -148,7 +150,7 @@ final class AppCoordinator: NSObject, AppCoordinatorType { private func addSideMenu() { let appInfo = AppInfo.current - let coordinatorParameters = SideMenuCoordinatorParameters(userSessionsService: self.userSessionsService, appInfo: appInfo) + let coordinatorParameters = SideMenuCoordinatorParameters(appNavigator: self.appNavigator, userSessionsService: self.userSessionsService, appInfo: appInfo) let coordinator = SideMenuCoordinator(parameters: coordinatorParameters) coordinator.delegate = self @@ -187,6 +189,29 @@ final class AppCoordinator: NSObject, AppCoordinatorType { FLEXManager.shared.showExplorer() #endif } + + fileprivate func navigate(to destination: AppNavigatorDestination) { + switch destination { + case .homeSpace: + MXLog.verbose("Switch to home space") + self.navigateToSpace(with: nil) + case .space(let spaceId): + MXLog.verbose("Switch to space with id: \(spaceId)") + self.navigateToSpace(with: spaceId) + } + } + + private func navigateToSpace(with spaceId: String?) { + guard spaceId != self.currentSpaceId else { + MXLog.verbose("Space with id: \(String(describing: spaceId)) is already selected") + return + } + + self.currentSpaceId = spaceId + + // Reload split view with selected space id + self.splitViewCoordinator?.start(with: spaceId) + } } // MARK: - LegacyAppDelegateDelegate @@ -218,6 +243,10 @@ extension AppCoordinator: LegacyAppDelegateDelegate { func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemove account: MXKAccount!) { self.userSessionsService.removeUserSession(relatedToAccount: account) } + + func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didNavigateToSpaceWithId spaceId: String!) { + self.sideMenuCoordinator?.select(spaceWithId: spaceId) + } } // MARK: - SplitViewCoordinatorDelegate @@ -239,6 +268,8 @@ extension AppCoordinator: SideMenuCoordinatorDelegate { fileprivate class AppNavigator: AppNavigatorProtocol { // swiftlint:enable private_over_fileprivate + // MARK: - Properties + private unowned let appCoordinator: AppCoordinator let alert: AlertPresentable @@ -251,8 +282,16 @@ fileprivate class AppNavigator: AppNavigatorProtocol { return SideMenuPresenter(sideMenuCoordinator: sideMenuCoordinator) }() + // MARK: - Setup + init(appCoordinator: AppCoordinator) { self.appCoordinator = appCoordinator self.alert = AppAlertPresenter(legacyAppDelegate: appCoordinator.legacyAppDelegate) } + + // MARK: - Public + + func navigate(to destination: AppNavigatorDestination) { + self.appCoordinator.navigate(to: destination) + } } diff --git a/Riot/Navigator/AppNavigator.swift b/Riot/Modules/Application/AppNavigator.swift similarity index 68% rename from Riot/Navigator/AppNavigator.swift rename to Riot/Modules/Application/AppNavigator.swift index 31ebd78b1..d3b7eddf4 100644 --- a/Riot/Navigator/AppNavigator.swift +++ b/Riot/Modules/Application/AppNavigator.swift @@ -18,9 +18,13 @@ import Foundation /// AppNavigatorProtocol abstract a navigator at app level. /// It enables to perform the navigation within the global app scope (open the side menu, open a room and so on) -/// Note: Use a destination enum like presented here https://www.swiftbysundell.com/articles/navigation-in-swift/#where-to-navigator or use simple methods like Element Android Navigator +/// Note: Presentation of the pattern here https://www.swiftbysundell.com/articles/navigation-in-swift/#where-to-navigator protocol AppNavigatorProtocol { var sideMenu: SideMenuPresentable { get } var alert: AlertPresentable { get } + + /// Navigate to a destination screen or a state + /// Do not use protocol with associatedtype for the moment like presented here https://www.swiftbysundell.com/articles/navigation-in-swift/#where-to-navigator use a separate enum + func navigate(to destination: AppNavigatorDestination) } diff --git a/Riot/Modules/Application/AppNavigatorDestination.swift b/Riot/Modules/Application/AppNavigatorDestination.swift new file mode 100644 index 000000000..06c90a811 --- /dev/null +++ b/Riot/Modules/Application/AppNavigatorDestination.swift @@ -0,0 +1,27 @@ +// +// 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 + +/// Supported destinations used by AppNavigator to navigate in screen hierarchy +enum AppNavigatorDestination { + + /// Show home space + case homeSpace + + /// Show a space with specific id + case space(_ spaceId: String) +} diff --git a/Riot/Modules/Application/LegacyAppDelegate.h b/Riot/Modules/Application/LegacyAppDelegate.h index 2a2226650..ddf33c28b 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.h +++ b/Riot/Modules/Application/LegacyAppDelegate.h @@ -241,6 +241,29 @@ UINavigationControllerDelegate */ - (BOOL)handleUniversalLinkURL:(NSURL*)universalLinkURL; +/** + Extract params from the URL fragment part (after '#') of a vector.im Universal link: + + The fragment can contain a '?'. So there are two kinds of parameters: path params and query params. + It is in the form of /[pathParam1]/[pathParam2]?[queryParam1Key]=[queryParam1Value]&[queryParam2Key]=[queryParam2Value] + @note this method should be private but is used by RoomViewController. This should be moved to a univresal link parser class + + @param fragment the fragment to parse. + @param outPathParams the decoded path params. + @param outQueryParams the decoded query params. If there is no query params, it will be nil. + */ +- (void)parseUniversalLinkFragment:(NSString*)fragment outPathParams:(NSArray **)outPathParams outQueryParams:(NSMutableDictionary **)outQueryParams; + +/** + Open the dedicated space with the given ID. + + This method will open only joined or invited spaces. + @note this method is temporary and should be moved to a dedicated coordinator + + @param spaceId ID of the space. + */ +- (void)openSpaceWithId:(NSString*)spaceId; + #pragma mark - App version management /** @@ -271,4 +294,6 @@ UINavigationControllerDelegate - (void)legacyAppDelegate:(LegacyAppDelegate*)legacyAppDelegate didRemoveAccount:(MXKAccount*)account; +- (void)legacyAppDelegate:(LegacyAppDelegate*)legacyAppDelegate didNavigateToSpaceWithId:(NSString*)spaceId; + @end diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 12eef4d39..1ad7847f8 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -87,7 +87,7 @@ NSString *const AppDelegateDidValidateEmailNotificationClientSecretKey = @"AppDe NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUniversalLinkDidChangeNotification"; -@interface LegacyAppDelegate () +@interface LegacyAppDelegate () { /** Reachability observer @@ -204,6 +204,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni @property (nonatomic, strong) ServiceTermsModalCoordinatorBridgePresenter *serviceTermsModalCoordinatorBridgePresenter; @property (nonatomic, strong) SlidingModalPresenter *slidingModalPresenter; @property (nonatomic, strong) SetPinCoordinatorBridgePresenter *setPinCoordinatorBridgePresenter; +@property (nonatomic, strong) SpaceDetailPresenter *spaceDetailPresenter; /** Used to manage on boarding steps, like create DM with riot bot @@ -1344,8 +1345,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni if (room.summary.roomType == MXRoomTypeSpace) { - // Indicates that spaces are not supported - [self.spaceFeatureUnavailablePresenter presentUnavailableFeatureFrom:self.presentedViewController animated:YES]; + [self restoreInitialDisplay:^{ + self.spaceDetailPresenter = [SpaceDetailPresenter new]; + self.spaceDetailPresenter.delegate = self; + [self.spaceDetailPresenter presentForSpaceWithId:room.roomId from:self.masterNavigationController sourceView:nil session:account.mxSession animated:YES]; + }]; } else { @@ -1361,9 +1365,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // So, come back to the home VC and show its loading wheel while processing [self restoreInitialDisplay:^{ - if ([_masterTabBarController.selectedViewController isKindOfClass:MXKViewController.class]) + if ([_masterTabBarController.selectedViewController isKindOfClass:MXKActivityHandlingViewController.class]) { - MXKViewController *homeViewController = (MXKViewController*)_masterTabBarController.selectedViewController; + MXKActivityHandlingViewController *homeViewController = (MXKActivityHandlingViewController*)_masterTabBarController.selectedViewController; [homeViewController startActivityIndicator]; @@ -1458,24 +1462,21 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni roomPreviewData.viaServers = queryParams[@"via"]; } - // Is it a link to an event of a room? - // If yes, the event will be displayed once the room is joined - roomPreviewData.eventId = (pathParams.count >= 3) ? pathParams[2] : nil; - - // Try to get more information about the room before opening its preview - [roomPreviewData peekInRoom:^(BOOL succeeded) { - - // Note: the activity indicator will not disappear if the session is not ready - [homeViewController stopActivityIndicator]; - - // If no data is available for this room, we name it with the known room alias (if any). - if (!succeeded && universalLinkFragmentPendingRoomAlias[roomIdOrAlias]) + [account.mxSession.matrixRestClient roomSummaryWith:roomIdOrAlias via:roomPreviewData.viaServers success:^(MXPublicRoom *room) { + if ([room.roomTypeString isEqualToString:MXRoomTypeStringSpace]) { - roomPreviewData.roomName = universalLinkFragmentPendingRoomAlias[roomIdOrAlias]; + [homeViewController stopActivityIndicator]; + + self.spaceDetailPresenter = [SpaceDetailPresenter new]; + self.spaceDetailPresenter.delegate = self; + [self.spaceDetailPresenter presentForSpaceWithPublicRoom:room from:self.masterNavigationController sourceView:nil session:account.mxSession animated:YES]; } - universalLinkFragmentPendingRoomAlias = nil; - - [self showRoomPreview:roomPreviewData]; + else + { + [self peekInRoomWithId:roomIdOrAlias forPreviewData:roomPreviewData params:pathParams]; + } + } failure:^(NSError *error) { + [self peekInRoomWithId:roomIdOrAlias forPreviewData:roomPreviewData params:pathParams]; }]; } @@ -1607,6 +1608,33 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } } +- (void)peekInRoomWithId:(NSString*)roomIdOrAlias forPreviewData:(RoomPreviewData *)roomPreviewData params:(NSArray *)pathParams +{ + // Is it a link to an event of a room? + // If yes, the event will be displayed once the room is joined + roomPreviewData.eventId = (pathParams.count >= 3) ? pathParams[2] : nil; + + MXWeakify(self); + // Try to get more information about the room before opening its preview + [roomPreviewData peekInRoom:^(BOOL succeeded) { + MXStrongifyAndReturnIfNil(self); + + MXKViewController *homeViewController = (MXKViewController*)self.masterTabBarController.selectedViewController; + + // Note: the activity indicator will not disappear if the session is not ready + [homeViewController stopActivityIndicator]; + + // If no data is available for this room, we name it with the known room alias (if any). + if (!succeeded && self->universalLinkFragmentPendingRoomAlias[roomIdOrAlias]) + { + roomPreviewData.roomName = self->universalLinkFragmentPendingRoomAlias[roomIdOrAlias]; + } + self->universalLinkFragmentPendingRoomAlias = nil; + + [self showRoomPreview:roomPreviewData]; + }]; +} + /** Extract params from the URL fragment part (after '#') of a vector.im Universal link: @@ -4382,4 +4410,50 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [URLPreviewService.shared clearStore]; } +#pragma mark - Spaces + +-(void)openSpaceWithId:(NSString *)spaceId +{ + MXSession *session = mxSessionArray.firstObject; + if ([session.spaceService getSpaceWithId:spaceId]) { + [self restoreInitialDisplay:^{ + [self.delegate legacyAppDelegate:self didNavigateToSpaceWithId:spaceId]; + }]; + } + else + { + MXWeakify(self); + __block __weak id observer = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { + MXStrongifyAndReturnIfNil(self); + + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + + if ([session.spaceService getSpaceWithId:spaceId]) { + [self restoreInitialDisplay:^{ + [self.delegate legacyAppDelegate:self didNavigateToSpaceWithId:spaceId]; + }]; + } + }]; + } +} + +#pragma mark - SpaceDetailPresenterDelegate + +- (void)spaceDetailPresenterDidComplete:(SpaceDetailPresenter *)presenter +{ + self.spaceDetailPresenter = nil; +} + +- (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didOpenSpaceWithId:(NSString *)spaceId +{ + self.spaceDetailPresenter = nil; + [self openSpaceWithId:spaceId]; +} + +- (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didJoinSpaceWithId:(NSString *)spaceId +{ + self.spaceDetailPresenter = nil; + [self openSpaceWithId:spaceId]; +} + @end diff --git a/Riot/Modules/Common/Avatar/AvatarView.swift b/Riot/Modules/Common/Avatar/AvatarView.swift index 54afc868a..210943386 100644 --- a/Riot/Modules/Common/Avatar/AvatarView.swift +++ b/Riot/Modules/Common/Avatar/AvatarView.swift @@ -32,7 +32,7 @@ class AvatarView: UIView, Themable { // MARK: Private - private var theme: Theme? + private(set) var theme: Theme? // MARK: Public @@ -106,7 +106,18 @@ class AvatarView: UIView, Themable { return } - let defaultavatarImage = AvatarGenerator.generateAvatar(forMatrixItem: viewData.matrixItemId, withDisplayName: viewData.displayName) + let defaultAvatarImage: UIImage? + var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill + + switch viewData.fallbackImage { + case .matrixItem(let matrixItemId, let matrixItemDisplayName): + defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName) + case .image(let image, let contentMode): + defaultAvatarImage = image + defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill + case .none: + defaultAvatarImage = nil + } if let avatarUrl = viewData.avatarUrl { avatarImageView.setImageURI(avatarUrl, @@ -114,13 +125,13 @@ class AvatarView: UIView, Themable { andImageOrientation: .up, toFitViewSize: avatarImageView.frame.size, with: MXThumbnailingMethodScale, - previewImage: defaultavatarImage, + previewImage: defaultAvatarImage, mediaManager: viewData.mediaManager) + avatarImageView.contentMode = .scaleAspectFill } else { - avatarImageView.image = defaultavatarImage + avatarImageView.image = defaultAvatarImage + avatarImageView.contentMode = defaultAvatarImageContentMode } - - avatarImageView.contentMode = .scaleAspectFill } func updateView() { diff --git a/Riot/Modules/Common/Avatar/AvatarViewData.swift b/Riot/Modules/Common/Avatar/AvatarViewData.swift new file mode 100644 index 000000000..6e9ec999a --- /dev/null +++ b/Riot/Modules/Common/Avatar/AvatarViewData.swift @@ -0,0 +1,34 @@ +// +// 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 + +struct AvatarViewData: AvatarViewDataProtocol { + /// Matrix item identifier (user id or room id) + var matrixItemId: String + + /// Matrix item display name (user or room display name) + var displayName: String? + + /// Matrix item avatar URL (user or room avatar url) + var avatarUrl: String? + + /// Matrix media handler + var mediaManager: MXMediaManager + + /// Fallback image used when avatarUrl is nil + var fallbackImage: AvatarFallbackImage? +} diff --git a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift index 6adc43baa..a1038f1e8 100644 --- a/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift +++ b/Riot/Modules/Common/Avatar/AvatarViewDataProtocol.swift @@ -14,7 +14,18 @@ // limitations under the License. // -import Foundation +import UIKit + +enum AvatarFallbackImage { + + /// matrixItem represent a Matrix item like a room, space, user + /// matrixItemId: Matrix item identifier (user id or room id) + /// displayName: Matrix item display name (user or room display name) + case matrixItem(_ matrixItemId: String, _ displayName: String?) + + /// Normal image with optional content mode + case image(_ image: UIImage, _ contentMode: UIView.ContentMode? = nil) +} /// AvatarViewDataProtocol describe a view data that should be given to an AvatarView sublcass protocol AvatarViewDataProtocol: AvatarProtocol { @@ -25,8 +36,11 @@ protocol AvatarViewDataProtocol: AvatarProtocol { var displayName: String? { get } /// Matrix item avatar URL (user or room avatar url) - var avatarUrl: String? { get } + var avatarUrl: String? { get } /// Matrix media handler var mediaManager: MXMediaManager { get } + + /// Fallback image used when avatarUrl is nil + var fallbackImage: AvatarFallbackImage? { get } } diff --git a/Riot/Modules/Common/Recents/CellData/RecentCellData.m b/Riot/Modules/Common/Recents/CellData/RecentCellData.m index a9def48fd..9d4e3dea8 100644 --- a/Riot/Modules/Common/Recents/CellData/RecentCellData.m +++ b/Riot/Modules/Common/Recents/CellData/RecentCellData.m @@ -18,6 +18,7 @@ #import "RecentCellData.h" #import "MXRoom+Riot.h" +#import "MatrixSDK-Swift.h" @implementation RecentCellData // trick to hide the mother class property as it is readonly one. @@ -57,7 +58,7 @@ - (void)update { [super update]; - roomDisplayname = self.roomSummary.displayname; + roomDisplayname = self.spaceChildInfo ? self.spaceChildInfo.name: self.roomSummary.displayname; if (!roomDisplayname.length) { roomDisplayname = [NSBundle mxk_localizedStringForKey:@"room_displayname_empty_room"]; diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.h b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.h index 2e1510949..a12e72d67 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.h +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.h @@ -19,6 +19,8 @@ #import "PublicRoomsDirectoryDataSource.h" +@class MXSpace; + /** List the different modes used to prepare the recents data source. Each mode corresponds to an application tab: Home, Favourites, People and Rooms. @@ -72,6 +74,7 @@ extern NSString *const kRecentsDataSourceTapOnDirectoryServerChange; @property (nonatomic) NSInteger conversationSection; @property (nonatomic) NSInteger lowPrioritySection; @property (nonatomic) NSInteger serverNoticeSection; +@property (nonatomic) NSInteger suggestedRoomsSection; @property (nonatomic, readonly) NSArray* invitesCellDataArray; @property (nonatomic, readonly) NSArray* favoriteCellDataArray; @@ -79,6 +82,7 @@ extern NSString *const kRecentsDataSourceTapOnDirectoryServerChange; @property (nonatomic, readonly) NSArray* conversationCellDataArray; @property (nonatomic, readonly) NSArray* lowPriorityCellDataArray; @property (nonatomic, readonly) NSArray* serverNoticeCellDataArray; +@property (nonatomic, readonly) NSArray* suggestedRoomCellDataArray; @property (nonatomic, readonly) SecureBackupBannerDisplay secureBackupBannerDisplay; @property (nonatomic, readonly) CrossSigningBannerDisplay crossSigningBannerDisplay; diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m index 123d9da69..7812a8388 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m @@ -34,6 +34,7 @@ #define RECENTSDATASOURCE_SECTION_LOWPRIORITY 0x10 #define RECENTSDATASOURCE_SECTION_SERVERNOTICE 0x20 #define RECENTSDATASOURCE_SECTION_PEOPLE 0x40 +#define RECENTSDATASOURCE_SECTION_SUGGESTED 0x80 #define RECENTSDATASOURCE_DEFAULT_SECTION_HEADER_HEIGHT 30.0 @@ -60,7 +61,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou @end @implementation RecentsDataSource -@synthesize directorySection, invitesSection, favoritesSection, peopleSection, conversationSection, lowPrioritySection, serverNoticeSection, secureBackupBannerSection, crossSigningBannerSection; +@synthesize directorySection, invitesSection, favoritesSection, peopleSection, conversationSection, lowPrioritySection, serverNoticeSection, suggestedRoomsSection, secureBackupBannerSection, crossSigningBannerSection; @synthesize hiddenCellIndexPath, droppingCellIndexPath, droppingCellBackGroundView; - (instancetype)init @@ -84,10 +85,17 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou // Set default data and view classes [self registerCellDataClass:RecentCellData.class forCellIdentifier:kMXKRecentCellIdentifier]; + + [self registerSpaceServiceDidBuildGraphNotification]; } return self; } +- (void)dealloc +{ + [self unregisterSpaceServiceDidBuildGraphNotification]; +} + - (void)resetSectionIndexes { crossSigningBannerSection = -1; @@ -99,9 +107,9 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou conversationSection = -1; lowPrioritySection = -1; serverNoticeSection = -1; + suggestedRoomsSection = -1; } - #pragma mark - Properties - (NSArray *)invitesCellDataArray @@ -128,6 +136,10 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { return state.serverNoticeCellDataArray; } +- (NSArray *)suggestedRoomCellDataArray +{ + return state.suggestedRoomCellDataArray; +} - (NSUInteger)missedFavouriteDiscussionsCount { @@ -165,7 +177,6 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou return state.unsentMessagesGroupDiscussionsCount; } - #pragma mark - - (void)setDelegate:(id)delegate andRecentsDataSourceMode:(RecentsDataSourceMode)recentsDataSourceMode @@ -212,6 +223,23 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou return stickyHeader; } +#pragma mark - Space Service notifications + +- (void)registerSpaceServiceDidBuildGraphNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(spaceServiceDidBuildGraphNotification:) name:MXSpaceService.didBuildSpaceGraph object:nil]; +} + +- (void)spaceServiceDidBuildGraphNotification:(NSNotification*)notification +{ + [self forceRefresh]; +} + +- (void)unregisterSpaceServiceDidBuildGraphNotification +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:MXSpaceService.didBuildSpaceGraph object:nil]; +} + #pragma mark - Key backup setup banner - (void)registerKeyBackupStateDidChangeNotification @@ -491,6 +519,11 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { serverNoticeSection = sectionsCount++; } + + if (self.suggestedRoomCellDataArray.count > 0) + { + suggestedRoomsSection = sectionsCount++; + } } return sectionsCount; @@ -543,6 +576,10 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { count = self.invitesCellDataArray.count; } + else if (section == suggestedRoomsSection && !(shrinkedSectionsBitMask & RECENTSDATASOURCE_SECTION_SUGGESTED)) + { + count = self.suggestedRoomCellDataArray.count; + } // Adjust this count according to the potential dragged cell. if ([self isMovingCellSection:section]) @@ -624,6 +661,11 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou title = NSLocalizedStringFromTable(@"room_recents_invites_section", @"Vector", nil); } } + else if (section == suggestedRoomsSection) + { + count = self.suggestedRoomCellDataArray.count; + title = NSLocalizedStringFromTable(@"room_recents_suggested_rooms_section", @"Vector", nil); + } if (count) { @@ -675,6 +717,10 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { sectionArray = self.serverNoticeCellDataArray; } + else if (section == suggestedRoomsSection) + { + sectionArray = self.suggestedRoomCellDataArray; + } BOOL highlight = NO; for (id cellData in sectionArray) @@ -760,6 +806,10 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { sectionBitwise = RECENTSDATASOURCE_SECTION_INVITES; } + else if (section == suggestedRoomsSection) + { + sectionBitwise = RECENTSDATASOURCE_SECTION_SUGGESTED; + } } if (sectionBitwise) @@ -957,6 +1007,13 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou cellData = self.invitesCellDataArray[cellDataIndex]; } } + else if (tableSection == suggestedRoomsSection) + { + if (cellDataIndex < self.suggestedRoomCellDataArray.count) + { + cellData = self.suggestedRoomCellDataArray[cellDataIndex]; + } + } return cellData; } @@ -1013,9 +1070,19 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { id cellDataStoring = cellDataArray[index]; - if ([roomId isEqualToString:cellDataStoring.roomSummary.roomId] && (matrixSession == cellDataStoring.roomSummary.room.mxSession)) + if (cellDataStoring.roomSummary) { - return index; + if ([roomId isEqualToString:cellDataStoring.roomSummary.roomId] && (matrixSession == cellDataStoring.roomSummary.room.mxSession)) + { + return index; + } + } + else if (cellDataStoring.spaceChildInfo) + { + if ([roomId isEqualToString:cellDataStoring.spaceChildInfo.name]) + { + return index; + } } } } @@ -1117,7 +1184,22 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou indexPath = [NSIndexPath indexPathForRow:index inSection:serverNoticeSection]; } } - + + if (!indexPath && (suggestedRoomsSection >= 0)) + { + index = [self cellIndexPosWithRoomId:roomId andMatrixSession:matrixSession within:self.serverNoticeCellDataArray]; + + if (index != NSNotFound) + { + // Check whether the low priority rooms are shrinked + if (shrinkedSectionsBitMask & RECENTSDATASOURCE_SECTION_SUGGESTED) + { + return nil; + } + indexPath = [NSIndexPath indexPathForRow:index inSection:serverNoticeSection]; + } + } + return indexPath; } @@ -1133,13 +1215,13 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou NSMutableArray> *cells = [NSMutableArray new]; NSInteger count = recentsDataSource.numberOfCells; - + for (NSUInteger index = 0; index < count; index++) { id cell = [recentsDataSource cellDataAtIndex:index]; [cells addObject:cell]; } - + MXWeakify(self); [self computeStateAsyncWithCells:cells recentsDataSourceMode:self.recentsDataSourceMode matrixSession:recentsDataSource.mxSession onComplete:^(RecentsDataSourceState *newState) { MXStrongifyAndReturnIfNil(self); @@ -1179,7 +1261,8 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou NSMutableArray> *conversationCellDataArray = [NSMutableArray new]; NSMutableArray> *lowPriorityCellDataArray = [NSMutableArray new]; NSMutableArray> *serverNoticeCellDataArray = [NSMutableArray new]; - + NSMutableArray> *suggestedRoomCellDataArray = [NSMutableArray new]; + MissedDiscussionsCount *favouriteMissedDiscussionsCount = [MissedDiscussionsCount new]; MissedDiscussionsCount *directMissedDiscussionsCount = [MissedDiscussionsCount new]; MissedDiscussionsCount *groupMissedDiscussionsCount = [MissedDiscussionsCount new]; @@ -1206,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]; } @@ -1215,6 +1298,14 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou { [peopleCellDataArray addObject:recentCellDataStoring]; } + else if (recentCellDataStoring.isSuggestedRoom) + { + MXRoomSummary *roomSummary = [mxSession roomSummaryWithRoomId:recentCellDataStoring.spaceChildInfo.childRoomId]; + if (!roomSummary.isJoined) + { + [suggestedRoomCellDataArray addObject:recentCellDataStoring]; + } + } else { // Hide spaces from home (keep space invites) @@ -1253,13 +1344,22 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou } else if (recentsDataSourceMode == RecentsDataSourceModeRooms) { + if (recentCellDataStoring.isSuggestedRoom) + { + MXRoomSummary *roomSummary = [mxSession roomSummaryWithRoomId:recentCellDataStoring.spaceChildInfo.childRoomId]; + BOOL isJoined = roomSummary.membership == MXMembershipJoin || roomSummary.membershipTransitionState == MXMembershipTransitionStateJoined; + if (!isJoined) + { + [suggestedRoomCellDataArray addObject:recentCellDataStoring]; + } + } // Consider only non direct rooms. - if (!room.isDirect) + else if (!room.isDirect) { // 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]; } @@ -1348,6 +1448,15 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou // Sort each rooms collection by considering first the rooms with some missed notifs, the rooms with unread, then the others. comparator = ^NSComparisonResult(id recentCellData1, id recentCellData2) { + if (recentCellData1.spaceChildInfo && !recentCellData2.spaceChildInfo) + { + return NSOrderedDescending; + } + if (recentCellData2.spaceChildInfo && !recentCellData1.spaceChildInfo) + { + return NSOrderedAscending; + } + if (recentCellData1.roomSummary.room.sentStatus != RoomSentStatusOk && recentCellData2.roomSummary.room.sentStatus == RoomSentStatusOk) { @@ -1417,6 +1526,15 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou // Sort each rooms collection by considering first the rooms with some unread messages then the others. comparator = ^NSComparisonResult(id recentCellData1, id recentCellData2) { + if (recentCellData1.spaceChildInfo && !recentCellData2.spaceChildInfo) + { + return NSOrderedDescending; + } + if (recentCellData2.spaceChildInfo && !recentCellData1.spaceChildInfo) + { + return NSOrderedAscending; + } + if (recentCellData1.roomSummary.room.sentStatus != RoomSentStatusOk && recentCellData2.roomSummary.room.sentStatus == RoomSentStatusOk) { @@ -1489,6 +1607,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou } MXLogDebug(@"[RecentsDataSource] refreshRoomsSections: Done in %.0fms", [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + MXLogDebug(@"[Spaces] refreshRoomsSections with %ld suggested room", suggestedRoomCellDataArray.count); return [[RecentsDataSourceState alloc] initWithInvitesCellDataArray:invitesCellDataArray @@ -1497,6 +1616,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou conversationCellDataArray:conversationCellDataArray lowPriorityCellDataArray:lowPriorityCellDataArray serverNoticeCellDataArray:serverNoticeCellDataArray + suggestedRoomCellDataArray:suggestedRoomCellDataArray favouriteMissedDiscussionsCount:favouriteMissedDiscussionsCount directMissedDiscussionsCount:directMissedDiscussionsCount groupMissedDiscussionsCount:groupMissedDiscussionsCount diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSourceState.swift b/Riot/Modules/Common/Recents/DataSources/RecentsDataSourceState.swift index 5f7b376dd..1cadbec71 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSourceState.swift +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSourceState.swift @@ -29,6 +29,7 @@ class RecentsDataSourceState: NSObject { let conversationCellDataArray: [MXKRecentCellDataStoring] let lowPriorityCellDataArray: [MXKRecentCellDataStoring] let serverNoticeCellDataArray: [MXKRecentCellDataStoring] + let suggestedRoomCellDataArray: [MXKRecentCellDataStoring] // MARK: Notifications counts let favouriteMissedDiscussionsCount: MissedDiscussionsCount @@ -47,6 +48,7 @@ class RecentsDataSourceState: NSObject { conversationCellDataArray: [MXKRecentCellDataStoring], lowPriorityCellDataArray: [MXKRecentCellDataStoring], serverNoticeCellDataArray: [MXKRecentCellDataStoring], + suggestedRoomCellDataArray: [MXKRecentCellDataStoring], favouriteMissedDiscussionsCount: MissedDiscussionsCount, directMissedDiscussionsCount: MissedDiscussionsCount, groupMissedDiscussionsCount: MissedDiscussionsCount, @@ -58,6 +60,7 @@ class RecentsDataSourceState: NSObject { self.conversationCellDataArray = conversationCellDataArray self.lowPriorityCellDataArray = lowPriorityCellDataArray self.serverNoticeCellDataArray = serverNoticeCellDataArray + self.suggestedRoomCellDataArray = suggestedRoomCellDataArray self.favouriteMissedDiscussionsCount = favouriteMissedDiscussionsCount self.directMissedDiscussionsCount = directMissedDiscussionsCount self.groupMissedDiscussionsCount = groupMissedDiscussionsCount diff --git a/Riot/Modules/Common/Recents/RecentsViewController.m b/Riot/Modules/Common/Recents/RecentsViewController.m index 7c00230c6..7520c4b47 100644 --- a/Riot/Modules/Common/Recents/RecentsViewController.m +++ b/Riot/Modules/Common/Recents/RecentsViewController.m @@ -72,6 +72,8 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro @property (nonatomic, strong) RoomsDirectoryCoordinatorBridgePresenter *roomsDirectoryCoordinatorBridgePresenter; +@property (nonatomic, strong) ExploreRoomCoordinatorBridgePresenter *exploreRoomsCoordinatorBridgePresenter; + @property (nonatomic, strong) SpaceFeatureUnavailablePresenter *spaceFeatureUnavailablePresenter; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; @@ -1950,7 +1952,13 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro return; } - if (RiotSettings.shared.roomsAllowToJoinPublicRooms) + if (self.dataSource.currentSpace) + { + self.exploreRoomsCoordinatorBridgePresenter = [[ExploreRoomCoordinatorBridgePresenter alloc] initWithSession:self.mainSession spaceId:self.dataSource.currentSpace.spaceId]; + self.exploreRoomsCoordinatorBridgePresenter.delegate = self; + [self.exploreRoomsCoordinatorBridgePresenter presentFrom:self animated:YES]; + } + else if (RiotSettings.shared.roomsAllowToJoinPublicRooms) { self.roomsDirectoryCoordinatorBridgePresenter = [[RoomsDirectoryCoordinatorBridgePresenter alloc] initWithSession:self.mainSession dataSource:[self.recentsDataSource.publicRoomsDirectoryDataSource copy]]; self.roomsDirectoryCoordinatorBridgePresenter.delegate = self; @@ -2064,6 +2072,18 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro [self dispayRoomWithRoomId:roomId inMatrixSession:matrixSession]; } +- (void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectSuggestedRoom:(MXSpaceChildInfo *)childInfo +{ + RoomPreviewData *previewData = [[RoomPreviewData alloc] initWithSpaceChildInfo:childInfo andSession:self.mainSession]; + [self startActivityIndicator]; + MXWeakify(self); + [previewData peekInRoom:^(BOOL succeeded) { + MXStrongifyAndReturnIfNil(self); + [self stopActivityIndicator]; + [[AppDelegate theDelegate].masterTabBarController showRoomPreview:previewData]; + }]; +} + #pragma mark - UISearchBarDelegate - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset @@ -2270,6 +2290,16 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro } } +#pragma mark - ExploreRoomCoordinatorBridgePresenterDelegate + +- (void)exploreRoomCoordinatorBridgePresenterDelegateDidComplete:(ExploreRoomCoordinatorBridgePresenter *)coordinatorBridgePresenter { + MXWeakify(self); + [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ + MXStrongifyAndReturnIfNil(self); + self.exploreRoomsCoordinatorBridgePresenter = nil; + }]; +} + #pragma mark - RoomNotificationSettingsCoordinatorBridgePresenterDelegate -(void)roomNotificationSettingsCoordinatorBridgePresenterDelegateDidComplete:(RoomNotificationSettingsCoordinatorBridgePresenter *)coordinatorBridgePresenter { diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index 337c39cba..cf6077adf 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -76,7 +76,7 @@ self.lastEventDate.text = roomCellData.lastEventDate; // Manage lastEventAttributedTextMessage optional property - if ([roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) + if (!roomCellData.spaceChildInfo && [roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) { // Force the default text color for the last message (cancel highlighted message color) NSMutableAttributedString *lastEventDescription = [[NSMutableAttributedString alloc] initWithAttributedString:roomCellData.lastEventAttributedTextMessage]; @@ -124,7 +124,14 @@ self.roomTitle.font = [UIFont systemFontOfSize:17 weight:UIFontWeightMedium]; } - [roomCellData.roomSummary setRoomAvatarImageIn:self.roomAvatar]; + if (roomCellData.spaceChildInfo) + { + [self.roomAvatar vc_setRoomAvatarImageWith:roomCellData.spaceChildInfo.avatarUrl displayName:roomCellData.spaceChildInfo.displayName mediaManager:roomCellData.recentsDataSource.mxSession.mediaManager]; + } + else + { + [self.roomAvatar vc_setRoomAvatarImageWith:roomCellData.roomSummary.avatar displayName:roomCellData.roomSummary.displayname mediaManager:roomCellData.roomSummary.mxSession.mediaManager]; + } } else { diff --git a/Riot/Modules/Contacts/Views/ContactTableViewCell.m b/Riot/Modules/Contacts/Views/ContactTableViewCell.m index cf3ec0a77..a22454161 100644 --- a/Riot/Modules/Contacts/Views/ContactTableViewCell.m +++ b/Riot/Modules/Contacts/Views/ContactTableViewCell.m @@ -86,16 +86,7 @@ { _showCustomAccessoryView = show; - if (show) - { - self.customAccessViewWidthConstraint.constant = 25; - self.customAccessoryViewLeadingConstraint.constant = 13; - } - else - { - self.customAccessViewWidthConstraint.constant = 0; - self.customAccessoryViewLeadingConstraint.constant = 0; - } + self.customAccessViewWidthConstraint.constant = show ? 25 : 0; } - (void)setShowMatrixIdInDisplayName:(BOOL)showMatrixIdInDisplayName diff --git a/Riot/Modules/Home/HomeViewController.m b/Riot/Modules/Home/HomeViewController.m index 21ae2e64c..3909c1a6f 100644 --- a/Riot/Modules/Home/HomeViewController.m +++ b/Riot/Modules/Home/HomeViewController.m @@ -26,7 +26,7 @@ #import "MXRoom+Riot.h" -@interface HomeViewController () +@interface HomeViewController () { RecentsDataSource *recentsDataSource; @@ -48,6 +48,8 @@ @property (nonatomic, assign, readwrite) BOOL roomListDataReady; +@property(nonatomic) SpaceMembersCoordinatorBridgePresenter *spaceMembersCoordinatorBridgePresenter; + @end @implementation HomeViewController @@ -254,7 +256,72 @@ [self cancelEditionMode:YES]; } - [super onPlusButtonPressed]; + if (recentsDataSource.currentSpace != nil) + { + [self showPlusMenuForSpace]; + } + else + { + [super onPlusButtonPressed]; + } +} + +- (void)showPlusMenuForSpace +{ + __weak typeof(self) weakSelf = self; + + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + currentAlert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"spaces_explore_rooms", @"Vector", nil) + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self showRoomDirectory]; + } + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedStringFromTable(@"room_details_people", @"Vector", nil) + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + self.spaceMembersCoordinatorBridgePresenter = [[SpaceMembersCoordinatorBridgePresenter alloc] initWithUserSessionsService:[UserSessionsService shared] session:self.mainSession spaceId:self.dataSource.currentSpace.spaceId]; + self.spaceMembersCoordinatorBridgePresenter.delegate = self; + [self.spaceMembersCoordinatorBridgePresenter presentFrom:self animated:YES]; + } + + }]]; + + + [currentAlert addAction:[UIAlertAction actionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + [currentAlert popoverPresentationController].sourceView = plusButtonImageView; + [currentAlert popoverPresentationController].sourceRect = plusButtonImageView.bounds; + + [currentAlert mxk_setAccessibilityIdentifier:@"RecentsVCCreateRoomAlert"]; + [self presentViewController:currentAlert animated:YES completion:nil]; } - (void)cancelEditionMode:(BOOL)forceRefresh @@ -562,7 +629,14 @@ id renderedCellData = (id)roomCollectionViewCell.renderedCellData; - [self.delegate recentListViewController:self didSelectRoom:renderedCellData.roomSummary.roomId inMatrixSession:renderedCellData.roomSummary.room.mxSession]; + if (renderedCellData.isSuggestedRoom) + { + [self.delegate recentListViewController:self didSelectSuggestedRoom:renderedCellData.spaceChildInfo]; + } + else + { + [self.delegate recentListViewController:self didSelectRoom:renderedCellData.roomSummary.roomId inMatrixSession:renderedCellData.roomSummary.room.mxSession]; + } } // Hide the keyboard when user select a room @@ -861,7 +935,17 @@ + recentsDataSource.peopleCellDataArray.count + recentsDataSource.conversationCellDataArray.count + recentsDataSource.lowPriorityCellDataArray.count - + recentsDataSource.serverNoticeCellDataArray.count; + + recentsDataSource.serverNoticeCellDataArray.count + + recentsDataSource.suggestedRoomCellDataArray.count; +} + +#pragma mark - SpaceMembersCoordinatorBridgePresenterDelegate + +- (void)spaceMembersCoordinatorBridgePresenterDelegateDidComplete:(SpaceMembersCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ + self.spaceMembersCoordinatorBridgePresenter = nil; + }]; } @end diff --git a/Riot/Modules/Home/VersionCheck/HomeViewControllerWithBannerWrapperViewController.swift b/Riot/Modules/Home/VersionCheck/HomeViewControllerWithBannerWrapperViewController.swift index 2cd0988ba..85abec4d1 100644 --- a/Riot/Modules/Home/VersionCheck/HomeViewControllerWithBannerWrapperViewController.swift +++ b/Riot/Modules/Home/VersionCheck/HomeViewControllerWithBannerWrapperViewController.swift @@ -16,7 +16,7 @@ import Foundation -class HomeViewControllerWithBannerWrapperViewController: UIViewController, BannerPresentationProtocol { +class HomeViewControllerWithBannerWrapperViewController: MXKActivityHandlingViewController, BannerPresentationProtocol { @objc let homeViewController: HomeViewController private var bannerContainerView: UIView! @@ -41,6 +41,8 @@ class HomeViewControllerWithBannerWrapperViewController: UIViewController, Banne override func viewDidLoad() { super.viewDidLoad() + homeViewController.willMove(toParent: self) + view.backgroundColor = .clear stackView = UIStackView() @@ -49,7 +51,7 @@ class HomeViewControllerWithBannerWrapperViewController: UIViewController, Banne stackView.alignment = .fill view.vc_addSubViewMatchingParent(stackView) - + addChild(homeViewController) stackView.addArrangedSubview(homeViewController.view) homeViewController.didMove(toParent: self) diff --git a/Riot/Modules/Home/Views/RoomCollectionViewCell.m b/Riot/Modules/Home/Views/RoomCollectionViewCell.m index 33b263a80..f8986da55 100644 --- a/Riot/Modules/Home/Views/RoomCollectionViewCell.m +++ b/Riot/Modules/Home/Views/RoomCollectionViewCell.m @@ -130,7 +130,14 @@ } - [roomCellData.roomSummary setRoomAvatarImageIn:self.roomAvatar]; + if (roomCellData.roomSummary) + { + [self.roomAvatar vc_setRoomAvatarImageWith:roomCellData.roomSummary.avatar displayName:roomCellData.roomSummary.displayname mediaManager:roomCellData.roomSummary.mxSession.mediaManager]; + } + else + { + [self.roomAvatar vc_setRoomAvatarImageWith:roomCellData.spaceChildInfo.avatarUrl displayName:roomCellData.spaceChildInfo.displayName mediaManager:roomCellData.recentsDataSource.mxSession.mediaManager]; + } } } @@ -171,7 +178,7 @@ { if (roomCellData) { - return roomCellData.roomSummary.roomId; + return roomCellData.spaceChildInfo ? roomCellData.spaceChildInfo.childRoomId : roomCellData.roomSummary.roomId; } return nil; } diff --git a/Riot/Modules/People/PeopleViewController.m b/Riot/Modules/People/PeopleViewController.m index 26b0ca3f0..7d4e9260c 100644 --- a/Riot/Modules/People/PeopleViewController.m +++ b/Riot/Modules/People/PeopleViewController.m @@ -26,12 +26,14 @@ #import "Riot-Swift.h" -@interface PeopleViewController () +@interface PeopleViewController () { NSInteger directRoomsSectionNumber; RecentsDataSource *recentsDataSource; } +@property(nonatomic) SpaceMembersCoordinatorBridgePresenter *spaceMembersCoordinatorBridgePresenter; + @end @implementation PeopleViewController @@ -119,7 +121,16 @@ - (void)onPlusButtonPressed { - [self performSegueWithIdentifier:@"presentStartChat" sender:self]; + if (self.dataSource.currentSpace != nil) + { + self.spaceMembersCoordinatorBridgePresenter = [[SpaceMembersCoordinatorBridgePresenter alloc] initWithUserSessionsService:[UserSessionsService shared] session:self.mainSession spaceId:self.dataSource.currentSpace.spaceId]; + self.spaceMembersCoordinatorBridgePresenter.delegate = self; + [self.spaceMembersCoordinatorBridgePresenter presentFrom:self animated:YES]; + } + else + { + [self performSegueWithIdentifier:@"presentStartChat" sender:self]; + } } #pragma mark - @@ -172,4 +183,13 @@ + recentsDataSource.conversationCellDataArray.count; } +#pragma mark - SpaceMembersCoordinatorBridgePresenterDelegate + +- (void)spaceMembersCoordinatorBridgePresenterDelegateDidComplete:(SpaceMembersCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ + self.spaceMembersCoordinatorBridgePresenter = nil; + }]; +} + @end diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index ed313cffb..517a44fa5 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -465,6 +465,11 @@ [[AppDelegate theDelegate] presentCompleteSecurityForSession:self.mainSession]; } +- (void)showRoomWithId:(NSString*)roomId +{ + [[AppDelegate theDelegate] showRoom:roomId andEventId:nil withMatrixSession:self.mainSession]; +} + #pragma mark - Hide/Show navigation bar border - (void)hideNavigationBarBorder:(BOOL)isHidden @@ -518,7 +523,10 @@ { isOneself = YES; - [otherActionsArray addObject:@(MXKRoomMemberDetailsActionLeave)]; + if (self.enableLeave) + { + [otherActionsArray addObject:@(MXKRoomMemberDetailsActionLeave)]; + } if (oneSelfPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomPowerLevels]) { @@ -754,10 +762,24 @@ title = NSLocalizedStringFromTable(@"room_participants_action_leave", @"Vector", nil); break; case MXKRoomMemberDetailsActionKick: - title = NSLocalizedStringFromTable(@"room_participants_action_remove", @"Vector", nil); + if (self.mxRoom.summary.roomType == MXRoomTypeSpace) + { + title = NSLocalizedStringFromTable(@"space_participants_action_remove", @"Vector", nil); + } + else + { + title = NSLocalizedStringFromTable(@"room_participants_action_remove", @"Vector", nil); + } break; case MXKRoomMemberDetailsActionBan: - title = NSLocalizedStringFromTable(@"room_participants_action_ban", @"Vector", nil); + if (self.mxRoom.summary.roomType == MXRoomTypeSpace) + { + title = NSLocalizedStringFromTable(@"space_participants_action_ban", @"Vector", nil); + } + else + { + title = NSLocalizedStringFromTable(@"room_participants_action_ban", @"Vector", nil); + } break; case MXKRoomMemberDetailsActionUnban: title = NSLocalizedStringFromTable(@"room_participants_action_unban", @"Vector", nil); @@ -1047,7 +1069,7 @@ if (indexPath.row < directChatsArray.count) { // Open this room - [[AppDelegate theDelegate] showRoom:directChatsArray[indexPath.row] andEventId:nil withMatrixSession:self.mainSession]; + [self showRoomWithId:directChatsArray[indexPath.row]]; } else { diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.h b/Riot/Modules/Room/Members/RoomParticipantsViewController.h index 01ee41929..c0b8dc3f8 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.h +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.h @@ -85,6 +85,7 @@ @property (nonatomic) BOOL enableMention; @property (nonatomic) BOOL showCancelBarButtonItem; +@property (nonatomic) BOOL showParticipantCustomAccessoryView; /** The delegate for the view controller. diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.m b/Riot/Modules/Room/Members/RoomParticipantsViewController.m index ec814191d..2d84fa595 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.m +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.m @@ -84,6 +84,7 @@ // Setup `MXKViewControllerHandling` properties self.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; + self.showParticipantCustomAccessoryView = YES; } - (void)viewDidLoad @@ -113,7 +114,11 @@ self.navigationItem.title = NSLocalizedStringFromTable(@"room_participants_title", @"Vector", nil); - if (self.mxRoom.isDirect) + if (self.mxRoom.summary.roomType == MXRoomTypeSpace) + { + _searchBarView.placeholder = NSLocalizedStringFromTable(@"search_default_placeholder", @"Vector", nil); + } + else if (self.mxRoom.isDirect) { _searchBarView.placeholder = NSLocalizedStringFromTable(@"room_participants_filter_room_members_for_dm", @"Vector", nil); } @@ -340,7 +345,11 @@ { self.searchBarHeader.hidden = NO; - if (self.mxRoom.isDirect) + if (self.mxRoom.summary.roomType == MXRoomTypeSpace) + { + self.searchBarView.placeholder = NSLocalizedStringFromTable(@"search_default_placeholder", @"Vector", nil); + } + else if (self.mxRoom.isDirect) { self.searchBarView.placeholder = NSLocalizedStringFromTable(@"room_participants_filter_room_members_for_dm", @"Vector", nil); } @@ -870,6 +879,19 @@ } } +- (void)showDetailFor:(MXRoomMember* _Nonnull)member from:(UIView* _Nullable)sourceView { + memberDetailsViewController = [RoomMemberDetailsViewController roomMemberDetailsViewController]; + + // Set delegate to handle action on member (start chat, mention) + memberDetailsViewController.delegate = self; + memberDetailsViewController.enableMention = _enableMention; + memberDetailsViewController.enableVoipCall = NO; + + [memberDetailsViewController displayRoomMember:member withMatrixRoom:self.mxRoom]; + + [self pushViewController:memberDetailsViewController]; +} + #pragma mark - UITableView data source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView @@ -948,6 +970,7 @@ { ContactTableViewCell* participantCell = [tableView dequeueReusableCellWithIdentifier:@"ParticipantTableViewCellId" forIndexPath:indexPath]; participantCell.selectionStyle = UITableViewCellSelectionStyleNone; + participantCell.showCustomAccessoryView = self.showParticipantCustomAccessoryView; participantCell.mxRoom = self.mxRoom; @@ -1185,16 +1208,8 @@ if (contact.mxMember) { - memberDetailsViewController = [RoomMemberDetailsViewController roomMemberDetailsViewController]; - - // Set delegate to handle action on member (start chat, mention) - memberDetailsViewController.delegate = self; - memberDetailsViewController.enableMention = _enableMention; - memberDetailsViewController.enableVoipCall = NO; - - [memberDetailsViewController displayRoomMember:contact.mxMember withMatrixRoom:self.mxRoom]; - - [self pushViewController:memberDetailsViewController]; + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; + [self showDetailFor:contact.mxMember from:selectedCell]; } [tableView deselectRowAtIndexPath:indexPath animated:YES]; diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift index c9bf9af89..744bfa2b6 100644 --- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift +++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.swift @@ -19,26 +19,18 @@ import Reusable class RoomNotificationSettingsAvatarView: UIView { - @IBOutlet weak var avatarView: MXKImageView! + @IBOutlet weak var avatarView: RoomAvatarView! @IBOutlet weak var nameLabel: UILabel! func configure(viewData: AvatarViewDataProtocol) { - let avatarImage = AvatarGenerator.generateAvatar(forMatrixItem: viewData.matrixItemId, withDisplayName: viewData.displayName) + avatarView.fill(with: viewData) - if let avatarUrl = viewData.avatarUrl { - avatarView.enableInMemoryCache = true - - avatarView.setImageURI(avatarUrl, - withType: nil, - andImageOrientation: .up, - toFitViewSize: avatarView.frame.size, - with: MXThumbnailingMethodCrop, - previewImage: avatarImage, - mediaManager: viewData.mediaManager) - } else { - avatarView.image = avatarImage + switch viewData.fallbackImage { + case .matrixItem(_, let matrixItemDisplayName): + nameLabel.text = matrixItemDisplayName + default: + nameLabel.text = nil } - nameLabel.text = viewData.displayName } } diff --git a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.xib b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.xib index b9c21f892..7ee782fdd 100644 --- a/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.xib +++ b/Riot/Modules/Room/NotificationSettings/UIKit/RoomNotificationSettingsAvatarView.xib @@ -14,7 +14,7 @@ - + diff --git a/Riot/Modules/Room/RoomViewController+Spaces.swift b/Riot/Modules/Room/RoomViewController+Spaces.swift new file mode 100644 index 000000000..f85197cd8 --- /dev/null +++ b/Riot/Modules/Room/RoomViewController+Spaces.swift @@ -0,0 +1,127 @@ +// +// 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 + +/// this extension is temprorary and implements navigation to the Space bootom sheet. This should be moved to an universal link flow coordinator +extension RoomViewController { + @objc func handleSpaceUniversalLink(with url: URL) { + let url = Tools.fixURL(withSeveralHashKeys: url) + + var pathParamsObjc: NSArray? + var queryParamsObjc: NSMutableDictionary? + AppDelegate.theDelegate().parseUniversalLinkFragment(url?.fragment, outPathParams: &pathParamsObjc, outQueryParams: &queryParamsObjc) + + // Sanity check + guard let pathParams = pathParamsObjc as? [String], pathParams.count > 0 else { + MXLog.error("[RoomViewController] Universal link: Error: No path parameters") + return + } + + var roomIdOrAliasParam: String? + var eventIdParam: String? + var userIdParam: String? + var groupIdParam: String? + + // Check permalink to room or event + if pathParams[0] == "room" && pathParams.count >= 2 { + + // The link is the form of "/room/[roomIdOrAlias]" or "/room/[roomIdOrAlias]/[eventId]" + roomIdOrAliasParam = pathParams[1] + + // Is it a link to an event of a room? + eventIdParam = pathParams.count >= 3 ? pathParams[2] : nil + + } else if pathParams[0] == "group" && pathParams.count >= 2 { + + // The link is the form of "/group/[groupId]" + groupIdParam = pathParams[1] + + } else if (pathParams[0].hasPrefix("#") || pathParams[0].hasPrefix("!")) && pathParams.count >= 1 { + + // The link is the form of "/#/[roomIdOrAlias]" or "/#/[roomIdOrAlias]/[eventId]" + // Such links come from matrix.to permalinks + roomIdOrAliasParam = pathParams[0] + eventIdParam = pathParams.count >= 2 ? pathParams[1] : nil + + } else if pathParams[0] == "user" && pathParams.count == 2 { // Check permalink to a user + // The link is the form of "/user/userId" + userIdParam = pathParams[1] + } else if pathParams[0].hasPrefix("@") && pathParams.count == 1 { + // The link is the form of "/#/[userId]" + // Such links come from matrix.to permalinks + userIdParam = pathParams[0] + } + + guard let roomIdOrAlias = roomIdOrAliasParam else { + AppDelegate.theDelegate().handleUniversalLinkURL(url) + return + } + + self.startActivityIndicator() + + var viaServers: [String] = [] + if let queryParams = queryParamsObjc as? [String: Any], let via = queryParams["via"] as? [String] { + viaServers = via + } + + if roomIdOrAlias.hasPrefix("#") { + self.mainSession.matrixRestClient.roomId(forRoomAlias: roomIdOrAlias) { [weak self] response in + guard let self = self else { + return + } + + guard let roomId = response.value else { + self.stopActivityIndicator() + + if response.error != nil { + let errorMessage = VectorL10n.roomDoesNotExist(roomIdOrAlias) + AppDelegate.theDelegate().showAlert(withTitle: nil, message: errorMessage) + } + return + } + + self.requestSummaryAndShowSpaceDetail(forRoomWithId: roomId, via: viaServers, from: url) + } + } else { + self.requestSummaryAndShowSpaceDetail(forRoomWithId: roomIdOrAlias, via: viaServers, from: url) + } + } + + private func requestSummaryAndShowSpaceDetail(forRoomWithId roomId: String, via: [String], from url: URL?) { + if self.mainSession.spaceService.getSpace(withId: roomId) != nil { + self.stopActivityIndicator() + self.showSpaceDetail(withId: roomId) + return + } + + self.mainSession.matrixRestClient.roomSummary(with: roomId, via: via) { [weak self] response in + guard let self = self else { + return + } + + self.stopActivityIndicator() + + guard let publicRoom = response.value, publicRoom.roomTypeString == MXRoomTypeString.space.rawValue else { + AppDelegate.theDelegate().handleUniversalLinkURL(url) + return + } + + self.showSpaceDetail(with: publicRoom) + } + } + +} diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 6e70591a4..7fa73286a 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -86,6 +86,10 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; */ - (void)displayRoomPreview:(RoomPreviewData*)roomPreviewData; +- (void)showSpaceDetailWithPublicRoom:(MXPublicRoom *)publicRoom; + +- (void)showSpaceDetailWithId:(NSString *)spaceId; + /** Action used to handle some buttons. */ diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index a8d316d4c..8e574e082 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -137,7 +137,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate> { // The preview header @@ -244,8 +244,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; +@property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden; @property (nonatomic, strong) VoiceMessageController *voiceMessageController; +@property (nonatomic, strong) SpaceDetailPresenter *spaceDetailPresenter; @end @@ -1423,12 +1425,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } } -- (void)setShowMissedDiscussionsBadge:(BOOL)showMissedDiscussionsBadge -{ - missedDiscussionsBadgeLabel.hidden = !showMissedDiscussionsBadge; - missedDiscussionsDotView.hidden = !showMissedDiscussionsBadge; -} - - (void)setScrollToBottomHidden:(BOOL)scrollToBottomHidden { if (_scrollToBottomHidden != scrollToBottomHidden) @@ -1452,6 +1448,13 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]; } +- (void)setMissedDiscussionsBadgeHidden:(BOOL)missedDiscussionsBadgeHidden{ + _missedDiscussionsBadgeHidden = missedDiscussionsBadgeHidden; + + missedDiscussionsBadgeLabel.hidden = missedDiscussionsBadgeHidden; + missedDiscussionsDotView.hidden = missedDiscussionsBadgeHidden; +} + #pragma mark - Internals - (UIBarButtonItem *)videoCallBarButtonItem @@ -2190,7 +2193,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } else { - return [[AppDelegate theDelegate] handleUniversalLinkURL:universalLinkURL]; + [self handleSpaceUniversalLinkWith:universalLinkURL]; + return YES; } } @@ -4961,16 +4965,16 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)refreshMissedDiscussionsCount:(BOOL)force { // Ignore this action when no room is displayed - if (!self.roomDataSource || !missedDiscussionsBadgeLabel + if (!self.showMissedDiscussionsBadge || !self.roomDataSource || !missedDiscussionsBadgeLabel || [UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPhone || ([[UIScreen mainScreen] nativeBounds].size.height > 2532 && UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation))) { - self.showMissedDiscussionsBadge = NO; + self.missedDiscussionsBadgeHidden = YES; return; } - self.showMissedDiscussionsBadge = YES; - + self.missedDiscussionsBadgeHidden = NO; + NSUInteger highlightCount = 0; NSUInteger missedCount = [[AppDelegate theDelegate].masterTabBarController missedDiscussionsCount]; @@ -6478,4 +6482,37 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]; } +- (void)showSpaceDetailWithPublicRoom:(MXPublicRoom *)publicRoom +{ + self.spaceDetailPresenter = [SpaceDetailPresenter new]; + self.spaceDetailPresenter.delegate = self; + [self.spaceDetailPresenter presentForSpaceWithPublicRoom:publicRoom from:self sourceView:nil session:self.mainSession animated:YES]; +} + +- (void)showSpaceDetailWithId:(NSString *)spaceId +{ + self.spaceDetailPresenter = [SpaceDetailPresenter new]; + self.spaceDetailPresenter.delegate = self; + [self.spaceDetailPresenter presentForSpaceWithId:spaceId from:self sourceView:nil session:self.mainSession animated:YES]; +} + +#pragma mark - SpaceDetailPresenterDelegate + +- (void)spaceDetailPresenterDidComplete:(SpaceDetailPresenter *)presenter +{ + self.spaceDetailPresenter = nil; +} + +- (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didOpenSpaceWithId:(NSString *)spaceId +{ + self.spaceDetailPresenter = nil; + [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; +} + +- (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didJoinSpaceWithId:(NSString *)spaceId +{ + self.spaceDetailPresenter = nil; + [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; +} + @end diff --git a/Riot/Modules/Room/Views/Avatar/RoomAvatarView.swift b/Riot/Modules/Room/Views/Avatar/RoomAvatarView.swift index 1b4612a36..6f6d79e86 100644 --- a/Riot/Modules/Room/Views/Avatar/RoomAvatarView.swift +++ b/Riot/Modules/Room/Views/Avatar/RoomAvatarView.swift @@ -25,7 +25,11 @@ final class RoomAvatarView: AvatarView, NibOwnerLoadable { @IBOutlet private weak var cameraBadgeContainerView: UIView! - // MARK: Setup + // MARK: Public + + var showCameraBadgeOnFallbackImage: Bool = false + + // MARK: - Setup private func commonInit() { } @@ -74,6 +78,14 @@ final class RoomAvatarView: AvatarView, NibOwnerLoadable { override func updateAvatarImageView(with viewData: AvatarViewDataProtocol) { super.updateAvatarImageView(with: viewData) - self.cameraBadgeContainerView.isHidden = viewData.avatarUrl != nil + let hideCameraBadge: Bool + + if self.showCameraBadgeOnFallbackImage { + hideCameraBadge = viewData.avatarUrl != nil + } else { + hideCameraBadge = true + } + + self.cameraBadgeContainerView.isHidden = hideCameraBadge } } diff --git a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift index bf6f4888d..ee5d3ad81 100644 --- a/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift +++ b/Riot/Modules/Room/Views/Avatar/RoomAvatarViewData.swift @@ -25,4 +25,8 @@ struct RoomAvatarViewData: AvatarViewDataProtocol { var matrixItemId: String { return roomId } + + var fallbackImage: AvatarFallbackImage? { + return .matrixItem(matrixItemId, displayName) + } } diff --git a/Riot/Modules/Room/Views/BubbleCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift b/Riot/Modules/Room/Views/BubbleCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift index 78958b362..671976e29 100644 --- a/Riot/Modules/Room/Views/BubbleCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift +++ b/Riot/Modules/Room/Views/BubbleCells/RoomCreationIntro/RoomCreationIntroCellContentView.swift @@ -70,6 +70,8 @@ final class RoomCreationIntroCellContentView: UIView, NibLoadable, Themable { self.addParticipantsButton.addTarget(self, action: #selector(socialButtonAction(_:)), for: .touchUpInside) self.addParticipantsLabel.text = VectorL10n.roomIntroCellAddParticipantsAction + + self.roomAvatarView.showCameraBadgeOnFallbackImage = true } override func layoutSubviews() { diff --git a/Riot/Modules/Rooms/RoomsViewController.m b/Riot/Modules/Rooms/RoomsViewController.m index 979b30610..19a37050b 100644 --- a/Riot/Modules/Rooms/RoomsViewController.m +++ b/Riot/Modules/Rooms/RoomsViewController.m @@ -103,8 +103,13 @@ - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { - // Hide the header to merge Invites and Rooms into a single list. - return 0.0; + if ([tableView numberOfSections] <= 1) + { + // Hide the header to merge Invites and Rooms into a single list. + return 0.0; + } + + return [super tableView:tableView heightForHeaderInSection:section]; } #pragma mark - @@ -155,7 +160,8 @@ - (NSUInteger)totalItemCounts { return recentsDataSource.conversationCellDataArray.count - + recentsDataSource.invitesCellDataArray.count; + + recentsDataSource.invitesCellDataArray.count + + recentsDataSource.suggestedRoomCellDataArray.count; } @end diff --git a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift index 9acb7bd23..099102014 100644 --- a/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift +++ b/Riot/Modules/Rooms/ShowDirectory/Cells/Room/DirectoryRoomTableViewCellVM.swift @@ -27,7 +27,19 @@ struct DirectoryRoomTableViewCellVM { // TODO: Use AvatarView subclass in the cell view func setAvatar(in avatarImageView: MXKImageView) { - let avatarImage = AvatarGenerator.generateAvatar(forMatrixItem: self.avatarViewData.matrixItemId, withDisplayName: title) + + let defaultAvatarImage: UIImage? + var defaultAvatarImageContentMode: UIView.ContentMode = .scaleAspectFill + + switch self.avatarViewData.fallbackImage { + case .matrixItem(let matrixItemId, let matrixItemDisplayName): + defaultAvatarImage = AvatarGenerator.generateAvatar(forMatrixItem: matrixItemId, withDisplayName: matrixItemDisplayName) + case .image(let image, let contentMode): + defaultAvatarImage = image + defaultAvatarImageContentMode = contentMode ?? .scaleAspectFill + case .none: + defaultAvatarImage = nil + } if let avatarUrl = self.avatarViewData.avatarUrl { avatarImageView.enableInMemoryCache = true @@ -37,10 +49,12 @@ struct DirectoryRoomTableViewCellVM { andImageOrientation: .up, toFitViewSize: avatarImageView.frame.size, with: MXThumbnailingMethodCrop, - previewImage: avatarImage, + previewImage: defaultAvatarImage, mediaManager: self.avatarViewData.mediaManager) + avatarImageView.contentMode = .scaleAspectFill } else { - avatarImageView.image = avatarImage + avatarImageView.image = defaultAvatarImage + avatarImageView.contentMode = defaultAvatarImageContentMode } } diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index 13414e382..fa79dbe75 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -22,16 +22,27 @@ import SideMenu import SafariServices class SideMenuCoordinatorParameters { + let appNavigator: AppNavigatorProtocol let userSessionsService: UserSessionsService let appInfo: AppInfo - init(userSessionsService: UserSessionsService, appInfo: AppInfo) { + init(appNavigator: AppNavigatorProtocol, + userSessionsService: UserSessionsService, + appInfo: AppInfo) { + self.appNavigator = appNavigator self.userSessionsService = userSessionsService self.appInfo = appInfo } } -final class SideMenuCoordinator: SideMenuCoordinatorType { +final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { + + // MARK: - Constants + + private enum SideMenu { + static let widthRatio: CGFloat = 0.82 + static let maxWidthiPad: CGFloat = 320.0 + } // MARK: - Properties @@ -40,12 +51,20 @@ final class SideMenuCoordinator: SideMenuCoordinatorType { private let parameters: SideMenuCoordinatorParameters private var sideMenuViewModel: SideMenuViewModelType + private weak var spaceListCoordinator: SpaceListCoordinatorType? + private lazy var sideMenuNavigationViewController: SideMenuNavigationController = { return self.createSideMenuNavigationController(with: self.sideMenuViewController) }() private let sideMenuViewController: SideMenuViewController + let spaceMenuPresenter = SpaceMenuPresenter() + let spaceDetailPresenter = SpaceDetailPresenter() + + private var exploreRoomCoordinator: ExploreRoomCoordinator? + private var membersCoordinator: SpaceMembersCoordinator? + // MARK: Public // Must be used only internally @@ -69,9 +88,13 @@ final class SideMenuCoordinator: SideMenuCoordinatorType { self.sideMenuViewModel.coordinatorDelegate = self self.sideMenuNavigationViewController.sideMenuDelegate = self + self.sideMenuNavigationViewController.dismissOnRotation = false // Set the sideMenuNavigationViewController as default left menu SideMenuManager.default.leftMenuNavigationController = self.sideMenuNavigationViewController + + self.addSpaceListIfNeeded() + self.registerUserSessionsServiceNotifications() } func toPresentable() -> UIViewController { @@ -86,21 +109,73 @@ final class SideMenuCoordinator: SideMenuCoordinatorType { return self.sideMenuNavigationViewController.sideMenuManager.addPanGestureToPresent(toView: view) } + func select(spaceWithId spaceId: String) { + self.spaceListCoordinator?.select(spaceWithId: spaceId) + } + // MARK: - Private private func createSideMenuNavigationController(with rootViewController: UIViewController) -> SideMenuNavigationController { var sideMenuSettings = SideMenuSettings() sideMenuSettings.presentationStyle = .viewSlideOut + sideMenuSettings.menuWidth = self.getMenuWidth() let navigationController = SideMenuNavigationController(rootViewController: rootViewController, settings: sideMenuSettings) - - // Present side menu to the left - navigationController.leftSide = true + + // FIX: SideMenuSettings are not taken into account at init apply them again + navigationController.settings = sideMenuSettings return navigationController } + private func getMenuWidth() -> CGFloat { + let appScreenRect = UIApplication.shared.keyWindow?.bounds ?? UIWindow().bounds + let minimumSize = min(appScreenRect.width, appScreenRect.height) + + let menuWidth: CGFloat + + if UIDevice.current.isPhone { + menuWidth = round(minimumSize * SideMenu.widthRatio) + } else { + // Set a max menu width on iPad + menuWidth = min(round(minimumSize * SideMenu.widthRatio), SideMenu.maxWidthiPad * SideMenu.widthRatio) + } + + return menuWidth + } + + private func addSpaceListIfNeeded() { + guard self.spaceListCoordinator == nil else { + return + } + + guard let mainMatrixSession = self.parameters.userSessionsService.mainUserSession?.matrixSession else { + return + } + + self.addSpaceList(with: mainMatrixSession) + } + + private func addSpaceList(with matrixSession: MXSession) { + let parameters = SpaceListCoordinatorParameters(session: matrixSession) + + let spaceListCoordinator = SpaceListCoordinator(parameters: parameters) + spaceListCoordinator.delegate = self + spaceListCoordinator.start() + + let spaceListPresentable = spaceListCoordinator.toPresentable() + + // sideMenuViewController.spaceListContainerView can be nil, load controller view to avoid this case + self.sideMenuViewController.loadViewIfNeeded() + + self.sideMenuViewController.vc_addChildViewController(viewController: spaceListPresentable, onView: self.sideMenuViewController.spaceListContainerView) + + self.add(childCoordinator: spaceListCoordinator) + + self.spaceListCoordinator = spaceListCoordinator + } + private func createSettingsViewController() -> SettingsViewController { let viewController: SettingsViewController = SettingsViewController.instantiate() viewController.loadViewIfNeeded() @@ -136,12 +211,65 @@ final class SideMenuCoordinator: SideMenuCoordinatorType { self.sideMenuNavigationViewController.present(safariViewController, animated: true, completion: nil) } + private func showExploreRooms(spaceId: String, session: MXSession) { + let exploreRoomCoordinator = ExploreRoomCoordinator(session: session, spaceId: spaceId) + exploreRoomCoordinator.delegate = self + let presentable = exploreRoomCoordinator.toPresentable() + presentable.presentationController?.delegate = self + self.sideMenuViewController.present(presentable, animated: true, completion: nil) + exploreRoomCoordinator.start() + + self.exploreRoomCoordinator = exploreRoomCoordinator + } + + private func showMembers(spaceId: String, session: MXSession) { + let parameters = SpaceMembersCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, session: session, spaceId: spaceId) + let spaceMembersCoordinator = SpaceMembersCoordinator(parameters: parameters) + spaceMembersCoordinator.delegate = self + let presentable = spaceMembersCoordinator.toPresentable() + presentable.presentationController?.delegate = self + self.sideMenuViewController.present(presentable, animated: true, completion: nil) + spaceMembersCoordinator.start() + + self.membersCoordinator = spaceMembersCoordinator + } + private func showInviteFriends(from sourceView: UIView?) { let myUserId = self.parameters.userSessionsService.mainUserSession?.userId ?? "" let inviteFriendsPresenter = InviteFriendsPresenter() inviteFriendsPresenter.present(for: myUserId, from: self.sideMenuViewController, sourceView: sourceView, animated: true) } + + private func showMenu(forSpaceWithId spaceId: String, from sourceView: UIView?) { + guard let session = self.parameters.userSessionsService.mainUserSession?.matrixSession else { + return + } + self.spaceMenuPresenter.delegate = self + 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) + } + + // MARK: UserSessions management + + private func registerUserSessionsServiceNotifications() { + + // Listen only notifications from the current UserSessionsService instance + let userSessionService = self.parameters.userSessionsService + + NotificationCenter.default.addObserver(self, selector: #selector(userSessionsServiceDidAddUserSession(_:)), name: UserSessionsService.didAddUserSession, object: userSessionService) + } + + @objc private func userSessionsServiceDidAddUserSession(_ notification: Notification) { + self.addSpaceListIfNeeded() + } } // MARK: - SideMenuViewModelCoordinatorDelegate @@ -179,3 +307,84 @@ extension SideMenuCoordinator: SideMenuNavigationControllerDelegate { func sideMenuDidDisappear(menu: SideMenuNavigationController, animated: Bool) { } } + +// MARK: - SideMenuNavigationControllerDelegate +extension SideMenuCoordinator: SpaceListCoordinatorDelegate { + func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) { + self.parameters.appNavigator.sideMenu.dismiss(animated: true) { + + } + self.parameters.appNavigator.navigate(to: .homeSpace) + } + + func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectSpaceWithId spaceId: String) { + self.parameters.appNavigator.sideMenu.dismiss(animated: true) { + + } + 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) + } +} + +// MARK: - SpaceMenuPresenterDelegate +extension SideMenuCoordinator: SpaceMenuPresenterDelegate { + func spaceMenuPresenter(_ presenter: SpaceMenuPresenter, didCompleteWith action: SpaceMenuPresenter.Actions, forSpaceWithId spaceId: String, with session: MXSession) { + presenter.dismiss(animated: false) { + switch action { + case .exploreRooms: + self.showExploreRooms(spaceId: spaceId, session: session) + case .exploreMembers: + self.showMembers(spaceId: spaceId, session: session) + } + } + } +} + +extension SideMenuCoordinator: SpaceDetailPresenterDelegate { + func spaceDetailPresenter(_ presenter: SpaceDetailPresenter, didJoinSpaceWithId spaceId: String) { + self.spaceListCoordinator?.select(spaceWithId: spaceId) + } + + func spaceDetailPresenter(_ presenter: SpaceDetailPresenter, didOpenSpaceWithId spaceId: String) { + // this use case cannot happen here as the space list open directly joined spaces on tap + self.spaceListCoordinator?.revertItemSelection() + } + + func spaceDetailPresenterDidComplete(_ presenter: SpaceDetailPresenter) { + self.spaceListCoordinator?.revertItemSelection() + } +} + +// MARK: - ExploreRoomCoordinatorDelegate +extension SideMenuCoordinator: ExploreRoomCoordinatorDelegate { + func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType) { + self.exploreRoomCoordinator?.toPresentable().dismiss(animated: true) { + self.exploreRoomCoordinator = nil + } + } +} + +// MARK: - SpaceMembersCoordinatorDelegate +extension SideMenuCoordinator: SpaceMembersCoordinatorDelegate { + func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) { + self.membersCoordinator?.toPresentable().dismiss(animated: true) { + self.membersCoordinator = nil + } + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension SideMenuCoordinator: UIAdaptivePresentationControllerDelegate { + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.exploreRoomCoordinator = nil + self.membersCoordinator = nil + } +} diff --git a/Riot/Modules/SideMenu/SideMenuCoordinatorType.swift b/Riot/Modules/SideMenu/SideMenuCoordinatorType.swift index 9223eca59..da72e8477 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinatorType.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinatorType.swift @@ -28,4 +28,5 @@ protocol SideMenuCoordinatorType: Coordinator, Presentable { @discardableResult func addScreenEdgePanGesturesToPresent(to view: UIView) -> UIScreenEdgePanGestureRecognizer @discardableResult func addPanGestureToPresent(to view: UIView) -> UIPanGestureRecognizer + func select(spaceWithId spaceId: String) } diff --git a/Riot/Modules/SideMenu/SideMenuViewController.storyboard b/Riot/Modules/SideMenu/SideMenuViewController.storyboard index 8b43a409d..998474b8d 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.storyboard +++ b/Riot/Modules/SideMenu/SideMenuViewController.storyboard @@ -16,126 +16,97 @@ - - + + - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + - - - - - - + - + + - - - - - + + + + - + - - - - + + + + + + + + + + + + - + diff --git a/Riot/Modules/SideMenu/SideMenuViewController.swift b/Riot/Modules/SideMenu/SideMenuViewController.swift index 57f98b059..f7fecb60c 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.swift +++ b/Riot/Modules/SideMenu/SideMenuViewController.swift @@ -29,8 +29,8 @@ final class SideMenuViewController: UIViewController { // MARK: - Properties // MARK: Outlets - - @IBOutlet private weak var scrollView: UIScrollView! + + @IBOutlet weak var spaceListContainerView: UIView! // User info @IBOutlet private weak var userAvatarView: UserAvatarView! @@ -103,7 +103,7 @@ final class SideMenuViewController: UIViewController { theme.applyStyle(onNavigationBar: navigationBar) } - self.view.backgroundColor = theme.headerBackgroundColor + self.view.backgroundColor = theme.colors.background self.userAvatarView.update(theme: theme) self.userDisplayNameLabel.textColor = theme.textPrimaryColor diff --git a/Riot/Modules/SideMenu/SideMenuViewModel.swift b/Riot/Modules/SideMenu/SideMenuViewModel.swift index 532543e17..c8baac060 100644 --- a/Riot/Modules/SideMenu/SideMenuViewModel.swift +++ b/Riot/Modules/SideMenu/SideMenuViewModel.swift @@ -71,11 +71,12 @@ final class SideMenuViewModel: SideMenuViewModelType { } private func userAvatarViewData(from mxSession: MXSession) -> UserAvatarViewData? { - guard let userId = mxSession.myUserId, let mediaManager = mxSession.mediaManager else { + guard let userId = mxSession.myUserId, let mediaManager = mxSession.mediaManager, let myUser = mxSession.myUser else { return nil } - let userDisplayName = mxSession.myUser.displayname - let avatarUrl = mxSession.myUser.avatarUrl + + let userDisplayName = myUser.displayname + let avatarUrl = myUser.avatarUrl return UserAvatarViewData(userId: userId, displayName: userDisplayName, @@ -103,7 +104,8 @@ final class SideMenuViewModel: SideMenuViewModelType { .feedback ] - let appVersion = self.appInfo.appVersion?.bundleShortVersion + // Hide app version + let appVersion: String? = nil let viewData = SideMenuViewData(userAvatarViewData: userAvatarViewData, sideMenuItems: sideMenuItems, appVersion: appVersion) diff --git a/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift new file mode 100644 index 000000000..3a109e20e --- /dev/null +++ b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift @@ -0,0 +1,103 @@ +// +// 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 Reusable + +final class SpaceAvatarView: AvatarView, NibOwnerLoadable { + + // MARK: - Constants + + private enum Constants { + static let cornerRadius: CGFloat = 8.0 + } + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet private weak var cameraBadgeContainerView: UIView! + + // MARK: Public + + var showCameraBadgeOnFallbackImage: Bool = false + + // MARK: - Setup + + private func commonInit() { + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.loadNibContent() + self.commonInit() + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.loadNibContent() + self.commonInit() + } + + // MARK: - Lifecycle + + override func layoutSubviews() { + super.layoutSubviews() + + self.avatarImageView.layer.cornerRadius = Constants.cornerRadius + } + + // MARK: - Public + + override func fill(with viewData: AvatarViewDataProtocol) { + self.updateAvatarImageView(with: viewData) + + // Fix layoutSubviews not triggered issue + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.setNeedsLayout() + } + } + + // MARK: - Overrides + + override func updateAccessibilityTraits() { + if self.isUserInteractionEnabled { + self.vc_setupAccessibilityTraitsButton(withTitle: VectorL10n.spaceAvatarViewAccessibilityLabel, hint: VectorL10n.spaceAvatarViewAccessibilityHint, isEnabled: true) + } else { + self.vc_setupAccessibilityTraitsImage(withTitle: VectorL10n.spaceAvatarViewAccessibilityLabel) + } + } + + override func updateAvatarImageView(with viewData: AvatarViewDataProtocol) { + super.updateAvatarImageView(with: viewData) + + let hideCameraBadge: Bool + + if self.showCameraBadgeOnFallbackImage { + hideCameraBadge = viewData.avatarUrl != nil + } else { + hideCameraBadge = true + } + + self.cameraBadgeContainerView.isHidden = hideCameraBadge + } + + override func update(theme: Theme) { + super.update(theme: theme) + + self.avatarImageView.defaultBackgroundColor = theme.colors.tile + } +} diff --git a/Riot/Modules/Spaces/Avatar/SpaceAvatarView.xib b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.xib new file mode 100644 index 000000000..b6ec9b58d --- /dev/null +++ b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.xib @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift new file mode 100644 index 000000000..39a331bc9 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailPresenter.swift @@ -0,0 +1,139 @@ +// +// 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 + + @objc 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 var session: MXSession! + private var spaceId: String! + + // MARK: - Public + + @objc 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.show(with: session) + } + + @objc func present(forSpaceWithPublicRoom publicRoom: MXPublicRoom, + from viewController: UIViewController, + sourceView: UIView?, + session: MXSession, + animated: Bool) { + self.session = session + self.spaceId = publicRoom.roomId + + self.viewModel = SpaceDetailViewModel(session: session, publicRoom: publicRoom) + self.viewModel.coordinatorDelegate = self + self.presentingViewController = viewController + self.sourceView = sourceView + + 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 spaceDetailViewModelDidJoin(_ viewModel: SpaceDetailViewModelType) { + self.dismiss(animated: true) { + self.delegate?.spaceDetailPresenter(self, didJoinSpaceWithId: self.spaceId) + } + } + + func spaceDetailViewModelDidOpen(_ viewModel: SpaceDetailViewModelType) { + self.dismiss(animated: false) { + self.delegate?.spaceDetailPresenter(self, didOpenSpaceWithId: self.spaceId) + } + } + + func spaceDetailViewModelDidCancel(_ viewModel: SpaceDetailViewModelType) { + self.dismiss(animated: true, completion: nil) + } + + func spaceDetailViewModelDidDismiss(_ viewModel: SpaceDetailViewModelType) { + self.delegate?.spaceDetailPresenterDidComplete(self) + } +} + +@objc protocol SpaceDetailPresenterDelegate: AnyObject { + func spaceDetailPresenterDidComplete(_ presenter: SpaceDetailPresenter) + func spaceDetailPresenter(_ presenter: SpaceDetailPresenter, didJoinSpaceWithId spaceId: String) + func spaceDetailPresenter(_ presenter: SpaceDetailPresenter, didOpenSpaceWithId spaceId: String) +} diff --git a/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift new file mode 100644 index 000000000..f883b9741 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewAction.swift @@ -0,0 +1,27 @@ +// +// 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 open + 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..817c03286 --- /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..95f7e7b19 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewController.swift @@ -0,0 +1,278 @@ +// +// 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! + private var isJoined: Bool = false + + // 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) { + if isJoined { + self.viewModel.process(viewAction: .open) + } else { + 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.acceptButton) + + self.declineButton.layer.borderColor = theme.colors.alert.cgColor + self.declineButton.tintColor = theme.colors.alert + self.declineButton.setTitleColor(theme.colors.alert, for: .normal) + self.declineButton.titleLabel?.font = theme.fonts.body + } + + 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) + self.declineButton.layer.borderWidth = 1.0 + } + + 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 parameters): + self.renderLoaded(parameters: parameters) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(parameters: SpaceDetailLoadedParameters) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + + switch parameters.membership { + case .invite: + self.joinButton.isHidden = true + self.inviteActionPanel.isHidden = false + case .join: + self.inviterPanelHeight.constant = 0 + self.joinButton.setTitle(VectorL10n.open, for: .normal) + self.isJoined = true + default: + self.inviterPanelHeight.constant = 0 + } + + let avatarViewData = AvatarViewData(matrixItemId: parameters.spaceId, displayName: parameters.displayName, avatarUrl: parameters.avatarUrl, mediaManager: self.mediaManager, fallbackImage: .matrixItem(parameters.spaceId, parameters.displayName)) + + self.titleLabel.text = parameters.displayName + self.avatarView.fill(with: avatarViewData) + self.topicLabel.text = parameters.topic + + let joinRuleString = parameters.joinRule == .public ? VectorL10n.spacePublicJoinRule : VectorL10n.spacePrivateJoinRule + + let membersCount = parameters.membersCount + let membersString = membersCount == 1 ? VectorL10n.roomTitleOneMember : VectorL10n.roomTitleMembers("\(membersCount)") + self.spaceTypeLabel.text = "\(joinRuleString) · \(membersString)" + + self.inviterIdLabel.text = parameters.inviterId + if let inviterId = parameters.inviterId { + self.inviterTitleLabel.text = "\(parameters.inviter?.displayname ?? inviterId) invited you" + + if let inviter = parameters.inviter { + let avatarViewData = AvatarViewData(matrixItemId: inviter.userId, displayName: inviter.displayname, avatarUrl: inviter.avatarUrl, mediaManager: self.mediaManager, fallbackImage: .matrixItem(inviter.userId, inviter.displayname)) + self.inviterAvatarView.fill(with: avatarViewData) + } + } + + view.layoutIfNeeded() + } + + 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..4ba44c856 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModel.swift @@ -0,0 +1,130 @@ +// +// 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 + private let publicRoom: MXPublicRoom? + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + self.publicRoom = nil + } + + init(session: MXSession, publicRoom: MXPublicRoom) { + self.session = session + self.publicRoom = publicRoom + self.spaceId = publicRoom.roomId + } + + // MARK: - Public + + func process(viewAction: SpaceDetailViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .join: + self.join() + case .leave: + self.leave() + case .open: + self.coordinatorDelegate?.spaceDetailViewModelDidOpen(self) + 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() { + if let publicRoom = self.publicRoom { + self.update(viewState: .loaded(SpaceDetailLoadedParameters(spaceId: publicRoom.roomId, displayName: publicRoom.displayname(), topic: publicRoom.topic, avatarUrl: publicRoom.avatarUrl, joinRule: publicRoom.worldReadable ? .public : .private, membership: .unknown, inviterId: nil, inviter: nil, membersCount: UInt(publicRoom.numJoinedMembers)))) + } else { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId), let summary = space.summary else { + MXLog.error("[SpaceDetailViewModel] setupViews: no space found") + return + } + + let parameters = SpaceDetailLoadedParameters(spaceId: space.spaceId, displayName: summary.displayname, topic: summary.topic, avatarUrl: summary.avatar, joinRule: nil, membership: summary.membership, inviterId: nil, inviter: nil, membersCount: 0) + self.update(viewState: .loaded(parameters)) + + 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) + } + }) + + let parameters = SpaceDetailLoadedParameters(spaceId: space.spaceId, displayName: summary.displayname, topic: summary.topic, avatarUrl: summary.avatar, joinRule: joinRule, membership: summary.membership, inviterId: inviterId, inviter: inviter, membersCount: membersCount) + self.update(viewState: .loaded(parameters)) + } + } + } + + 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.coordinatorDelegate?.spaceDetailViewModelDidJoin(self) + 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..ce4673f9e --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewModelType.swift @@ -0,0 +1,37 @@ +// +// 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) + func spaceDetailViewModelDidOpen(_ viewModel: SpaceDetailViewModelType) + func spaceDetailViewModelDidJoin(_ 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..bc345bdc6 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceDetail/SpaceDetailViewState.swift @@ -0,0 +1,36 @@ +// +// 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 + +struct SpaceDetailLoadedParameters { + let spaceId: String + let displayName: String? + let topic: String? + let avatarUrl: String? + let joinRule: MXRoomJoinRule? + let membership: MXMembership + let inviterId: String? + let inviter: MXUser? + let membersCount: UInt +} + +/// SpaceDetailViewController view state +enum SpaceDetailViewState { + case loading + case loaded(_ paremeters: SpaceDetailLoadedParameters) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift new file mode 100644 index 000000000..0618d665c --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift @@ -0,0 +1,88 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 UIKit + +/// Side menu space list +final class SpaceListCoordinator: SpaceListCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceListCoordinatorParameters + private var spaceListViewModel: SpaceListViewModelType + private let spaceListViewController: SpaceListViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceListCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceListCoordinatorParameters) { + self.parameters = parameters + + let spaceListViewModel = SpaceListViewModel(session: self.parameters.session) + let spaceListViewController = SpaceListViewController.instantiate(with: spaceListViewModel) + self.spaceListViewModel = spaceListViewModel + self.spaceListViewController = spaceListViewController + } + + // MARK: - Public methods + + func start() { + self.spaceListViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.spaceListViewController + } + + func revertItemSelection() { + self.spaceListViewModel.revertItemSelection() + } + + func select(spaceWithId spaceId: String) { + self.spaceListViewModel.select(spaceWithId: spaceId) + } +} + +// MARK: - SpaceListViewModelCoordinatorDelegate +extension SpaceListCoordinator: SpaceListViewModelCoordinatorDelegate { + + func spaceListViewModelDidSelectHomeSpace(_ viewModel: SpaceListViewModelType) { + self.delegate?.spaceListCoordinatorDidSelectHomeSpace(self) + } + + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectSpaceWithId spaceId: String) { + 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/SpaceListCoordinatorParameters.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorParameters.swift new file mode 100644 index 000000000..2c7aa50e7 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorParameters.swift @@ -0,0 +1,25 @@ +// +// 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 SpaceListCoordinatorParameters { + let session: MXSession + + init(session: MXSession) { + self.session = session + } +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift new file mode 100644 index 000000000..8ab4815bc --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift @@ -0,0 +1,33 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 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() + func select(spaceWithId spaceId: String) +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift b/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift new file mode 100644 index 000000000..34af5417f --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListItemViewData.swift @@ -0,0 +1,27 @@ +// +// 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 + +/// SpaceListViewCell view data +struct SpaceListItemViewData { + let spaceId: String + let title: String + let avatarViewData: AvatarViewDataProtocol + let isInvite: Bool + let notificationCount: UInt + let highlightedNotificationCount: UInt +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift new file mode 100644 index 000000000..f4cc0c399 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift @@ -0,0 +1,23 @@ +// +// 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 + +/// Space list sections +enum SpaceListSection { + case home(_ viewData: SpaceListItemViewData) + case spaces(_ viewDataList: [SpaceListItemViewData]) +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift new file mode 100644 index 000000000..3db44f5f4 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewAction.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 + +/// SpaceListViewController view actions exposed to view model +enum SpaceListViewAction { + case loadData + 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 new file mode 100644 index 000000000..d0ea314dd --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift @@ -0,0 +1,97 @@ +// +// 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 UIKit +import Reusable + +protocol SpaceListViewCellDelegate: AnyObject { + func spaceListViewCell(_ cell: SpaceListViewCell, didPressMore button: UIButton) +} + +final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { + + // MARK: - Properties + + @IBOutlet private weak var avatarView: SpaceAvatarView! + @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? + + private var theme: Theme? + private var isBadgeAlert: Bool = false + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.selectionView.layer.cornerRadius = 8.0 + self.selectionView.layer.masksToBounds = true + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + UIView.animate(withDuration: animated ? 0.3 : 0.0) { + self.selectionView.alpha = selected ? 1.0 : 0.0 + } + } + + // MARK: - Public + + func fill(with viewData: SpaceListItemViewData) { + self.avatarView.fill(with: viewData.avatarViewData) + self.titleLabel.text = viewData.title + self.moreButton.isHidden = viewData.spaceId == SpaceListViewModel.Constants.homeSpaceId || viewData.isInvite + if viewData.isInvite { + self.isBadgeAlert = true + self.badgeLabel.isHidden = false + if let theme = self.theme { + self.badgeLabel.badgeColor = theme.colors.alert + } + self.badgeLabel.text = "!" + } else { + self.isBadgeAlert = viewData.highlightedNotificationCount > 0 + let notificationCount = viewData.notificationCount + viewData.highlightedNotificationCount + self.badgeLabel.isHidden = notificationCount == 0 + if let theme = self.theme { + self.badgeLabel.badgeColor = viewData.highlightedNotificationCount == 0 ? theme.colors.tertiaryContent : theme.colors.alert + } + self.badgeLabel.text = "\(notificationCount)" + } + } + + func update(theme: Theme) { + self.theme = theme + self.backgroundColor = theme.colors.background + self.avatarView.update(theme: theme) + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.calloutSB + self.selectionView.backgroundColor = theme.colors.separator + self.moreButton.tintColor = theme.colors.secondaryContent + self.badgeLabel.borderColor = theme.colors.background + self.badgeLabel.badgeColor = self.isBadgeAlert ? theme.colors.alert : theme.colors.tertiaryContent + } + + // MARK: - IBActions + + @IBAction private func moreAction(sender: UIButton) { + delegate?.spaceListViewCell(self, didPressMore: self.moreButton) + } +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib new file mode 100644 index 000000000..facf89a8c --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.xib @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.storyboard b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.storyboard new file mode 100644 index 000000000..3d14f2100 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.storyboard @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift new file mode 100644 index 000000000..79f5f2dba --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift @@ -0,0 +1,221 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 + +final class SpaceListViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let estimatedRowHeight: CGFloat = 46.0 + } + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet weak var tableView: UITableView! + @IBOutlet weak var titleLabel: UILabel! + + // MARK: Private + + private var viewModel: SpaceListViewModelType! + private var theme: Theme! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + private var sections: [SpaceListSection] = [] + + // MARK: - Setup + + class func instantiate(with viewModel: SpaceListViewModelType) -> SpaceListViewController { + let viewController = StoryboardScene.SpaceListViewController.initialScene.instantiate() + 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 + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.colors.background + self.tableView.backgroundColor = theme.colors.background + + self.tableView.reloadData() + + self.titleLabel.textColor = theme.colors.primaryContent + self.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.setupTableView() + self.titleLabel.text = VectorL10n.spacesLeftPanelTitle + } + + private func setupTableView() { + self.tableView.separatorStyle = .none + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.estimatedRowHeight = Constants.estimatedRowHeight + self.tableView.allowsSelection = true + self.tableView.register(cellType: SpaceListViewCell.self) + self.tableView.tableFooterView = UIView() + } + + private func render(viewState: SpaceListViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let sections): + self.renderLoaded(sections: sections) + case .selectionChanged(let indexPath): + self.renderSelectionChanged(at: indexPath) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + if let selectedRow = self.tableView.indexPathForSelectedRow { + self.tableView.deselectRow(at: selectedRow, animated: true) + } + } + + private func renderLoaded(sections: [SpaceListSection]) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.sections = sections + self.tableView.reloadData() + } + + private func renderSelectionChanged(at indexPath: IndexPath) { + self.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } +} + + +// MARK: - SpaceListViewModelViewDelegate +extension SpaceListViewController: SpaceListViewModelViewDelegate { + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didUpdateViewState viewSate: SpaceListViewState) { + self.render(viewState: viewSate) + } +} + +// MARK: - UITableViewDataSource +extension SpaceListViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return self.sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + + let numberOfRows: Int + + let spaceListSection = self.sections[section] + + switch spaceListSection { + case .home: + numberOfRows = 1 + case .spaces(let viewDataList): + numberOfRows = viewDataList.count + } + + return numberOfRows + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(for: indexPath, cellType: SpaceListViewCell.self) + + let viewData: SpaceListItemViewData + + let spaceListSection = self.sections[indexPath.section] + + switch spaceListSection { + case .home(let spaceViewData): + viewData = spaceViewData + case .spaces(let viewDataList): + viewData = viewDataList[indexPath.row] + } + + cell.update(theme: self.theme) + cell.fill(with: viewData) + cell.selectionStyle = .none + cell.delegate = self + + return cell + } +} + +// MARK: - UITableViewDelegate +extension SpaceListViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + self.viewModel.process(viewAction: .selectRow(at: indexPath, from: tableView.cellForRow(at: indexPath))) + } +} + +// MARK: - SpaceListViewCellDelegate +extension SpaceListViewController: SpaceListViewCellDelegate { + + func spaceListViewCell(_ cell: SpaceListViewCell, didPressMore button: UIButton) { + guard let indexPath = self.tableView.indexPath(for: cell) else { + MXLog.warning("[SpaceListViewController] didPressMore called from invalid cell.") + return + } + self.viewModel.process(viewAction: .moreAction(at: indexPath, from: button)) + } +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift new file mode 100644 index 000000000..85e44c94b --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift @@ -0,0 +1,239 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 + +final class SpaceListViewModel: SpaceListViewModelType { + + // MARK: - Constants + + enum Constants { + static let homeSpaceId: String = "home" + } + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + + private var currentOperation: MXHTTPOperation? + private var sections: [SpaceListSection] = [] + private var selectedIndexPath: IndexPath = IndexPath(row: 0, section: 0) { + didSet { + self.selectedItemId = self.itemId(with: self.selectedIndexPath) + } + } + private var homeIndexPath: IndexPath = IndexPath(row: 0, section: 0) + private var selectedItemId: String = Constants.homeSpaceId + + // MARK: Public + + weak var viewDelegate: SpaceListViewModelViewDelegate? + weak var coordinatorDelegate: SpaceListViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession) { + self.session = session + + NotificationCenter.default.addObserver(self, selector: #selector(self.sessionDidSync(notification:)), name: MXSpaceService.didBuildSpaceGraph, object: nil) + + } + + deinit { + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: SpaceListViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .selectRow(at: let indexPath, from: let sourceView): + guard self.selectedIndexPath != indexPath else { + return + } + let section = self.sections[indexPath.section] + switch section { + case .home: + self.selectHome() + self.selectedIndexPath = indexPath + self.update(viewState: .selectionChanged(indexPath)) + case .spaces(let viewDataList): + let spaceViewData = viewDataList[indexPath.row] + if spaceViewData.isInvite { + self.selectInvite(with: spaceViewData.spaceId, from: sourceView) + } else { + self.selectSpace(with: spaceViewData.spaceId) + self.selectedIndexPath = indexPath + self.update(viewState: .selectionChanged(indexPath)) + } + } + case .moreAction(at: let indexPath, from: let sourceView): + let section = self.sections[indexPath.section] + switch section { + case .home: break + case .spaces(let viewDataList): + let spaceViewData = viewDataList[indexPath.row] + self.coordinatorDelegate?.spaceListViewModel(self, didPressMoreForSpaceWithId: spaceViewData.spaceId, from: sourceView) + } + } + } + + func revertItemSelection() { + self.update(viewState: .selectionChanged(self.selectedIndexPath)) + } + + func select(spaceWithId spaceId: String) { + for (sectionIndex, section) in self.sections.enumerated() { + switch section { + case .home: break + case .spaces(let viewDataList): + for (row, itemViewData) in viewDataList.enumerated() where itemViewData.spaceId == spaceId { + let indexPath = IndexPath(row: row, section: sectionIndex) + self.selectSpace(with: spaceId) + self.selectedIndexPath = indexPath + self.update(viewState: .selectionChanged(indexPath)) + } + } + } + } + + // MARK: - Private + + @objc private func sessionDidSync(notification: Notification) { + loadData() + } + + private func loadData() { + guard session.mediaManager != nil else { + return + } + + self.update(viewState: .loading) + + let homeViewData = self.createHomeViewData() + let viewDataList = getSpacesViewData() + + let sections: [SpaceListSection] = viewDataList.invites.isEmpty ? [ + .home(homeViewData), + .spaces(viewDataList.spaces) + ] + : + [ + .spaces(viewDataList.invites), + .home(homeViewData), + .spaces(viewDataList.spaces) + ] + + self.sections = sections + let homeIndexPath = viewDataList.invites.isEmpty ? IndexPath(row: 0, section: 0) : IndexPath(row: 0, section: 1) + if self.selectedIndexPath.section == self.homeIndexPath.section { + self.selectedIndexPath = homeIndexPath + } else if self.selectedItemId != self.itemId(with: self.selectedIndexPath) { + var newSelection: IndexPath? + let section = sections.last + switch section { + case .home: + break + case .spaces(let viewDataList): + var index = 0 + for itemViewData in viewDataList { + if itemViewData.spaceId == self.selectedItemId { + newSelection = IndexPath(row: index, section: sections.count - 1) + } + index += 1 + } + case .none: + break + } + + if let selection = newSelection { + self.selectedIndexPath = selection + } else { + self.selectedIndexPath = homeIndexPath + self.coordinatorDelegate?.spaceListViewModelDidSelectHomeSpace(self) + } + } + self.homeIndexPath = homeIndexPath + self.update(viewState: .loaded(sections)) + self.update(viewState: .selectionChanged(self.selectedIndexPath)) + } + + private func selectHome() { + self.coordinatorDelegate?.spaceListViewModelDidSelectHomeSpace(self) + } + + private func selectSpace(with spaceId: String) { + self.coordinatorDelegate?.spaceListViewModel(self, didSelectSpaceWithId: spaceId) + } + + private func selectInvite(with spaceId: String, from sourceView: UIView?) { + self.coordinatorDelegate?.spaceListViewModel(self, didSelectInviteWithId: spaceId, from: sourceView) + } + + private func createHomeViewData() -> SpaceListItemViewData { + let avatarViewData = AvatarViewData(matrixItemId: Constants.homeSpaceId, displayName: nil, avatarUrl: nil, mediaManager: self.session.mediaManager, fallbackImage: .image(Asset.Images.spaceHomeIcon.image, .center)) + + let homeNotificationState = session.spaceService.notificationCounter.homeNotificationState + let homeViewData = SpaceListItemViewData(spaceId: Constants.homeSpaceId, + title: VectorL10n.spacesHomeSpaceTitle, avatarViewData: avatarViewData, isInvite: false, notificationCount: homeNotificationState.allCount, highlightedNotificationCount: homeNotificationState.allHighlightCount) + return homeViewData + } + + private func getSpacesViewData() -> (invites: [SpaceListItemViewData], spaces: [SpaceListItemViewData]) { + var invites: [SpaceListItemViewData] = [] + var spaces: [SpaceListItemViewData] = [] + session.spaceService.rootSpaceSummaries.forEach { summary in + let avatarViewData = AvatarViewData(matrixItemId: summary.roomId, displayName: summary.displayname, avatarUrl: summary.avatar, mediaManager: self.session.mediaManager, fallbackImage: .matrixItem(summary.roomId, summary.displayname)) + let notificationState = self.session.spaceService.notificationCounter.notificationState(forSpaceWithId: summary.roomId) + let viewData = SpaceListItemViewData(spaceId: summary.roomId, title: summary.displayname, avatarViewData: avatarViewData, isInvite: summary.membership == .invite, notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0) + if viewData.isInvite { + invites.append(viewData) + } else { + spaces.append(viewData) + } + } + + return (invites, spaces) + } + + private func update(viewState: SpaceListViewState) { + self.viewDelegate?.spaceListViewModel(self, didUpdateViewState: viewState) + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } + + private func itemId(with indexPath: IndexPath) -> String { + guard self.selectedIndexPath.section < self.sections.count else { + return Constants.homeSpaceId + } + let section = self.sections[self.selectedIndexPath.section] + switch section { + case .home: + return Constants.homeSpaceId + case .spaces(let viewDataList): + let spaceViewData = viewDataList[self.selectedIndexPath.row] + return spaceViewData.spaceId + } + } +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift new file mode 100644 index 000000000..00066a70d --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift @@ -0,0 +1,41 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 SpaceListViewModelViewDelegate: AnyObject { + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didUpdateViewState viewSate: SpaceListViewState) +} + +protocol SpaceListViewModelCoordinatorDelegate: AnyObject { + func spaceListViewModelDidSelectHomeSpace(_ viewModel: SpaceListViewModelType) + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectSpaceWithId spaceId: String) + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) + func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) +} + +/// Protocol describing the view model used by `SpaceListViewController` +protocol SpaceListViewModelType { + + var viewDelegate: SpaceListViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceListViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceListViewAction) + func revertItemSelection() + func select(spaceWithId spaceId: String) +} diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewState.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewState.swift new file mode 100644 index 000000000..451dff10f --- /dev/null +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewState.swift @@ -0,0 +1,27 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceList SpaceList +/* + Copyright 2020 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 + +/// SpaceListViewController view state +enum SpaceListViewState { + case loading + case loaded(_ sections: [SpaceListSection]) + case selectionChanged(_ indexPath: IndexPath) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift new file mode 100644 index 000000000..fa62c75b5 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift @@ -0,0 +1,94 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 UIKit + +struct SpaceMemberDetailCoordinatorParameters { + let userSessionsService: UserSessionsService + let member: MXRoomMember + let session: MXSession + let spaceId: String +} + +final class SpaceMemberDetailCoordinator: NSObject, SpaceMemberDetailCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceMemberDetailCoordinatorParameters + + private var spaceMemberDetailViewModel: SpaceMemberDetailViewModelType + private let spaceMemberDetailViewController: SpaceMemberDetailViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceMemberDetailCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceMemberDetailCoordinatorParameters) { + self.parameters = parameters + + let spaceMemberDetailViewModel = SpaceMemberDetailViewModel(userSessionsService: parameters.userSessionsService, session: parameters.session, member: parameters.member, spaceId: parameters.spaceId) + let spaceMemberDetailViewController = SpaceMemberDetailViewController.instantiate(with: spaceMemberDetailViewModel) + spaceMemberDetailViewController.enableMention = true + spaceMemberDetailViewController.enableVoipCall = false + spaceMemberDetailViewController.enableLeave = false + + self.spaceMemberDetailViewModel = spaceMemberDetailViewModel + self.spaceMemberDetailViewController = spaceMemberDetailViewController + } + + // MARK: - Public methods + + func start() { + self.spaceMemberDetailViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.spaceMemberDetailViewController + } +} + +// MARK: - SpaceMemberDetailViewModelCoordinatorDelegate +extension SpaceMemberDetailCoordinator: SpaceMemberDetailViewModelCoordinatorDelegate { + + func spaceMemberDetailViewModel(_ viewModel: SpaceMemberDetailViewModelType, showRoomWithId roomId: String) { + self.delegate?.spaceMemberDetailCoordinator(self, showRoomWithId: roomId) + } + + func spaceMemberDetailViewModelDidCancel(_ viewModel: SpaceMemberDetailViewModelType) { + self.delegate?.spaceMemberDetailCoordinatorDidCancel(self) + } + +} + +// MARK: - MXKRoomMemberDetailsViewControllerDelegate +extension SpaceMemberDetailCoordinator: MXKRoomMemberDetailsViewControllerDelegate { + + func roomMemberDetailsViewController(_ roomMemberDetailsViewController: MXKRoomMemberDetailsViewController!, startChatWithMemberId memberId: String!, completion: (() -> Void)!) { + completion() + self.spaceMemberDetailViewModel.process(viewAction: .createRoom(memberId)) + } + +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinatorType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinatorType.swift new file mode 100644 index 000000000..f1e09ec84 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinatorType.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 SpaceMemberDetailCoordinatorDelegate: AnyObject { + func spaceMemberDetailCoordinator(_ coordinator: SpaceMemberDetailCoordinatorType, showRoomWithId roomId: String) + func spaceMemberDetailCoordinatorDidCancel(_ coordinator: SpaceMemberDetailCoordinatorType) +} + +/// `SpaceMemberDetailCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. +protocol SpaceMemberDetailCoordinatorType: Coordinator, Presentable { + var delegate: SpaceMemberDetailCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewAction.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewAction.swift new file mode 100644 index 000000000..98c2ecca1 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewAction.swift @@ -0,0 +1,27 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 + +/// SpaceMemberDetailViewController view actions exposed to view model +enum SpaceMemberDetailViewAction { + case loadData + case openRoom(_ roomId: String) + case createRoom(_ memberId: String) + case cancel +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift new file mode 100644 index 000000000..136419f8d --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift @@ -0,0 +1,171 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 UIKit + +final class SpaceMemberDetailViewController: RoomMemberDetailsViewController { + + // MARK: - Constants + + override class func nib() -> UINib! { + return UINib(nibName: "RoomMemberDetailsViewController", bundle: Bundle(for: RoomMemberDetailsViewController.self)) + } + + // MARK: - Properties + + // MARK: Outlets + + // MARK: Private + + private var viewModel: SpaceMemberDetailViewModelType! + private var theme: Theme! + private var keyboardAvoider: KeyboardAvoider? + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + // MARK: - Setup + + class func instantiate(with viewModel: SpaceMemberDetailViewModelType) -> SpaceMemberDetailViewController { + let viewController = SpaceMemberDetailViewController() + 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.keyboardAvoider = KeyboardAvoider(scrollViewContainerView: self.view, scrollView: self.tableView) + self.activityPresenter = ActivityIndicatorPresenter() + self.errorPresenter = MXKErrorAlertPresentation() + + self.registerThemeServiceDidChangeThemeNotification() + self.update(theme: self.theme) + + self.viewModel.viewDelegate = self + self.viewModel.process(viewAction: .loadData) + + self.delegate = self + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.keyboardAvoider?.startAvoiding() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.keyboardAvoider?.stopAvoiding() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + } + + 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() { + let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in + self?.cancelButtonAction() + } + + self.navigationItem.rightBarButtonItem = cancelBarButtonItem + } + + private func render(viewState: SpaceMemberDetailViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let member, let space): + self.renderLoaded(member: member, space: space) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(member: MXRoomMember, space: MXRoom?) { + self.display(member, withMatrixRoom: space) + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + // MARK: - RoomMemberDetailsViewController private + + @objc private func showRoom(withId roomId: String!) { + self.viewModel.process(viewAction: .openRoom(roomId)) + } + + // MARK: - Actions + + private func cancelButtonAction() { + self.viewModel.process(viewAction: .cancel) + } +} + + +// MARK: - SpaceMemberDetailViewModelViewDelegate +extension SpaceMemberDetailViewController: SpaceMemberDetailViewModelViewDelegate { + + func spaceMemberDetailViewModel(_ viewModel: SpaceMemberDetailViewModelType, didUpdateViewState viewSate: SpaceMemberDetailViewState) { + self.render(viewState: viewSate) + } +} + +// MARK: - MXKRoomMemberDetailsViewControllerDelegate +extension SpaceMemberDetailViewController: MXKRoomMemberDetailsViewControllerDelegate { + + func roomMemberDetailsViewController(_ roomMemberDetailsViewController: MXKRoomMemberDetailsViewController!, startChatWithMemberId memberId: String!, completion: (() -> Void)!) { + completion() + self.viewModel.process(viewAction: .createRoom(memberId)) + } + +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift new file mode 100644 index 000000000..f462dda89 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift @@ -0,0 +1,122 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 + +final class SpaceMemberDetailViewModel: NSObject, SpaceMemberDetailViewModelType { + + // MARK: - Properties + + // MARK: Private + + private let userSessionsService: UserSessionsService + private let session: MXSession + private let member: MXRoomMember + private let spaceId: String + private var space: MXSpace? + + private var currentOperation: MXHTTPOperation? + + // MARK: Public + + weak var viewDelegate: SpaceMemberDetailViewModelViewDelegate? + weak var coordinatorDelegate: SpaceMemberDetailViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(userSessionsService: UserSessionsService, session: MXSession, member: MXRoomMember, spaceId: String) { + self.userSessionsService = userSessionsService + self.session = session + self.member = member + self.spaceId = spaceId + } + + deinit { + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: SpaceMemberDetailViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .openRoom(let roomId): + self.coordinatorDelegate?.spaceMemberDetailViewModel(self, showRoomWithId: roomId) + case .createRoom(let memberId): + self.createDirectRoom(forMemberWithId: memberId) + case .cancel: + self.cancelOperations() + self.coordinatorDelegate?.spaceMemberDetailViewModelDidCancel(self) + } + } + + // MARK: - Private + + private func loadData() { + self.space = self.session.spaceService.getSpace(withId: self.spaceId) + self.update(viewState: .loaded(self.member, self.space?.room)) + } + + private func update(viewState: SpaceMemberDetailViewState) { + self.viewDelegate?.spaceMemberDetailViewModel(self, didUpdateViewState: viewState) + } + + private func createDirectRoom(forMemberWithId memberId: String) { + self.update(viewState: .loading) + guard let account = self.userSessionsService.mainUserSession?.account, let session = account.mxSession else { + self.update(viewState: .loaded(self.member, self.space?.room)) + return + } + + let invite: [String]? = (session.myUserId != memberId) ? [memberId] : nil + self.currentOperation = session.vc_canEnableE2EByDefaultInNewRoom(withUsers: invite) { canEnableE2E in + self.currentOperation = nil + let roomCreationParameters = MXRoomCreationParameters() + roomCreationParameters.visibility = kMXRoomDirectoryVisibilityPrivate + roomCreationParameters.inviteArray = invite + roomCreationParameters.isDirect = !(invite?.isEmpty ?? true) + roomCreationParameters.preset = kMXRoomPresetTrustedPrivateChat + + if canEnableE2E { + roomCreationParameters.initialStateEvents = [MXRoomCreationParameters.initialStateEventForEncryption(withAlgorithm: kMXCryptoMegolmAlgorithm)] + } + + self.currentOperation = session.createRoom(parameters: roomCreationParameters) { response in + self.currentOperation = nil + self.update(viewState: .loaded(self.member, self.space?.room)) + guard response.isSuccess, let room = response.value else { + if let error = response.error { + self.update(viewState: .error(error)) + } + return + } + self.coordinatorDelegate?.spaceMemberDetailViewModel(self, showRoomWithId: room.roomId) + } + } failure: { error in + self.update(viewState: .loaded(self.member, self.space?.room)) + if let error = error { + self.update(viewState: .error(error)) + } + } + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift new file mode 100644 index 000000000..86d7e8b7d --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift @@ -0,0 +1,37 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 SpaceMemberDetailViewModelViewDelegate: AnyObject { + func spaceMemberDetailViewModel(_ viewModel: SpaceMemberDetailViewModelType, didUpdateViewState viewSate: SpaceMemberDetailViewState) +} + +protocol SpaceMemberDetailViewModelCoordinatorDelegate: AnyObject { + func spaceMemberDetailViewModel(_ viewModel: SpaceMemberDetailViewModelType, showRoomWithId roomId: String) + func spaceMemberDetailViewModelDidCancel(_ viewModel: SpaceMemberDetailViewModelType) +} + +/// Protocol describing the view model used by `SpaceMemberDetailViewController` +protocol SpaceMemberDetailViewModelType { + + var viewDelegate: SpaceMemberDetailViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceMemberDetailViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceMemberDetailViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewState.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewState.swift new file mode 100644 index 000000000..e02893efd --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewState.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberDetail ShowSpaceMemberDetail +/* + 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 + +/// SpaceMemberDetailViewController view state +enum SpaceMemberDetailViewState { + case loading + case loaded(MXRoomMember, MXRoom?) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SeachEmptyView.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SeachEmptyView.swift new file mode 100644 index 000000000..06982a645 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SeachEmptyView.swift @@ -0,0 +1,67 @@ +// +// 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 + +@objcMembers +class SearchEmptyView: UIStackView, Themable { + + // MARK: - Properties + + public private(set) var titleLabel: UILabel! + public private(set) var detailLabel: UILabel! + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + // MARK: - Themable + + func update(theme: Theme) { + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.bodySB + + self.detailLabel.textColor = theme.colors.secondaryContent + self.detailLabel.font = theme.fonts.callout + } + + // MARK: - Private + + private func setupView() { + self.titleLabel = UILabel(frame: .zero) + self.titleLabel.backgroundColor = .clear + self.titleLabel.numberOfLines = 0 + + self.detailLabel = UILabel(frame: .zero) + self.detailLabel.backgroundColor = .clear + self.detailLabel.numberOfLines = 0 + + self.addArrangedSubview(titleLabel) + self.addArrangedSubview(detailLabel) + self.distribution = .equalSpacing + self.axis = .vertical + self.alignment = .leading + self.spacing = 8 + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift new file mode 100644 index 000000000..77ec12f62 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift @@ -0,0 +1,73 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 UIKit + +final class SpaceMemberListCoordinator: SpaceMemberListCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let spaceId: String + private var spaceMemberListViewModel: SpaceMemberListViewModelType + private let spaceMemberListViewController: SpaceMemberListViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceMemberListCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + + let spaceMemberListViewModel = SpaceMemberListViewModel(session: self.session, spaceId: self.spaceId) + let spaceMemberListViewController = SpaceMemberListViewController.instantiate(with: spaceMemberListViewModel) + self.spaceMemberListViewModel = spaceMemberListViewModel + self.spaceMemberListViewController = spaceMemberListViewController + } + + // MARK: - Public methods + + func start() { + self.spaceMemberListViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.spaceMemberListViewController + } +} + +// MARK: - SpaceMemberListViewModelCoordinatorDelegate +extension SpaceMemberListCoordinator: SpaceMemberListViewModelCoordinatorDelegate { + + func spaceMemberListViewModel(_ viewModel: SpaceMemberListViewModelType, didSelect member: MXRoomMember, from sourceView: UIView?) { + self.delegate?.spaceMemberListCoordinator(self, didSelect: member, from: sourceView) + } + + func spaceMemberListViewModelDidCancel(_ viewModel: SpaceMemberListViewModelType) { + self.delegate?.spaceMemberListCoordinatorDidCancel(self) + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift new file mode 100644 index 000000000..43225f787 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 SpaceMemberListCoordinatorDelegate: AnyObject { + func spaceMemberListCoordinator(_ coordinator: SpaceMemberListCoordinatorType, didSelect member: MXRoomMember, from sourceView: UIView?) + func spaceMemberListCoordinatorDidCancel(_ coordinator: SpaceMemberListCoordinatorType) +} + +/// `SpaceMemberListCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. +protocol SpaceMemberListCoordinatorType: Coordinator, Presentable { + var delegate: SpaceMemberListCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift new file mode 100644 index 000000000..83118ce29 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 + +/// SpaceMemberListViewController view actions exposed to view model +enum SpaceMemberListViewAction { + case loadData + case complete(_ selectedMember: MXRoomMember, _ sourceView: UIView?) + case cancel +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift new file mode 100644 index 000000000..d05076758 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift @@ -0,0 +1,202 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 UIKit + +final class SpaceMemberListViewController: RoomParticipantsViewController { + + // MARK: - Constants + + private enum Constants { + static let emptySearchViewMargin: CGFloat = 8 + } + + // MARK: - Properties + + // MARK: Private + + private var viewModel: SpaceMemberListViewModelType! + private var theme: Theme! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + private var titleView: MainTitleView! + private var emptyView: SearchEmptyView! + + private var emptyViewArtwork: UIImage { + return ThemeService.shared().isCurrentThemeDark() ? Asset.Images.peopleEmptyScreenArtworkDark.image : Asset.Images.peopleEmptyScreenArtwork.image + } + + // MARK: - Setup + + class func instantiate(with viewModel: SpaceMemberListViewModelType) -> SpaceMemberListViewController { + let viewController = SpaceMemberListViewController() + viewController.viewModel = viewModel + viewController.showParticipantCustomAccessoryView = false + viewController.theme = ThemeService.shared().theme + viewController.emptyView = SearchEmptyView() + 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) + + self.title = "" + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + theme.applyStyle(onSearchBar: self.searchBarView) + self.titleView.update(theme: theme) + self.emptyView.update(theme: theme) + } + + 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() { + let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in + self?.cancelButtonAction() + } + + self.navigationItem.rightBarButtonItem = cancelBarButtonItem + + self.titleView = MainTitleView() + self.titleView.titleLabel.text = VectorL10n.roomDetailsPeople + self.navigationItem.titleView = self.titleView + + self.emptyView.frame = CGRect(x: Constants.emptySearchViewMargin, y: self.searchBarView.frame.maxY + 2 * Constants.emptySearchViewMargin, width: self.view.bounds.width - 2 * Constants.emptySearchViewMargin, height: 0) + self.emptyView.autoresizingMask = [.flexibleHeight, .flexibleWidth] + self.emptyView.alpha = 0 + self.view.insertSubview(self.emptyView, at: 0) + } + + private func render(viewState: SpaceMemberListViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let space): + self.renderLoaded(space: space) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(space: MXSpace) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.mxRoom = space.room + self.titleView.subtitleLabel.text = space.summary?.displayname + self.emptyView.titleLabel.text = VectorL10n.spacesNoResultFoundTitle + self.emptyView.detailLabel.text = VectorL10n.spacesNoMemberFoundDetail(space.summary?.displayname ?? "") + self.emptyView.layoutIfNeeded() + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + @objc private func showDetail(for member: MXRoomMember, from sourceView: UIView?) { + self.viewModel.process(viewAction: .complete(member, sourceView)) + } + + // MARK: - Actions + + @objc private func onAddParticipantButtonPressed() { + self.errorPresenter.presentError(from: self, title: VectorL10n.spacesInvitesComingSoonTitle, message: VectorL10n.spacesComingSoonDetail, animated: true, handler: nil) + } + + private func cancelButtonAction() { + self.viewModel.process(viewAction: .cancel) + } + + // MARK: - UISearchBarDelegate + + override func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { + return true + } + + override func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { + return true + } + + override func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + super.searchBar(searchBar, textDidChange: searchText) + + UIView.animate(withDuration: 0.2) { + self.emptyView.alpha = self.tableView.numberOfSections == 0 ? 1 : 0 + self.tableView.alpha = self.tableView.numberOfSections == 0 ? 0 : 1 + } + } + + // MARK: - MXKRoomMemberDetailsViewControllerDelegate + + override func roomMemberDetailsViewController(_ roomMemberDetailsViewController: MXKRoomMemberDetailsViewController!, startChatWithMemberId matrixId: String!, completion: (() -> Void)!) { + completion() + self.errorPresenter.presentError(from: self, title: VectorL10n.spacesComingSoonTitle, message: VectorL10n.spacesComingSoonDetail, animated: true, handler: nil) + } + + override func roomMemberDetailsViewController(_ roomMemberDetailsViewController: MXKRoomMemberDetailsViewController!, placeVoipCallWithMemberId matrixId: String!, andVideo isVideoCall: Bool) { + self.errorPresenter.presentError(from: self, title: VectorL10n.spacesComingSoonTitle, message: VectorL10n.spacesComingSoonDetail, animated: true, handler: nil) + } +} + + +// MARK: - SpaceMemberListViewModelViewDelegate +extension SpaceMemberListViewController: SpaceMemberListViewModelViewDelegate { + + func spaceMemberListViewModel(_ viewModel: SpaceMemberListViewModelType, didUpdateViewState viewSate: SpaceMemberListViewState) { + self.render(viewState: viewSate) + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift new file mode 100644 index 000000000..c8d7ec1fd --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift @@ -0,0 +1,78 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 + +final class SpaceMemberListViewModel: SpaceMemberListViewModelType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let spaceId: String + + private var currentOperation: MXHTTPOperation? + private var userDisplayName: String? + + // MARK: Public + + weak var viewDelegate: SpaceMemberListViewModelViewDelegate? + weak var coordinatorDelegate: SpaceMemberListViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + } + + deinit { + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: SpaceMemberListViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .complete(let selectedMember, let sourceView): + self.coordinatorDelegate?.spaceMemberListViewModel(self, didSelect: selectedMember, from: sourceView) + case .cancel: + self.cancelOperations() + self.coordinatorDelegate?.spaceMemberListViewModelDidCancel(self) + } + } + + // MARK: - Private + + private func loadData() { + if let space = self.session.spaceService.getSpace(withId: spaceId) { + self.update(viewState: .loaded(space)) + } + } + + private func update(viewState: SpaceMemberListViewState) { + self.viewDelegate?.spaceMemberListViewModel(self, didUpdateViewState: viewState) + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift new file mode 100644 index 000000000..a07ceb55e --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift @@ -0,0 +1,37 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 SpaceMemberListViewModelViewDelegate: AnyObject { + func spaceMemberListViewModel(_ viewModel: SpaceMemberListViewModelType, didUpdateViewState viewSate: SpaceMemberListViewState) +} + +protocol SpaceMemberListViewModelCoordinatorDelegate: AnyObject { + func spaceMemberListViewModel(_ viewModel: SpaceMemberListViewModelType, didSelect member: MXRoomMember, from sourceView: UIView?) + func spaceMemberListViewModelDidCancel(_ viewModel: SpaceMemberListViewModelType) +} + +/// Protocol describing the view model used by `SpaceMemberListViewController` +protocol SpaceMemberListViewModelType { + + var viewDelegate: SpaceMemberListViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceMemberListViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceMemberListViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewState.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewState.swift new file mode 100644 index 000000000..407060c40 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewState.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceMembers/MemberList ShowSpaceMemberList +/* + 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 + +/// SpaceMemberListViewController view state +enum SpaceMemberListViewState { + case loading + case loaded(_ space: MXSpace) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift new file mode 100644 index 000000000..9a0c44ac9 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift @@ -0,0 +1,151 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceMembers SpaceMemberList ShowSpaceMemberList +/* + 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 UIKit + +struct SpaceMembersCoordinatorParameters { + let userSessionsService: UserSessionsService + let session: MXSession + let spaceId: String +} + +@objcMembers +final class SpaceMembersCoordinator: SpaceMembersCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceMembersCoordinatorParameters + private let navigationRouter: NavigationRouterType + private weak var memberDetailCoordinator: SpaceMemberDetailCoordinator? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceMembersCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceMembersCoordinatorParameters) { + self.parameters = parameters + self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) + } + + // MARK: - Public methods + + func start() { + + let rootCoordinator = self.createSpaceMemberListCoordinator() + + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + self.navigationRouter.setRootModule(rootCoordinator) + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + func presentMemberDetail(with member: MXRoomMember, from sourceView: UIView?) { + let coordinator = self.createSpaceMemberDetailCoordinator(with: member) + coordinator.start() + self.add(childCoordinator: coordinator) + self.memberDetailCoordinator = coordinator + + if UIDevice.current.isPhone { + self.navigationRouter.push(coordinator.toPresentable(), animated: true) { + if let memberDetailCoordinator = self.memberDetailCoordinator { + self.remove(childCoordinator: memberDetailCoordinator) + } + } + } else { + let viewController = coordinator.toPresentable() + viewController.modalPresentationStyle = .popover + if let sourceView = sourceView, let popoverPresentationController = viewController.popoverPresentationController { + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + } + + self.navigationRouter.present(viewController, animated: true) + } + } + + // MARK: - Private methods + + private func createSpaceMemberListCoordinator() -> SpaceMemberListCoordinator { + let coordinator = SpaceMemberListCoordinator(session: self.parameters.session, spaceId: self.parameters.spaceId) + coordinator.delegate = self + return coordinator + } + + private func createSpaceMemberDetailCoordinator(with member: MXRoomMember) -> SpaceMemberDetailCoordinator { + let parameters = SpaceMemberDetailCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, member: member, session: self.parameters.session, spaceId: self.parameters.spaceId) + let coordinator = SpaceMemberDetailCoordinator(parameters: parameters) + coordinator.delegate = self + return coordinator + } + + private func navigateTo(roomWith roomId: String) { + let roomDataSourceManager = MXKRoomDataSourceManager.sharedManager(forMatrixSession: self.parameters.session) + roomDataSourceManager?.roomDataSource(forRoom: roomId, create: true, onComplete: { [weak self] roomDataSource in + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) + guard let roomViewController = storyboard.instantiateViewController(withIdentifier: "RoomViewControllerStoryboardId") as? RoomViewController else { + return + } + + self?.navigationRouter.push(roomViewController, animated: true, popCompletion: nil) + roomViewController.displayRoom(roomDataSource) + roomViewController.navigationItem.leftItemsSupplementBackButton = true + roomViewController.showMissedDiscussionsBadge = false + }) + } +} + +// MARK: - SpaceMemberListCoordinatorDelegate +extension SpaceMembersCoordinator: SpaceMemberListCoordinatorDelegate { + func spaceMemberListCoordinator(_ coordinator: SpaceMemberListCoordinatorType, didSelect member: MXRoomMember, from sourceView: UIView?) { + self.presentMemberDetail(with: member, from: sourceView) + } + + func spaceMemberListCoordinatorDidCancel(_ coordinator: SpaceMemberListCoordinatorType) { + self.delegate?.spaceMembersCoordinatorDidCancel(self) + } +} + +extension SpaceMembersCoordinator: SpaceMemberDetailCoordinatorDelegate { + func spaceMemberDetailCoordinator(_ coordinator: SpaceMemberDetailCoordinatorType, showRoomWithId roomId: String) { + if !UIDevice.current.isPhone, let memberDetailCoordinator = self.memberDetailCoordinator { + memberDetailCoordinator.toPresentable().dismiss(animated: true, completion: { + self.memberDetailCoordinator = nil + self.navigateTo(roomWith: roomId) + }) + } else { + self.navigateTo(roomWith: roomId) + } + } + + func spaceMemberDetailCoordinatorDidCancel(_ coordinator: SpaceMemberDetailCoordinatorType) { + self.delegate?.spaceMembersCoordinatorDidCancel(self) + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorBridgePresenter.swift b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..7b4ea93c6 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorBridgePresenter.swift @@ -0,0 +1,96 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceMembers SpaceMemberList ShowSpaceMemberList +/* + 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 + +@objc protocol SpaceMembersCoordinatorBridgePresenterDelegate { + func spaceMembersCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: SpaceMembersCoordinatorBridgePresenter) +} + +/// SpaceMembersCoordinatorBridgePresenter enables to start SpaceMemberListCoordinator 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 SpaceMembersCoordinatorBridgePresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private let userSessionsService: UserSessionsService + private let session: MXSession + private let spaceId: String + private var coordinator: SpaceMembersCoordinator? + + // MARK: Public + + weak var delegate: SpaceMembersCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + init(userSessionsService: UserSessionsService, session: MXSession, spaceId: String) { + self.userSessionsService = userSessionsService + self.session = session + self.spaceId = spaceId + super.init() + } + + // MARK: - Public + + func present(from viewController: UIViewController, animated: Bool) { + let parameters = SpaceMembersCoordinatorParameters(userSessionsService: self.userSessionsService, session: self.session, spaceId: self.spaceId) + let spaceMemberListCoordinator = SpaceMembersCoordinator(parameters: parameters) + spaceMemberListCoordinator.delegate = self + let presentable = spaceMemberListCoordinator.toPresentable() + presentable.presentationController?.delegate = self + viewController.present(presentable, animated: animated, completion: nil) + spaceMemberListCoordinator.start() + + self.coordinator = spaceMemberListCoordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = self.coordinator else { + return + } + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + if let completion = completion { + completion() + } + } + } +} + +// MARK: - SpaceMembersCoordinatorDelegate +extension SpaceMembersCoordinatorBridgePresenter: SpaceMembersCoordinatorDelegate { + func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) { + self.delegate?.spaceMembersCoordinatorBridgePresenterDelegateDidComplete(self) + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate + +extension SpaceMembersCoordinatorBridgePresenter: UIAdaptivePresentationControllerDelegate { + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.delegate?.spaceMembersCoordinatorBridgePresenterDelegateDidComplete(self) + } + +} diff --git a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorType.swift b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorType.swift new file mode 100644 index 000000000..b2775a3c0 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinatorType.swift @@ -0,0 +1,28 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceMembers SpaceMemberList ShowSpaceMemberList +/* + 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 SpaceMembersCoordinatorDelegate: AnyObject { + func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) +} + +/// `SpaceMembersCoordinatorType` is a protocol describing a Coordinator that handle keybackup setup navigation flow. +protocol SpaceMembersCoordinatorType: Coordinator, Presentable { + var delegate: SpaceMembersCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift new file mode 100644 index 000000000..6b7a67fa5 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift @@ -0,0 +1,31 @@ +// +// 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 + +/// Style of the `SpaceMenuListViewCell` +enum SpaceMenuListItemStyle { + case normal + case destructive +} + +/// `SpaceMenuListViewCell` view data +struct SpaceMenuListItemViewData { + let actionId: String + let style: SpaceMenuListItemStyle + let title: String? + let icon: UIImage? +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.swift new file mode 100644 index 000000000..952d89d4d --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.swift @@ -0,0 +1,77 @@ +// +// 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 Reusable + +class SpaceMenuListViewCell: UITableViewCell, Themable, NibReusable { + + // MARK: - Properties + + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var selectionView: UIView! + + // MARK: - Private + + private var theme: Theme? + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.selectionStyle = .none + self.selectionView.layer.cornerRadius = 8.0 + self.selectionView.layer.masksToBounds = true + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + UIView.animate(withDuration: animated ? 0.3 : 0.0) { + self.selectionView.alpha = selected ? 1.0 : 0.0 + } + } + + // MARK: - Public + + func fill(with viewData: SpaceMenuListItemViewData) { + self.iconView.image = viewData.icon + self.titleLabel.text = viewData.title + + guard let theme = self.theme else { + return + } + + if viewData.style == .destructive { + self.titleLabel.textColor = theme.colors.alert + self.iconView.tintColor = theme.colors.alert + } else { + self.titleLabel.textColor = theme.colors.primaryContent + self.iconView.tintColor = theme.colors.secondaryContent + } + } + + func update(theme: Theme) { + self.theme = theme + self.backgroundColor = theme.colors.background + self.iconView.tintColor = theme.colors.secondaryContent + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.body + self.selectionView.backgroundColor = theme.colors.separator + } +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.xib b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.xib new file mode 100644 index 000000000..0d71ef2c6 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListViewCell.xib @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift new file mode 100644 index 000000000..697c65b43 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift @@ -0,0 +1,120 @@ +// +// 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 spaces contextual menu +class SpaceMenuPresenter: NSObject { + + // MARK: - Constants + + enum Actions { + case exploreRooms + case exploreMembers + } + + // MARK: - Properties + + public weak var delegate: SpaceMenuPresenterDelegate? + + // MARK: Private + + private weak var presentingViewController: UIViewController? + private var viewModel: SpaceMenuViewModel! + 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 = SpaceMenuViewModel(session: session, spaceId: spaceId) + self.viewModel.coordinatorDelegate = self + self.presentingViewController = viewController + self.sourceView = sourceView + self.selectedSpace = session.spaceService.getSpace(withId: spaceId) + + self.showMenu(for: spaceId, session: session) + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + self.presentingViewController?.dismiss(animated: animated, completion: completion) + } + + // MARK: - Private + + private func showMenu(for spaceId: String, session: MXSession) { + let menuViewController = SpaceMenuViewController.instantiate(forSpaceWithId: spaceId, matrixSession: session, viewModel: self.viewModel) + self.present(menuViewController, animated: true) + } + + private func present(_ viewController: SpaceMenuViewController, animated: Bool) { + + if UIDevice.current.isPhone { + guard let rootViewController = self.presentingViewController else { + MXLog.error("[SpaceMenuPresenter] 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: - SpaceMenuModelViewModelCoordinatorDelegate + +extension SpaceMenuPresenter: SpaceMenuModelViewModelCoordinatorDelegate { + func spaceMenuViewModelDidDismiss(_ viewModel: SpaceMenuViewModelType) { + self.dismiss(animated: true, completion: nil) + } + + func spaceMenuViewModel(_ viewModel: SpaceMenuViewModelType, didSelectItemWithId itemId: String) { + let actionId = SpaceMenuViewModel.ActionId(rawValue: itemId) + switch actionId { + case .leave: break + case .members: + self.delegate?.spaceMenuPresenter(self, didCompleteWith: .exploreMembers, forSpaceWithId: self.spaceId, with: self.session) + case .rooms: + self.delegate?.spaceMenuPresenter(self, didCompleteWith: .exploreRooms, forSpaceWithId: self.spaceId, with: self.session) + default: + MXLog.error("[SpaceMenuPresenter] spaceListViewModel didSelectItemWithId: invalid itemId \(itemId)") + } + } +} + +protocol SpaceMenuPresenterDelegate: AnyObject { + func spaceMenuPresenter(_ presenter: SpaceMenuPresenter, didCompleteWith action: SpaceMenuPresenter.Actions, forSpaceWithId spaceId: String, with session: MXSession) +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewAction.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewAction.swift new file mode 100644 index 000000000..87c813bb8 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewAction.swift @@ -0,0 +1,25 @@ +// +// 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 + +/// `SpaceMenuViewController` view actions exposed to view model +enum SpaceMenuViewAction { + case selectRow(at: IndexPath) + case leaveSpaceAndKeepRooms + case leaveSpaceAndLeaveRooms + case dismiss +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.storyboard b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.storyboard new file mode 100644 index 000000000..dfc8d09eb --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.storyboard @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift new file mode 100644 index 000000000..8f9710e56 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift @@ -0,0 +1,256 @@ +// +// 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 SpaceMenuViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let estimatedRowHeight: CGFloat = 64.0 + } + + // MARK: Private + + private var theme: Theme! + private var session: MXSession! + private var spaceId: String! + private var viewModel: SpaceMenuViewModelType! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + // MARK: Outlets + + @IBOutlet private weak var avatarView: SpaceAvatarView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var subtitleLabel: UILabel! + @IBOutlet private weak var closeButton: UIButton! + @IBOutlet private weak var tableView: UITableView! + @IBOutlet private weak var bottomMargin: NSLayoutConstraint! + + // MARK: - Setup + + class func instantiate(forSpaceWithId spaceId: String, matrixSession: MXSession, viewModel: SpaceMenuViewModelType!) -> SpaceMenuViewController { + let viewController = StoryboardScene.SpaceMenuViewController.initialScene.instantiate() + viewController.session = matrixSession + viewController.spaceId = spaceId + 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 + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + override var preferredContentSize: CGSize { + get { + return CGSize(width: 320, height: self.tableView.frame.minY + Constants.estimatedRowHeight * CGFloat(self.viewModel.menuItems.count) + self.bottomMargin.constant) + } + set { + super.preferredContentSize = newValue + } + } + + // MARK: - IBActions + + @IBAction private func closeAction(sender: UIButton) { + self.viewModel.process(viewAction: .dismiss) + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.colors.background + + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.title3SB + self.subtitleLabel.textColor = theme.colors.secondaryContent + self.subtitleLabel.font = theme.fonts.caption1 + self.closeButton.backgroundColor = theme.roomInputTextBorder + self.closeButton.tintColor = theme.noticeSecondaryColor + } + + 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() { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId), let summary = space.summary else { + MXLog.error("[SpaceMenuViewController] setupViews: no space found") + return + } + + let avatarViewData = AvatarViewData(matrixItemId: summary.roomId, displayName: summary.displayname, avatarUrl: summary.avatar, mediaManager: self.session.mediaManager, fallbackImage: .matrixItem(summary.roomId, summary.displayname)) + + self.titleLabel.text = space.summary?.displayname + // TODO: display members instead once done on android +// self.subtitleLabel.text = space.membersId.count == 1 ? VectorL10n.roomTitleOneMember : +// VectorL10n.roomTitleMembers("\(space.membersId.count)") + self.subtitleLabel.text = summary.topic + self.avatarView.fill(with: avatarViewData) + + self.closeButton.layer.masksToBounds = true + self.closeButton.layer.cornerRadius = self.closeButton.bounds.height / 2 + setupTableView() + } + + private func setupTableView() { + self.tableView.separatorStyle = .none + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.estimatedRowHeight = Constants.estimatedRowHeight + self.tableView.allowsSelection = true + self.tableView.register(cellType: SpaceMenuListViewCell.self) + self.tableView.tableFooterView = UIView() + } + + private func render(viewState: SpaceMenuViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded: + self.renderLoaded() + case .leaveOptions(let displayName, let isAdmin): + self.renderLeaveOptions(displayName: displayName, isAdmin: isAdmin) + case .error(let error): + self.render(error: error) + case .deselect: + self.renderDeselect() + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded() { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.renderDeselect() + } + + private func renderLeaveOptions(displayName: String, isAdmin: Bool) { + var message = VectorL10n.leaveSpaceMessage(displayName) + + if isAdmin { + message += "\n\n" + VectorL10n.leaveSpaceMessageAdminWarning + } + + let alert = UIAlertController(title: VectorL10n.leaveSpaceTitle(displayName), message: message, preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: VectorL10n.leaveSpaceOnlyAction, style: .default, handler: { [weak self] action in + self?.viewModel.process(viewAction: .leaveSpaceAndKeepRooms) + })) + alert.addAction(UIAlertAction(title: VectorL10n.leaveSpaceAndAllRoomsAction, style: .destructive, handler: { [weak self] action in + self?.viewModel.process(viewAction: .leaveSpaceAndLeaveRooms) + })) + alert.addAction(UIAlertAction(title: VectorL10n.cancel, style: .cancel, handler: { [weak self] action in + self?.renderDeselect() + })) + + self.present(alert, animated: true, completion: nil) + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + private func renderDeselect() { + if let selectedRow = self.tableView.indexPathForSelectedRow { + self.tableView.deselectRow(at: selectedRow, animated: true) + } + } +} + +// MARK: - SlidingModalPresentable + +extension SpaceMenuViewController: SlidingModalPresentable { + + func allowsDismissOnBackgroundTap() -> Bool { + return true + } + + func layoutHeightFittingWidth(_ width: CGFloat) -> CGFloat { + return self.preferredContentSize.height + } + +} + +// MARK: - SpaceMenuViewModelViewDelegate + +extension SpaceMenuViewController: SpaceMenuViewModelViewDelegate { + func spaceMenuViewModel(_ viewModel: SpaceMenuViewModelType, didUpdateViewState viewSate: SpaceMenuViewState) { + self.render(viewState: viewSate) + } +} + +// MARK: - UITableViewDataSource + +extension SpaceMenuViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.menuItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(for: indexPath, cellType: SpaceMenuListViewCell.self) + + let viewData = viewModel.menuItems[indexPath.row] + + cell.update(theme: self.theme) + cell.fill(with: viewData) + + return cell + } +} + +// MARK: - UITableViewDelegate + +extension SpaceMenuViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + self.viewModel.process(viewAction: .selectRow(at: indexPath)) + } +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift new file mode 100644 index 000000000..34442e730 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift @@ -0,0 +1,173 @@ +// +// 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 `SpaceMenuViewController` +class SpaceMenuViewModel: SpaceMenuViewModelType { + + // MARK: - Enum + + enum ActionId: String { + case members = "members" + case rooms = "rooms" + case leave = "leave" + } + + // MARK: - Properties + + weak var coordinatorDelegate: SpaceMenuModelViewModelCoordinatorDelegate? + weak var viewDelegate: SpaceMenuViewModelViewDelegate? + + var menuItems: [SpaceMenuListItemViewData] = [ + SpaceMenuListItemViewData(actionId: ActionId.members.rawValue, style: .normal, title: VectorL10n.roomDetailsPeople, icon: UIImage(named: "space_menu_members")), + SpaceMenuListItemViewData(actionId: ActionId.rooms.rawValue, style: .normal, title: VectorL10n.spacesExploreRooms, icon: UIImage(named: "space_menu_rooms")), + SpaceMenuListItemViewData(actionId: ActionId.leave.rawValue, style: .destructive, title: VectorL10n.leave, icon: UIImage(named: "space_menu_leave")) + ] + + 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: SpaceMenuViewAction) { + switch viewAction { + case .dismiss: + self.coordinatorDelegate?.spaceMenuViewModelDidDismiss(self) + case .selectRow(at: let indexPath): + self.processAction(with: menuItems[indexPath.row].actionId) + case .leaveSpaceAndKeepRooms: + self.leaveSpaceAndKeepRooms() + case .leaveSpaceAndLeaveRooms: + self.leaveSpaceAndLeaveAllRooms() + } + } + + // MARK: - Private + + private func processAction(with actionStringId: String) { + let actionId = ActionId(rawValue: actionStringId) + switch actionId { + case .leave: + self.leaveSpace() + default: + self.coordinatorDelegate?.spaceMenuViewModel(self, didSelectItemWithId: actionStringId) + } + } + + private func leaveSpace() { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId), let displayName = space.summary?.displayname else { + return + } + + var isAdmin = false + if let roomState = space.room.dangerousSyncState, let powerLevels = roomState.powerLevels { + let powerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId) + let roomPowerLevel = RoomPowerLevelHelper.roomPowerLevel(from: powerLevel) + isAdmin = roomPowerLevel == .admin + } + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .leaveOptions(displayName, isAdmin)) + } + + private func leaveSpaceAndKeepRooms() { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId) else { + return + } + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .loading) + self.leaveSpace(space) + } + + private func leaveSpaceAndLeaveAllRooms() { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId) else { + return + } + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .loading) + + let allRoomsAndSpaces = space.childRoomIds + space.childSpaces.map({ space in + space.spaceId + }) + + self.leaveAllRooms(from: allRoomsAndSpaces, at: 0) { [weak self] error in + guard let self = self else { + return + } + + if let error = error { + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .loaded) + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .error(error)) + return + } + + self.leaveSpace(space) + } + } + + private func leaveAllRooms(from roomIds: [String], at index: Int, completion: @escaping (_ error: Error?) -> Void) { + guard index < roomIds.count, let room = self.session.room(withRoomId: roomIds[index]), !room.isDirect else { + let nextIndex = index+1 + if nextIndex < roomIds.count { + self.leaveAllRooms(from: roomIds, at: nextIndex, completion: completion) + } else { + completion(nil) + } + return + } + + room.leave { [weak self] response in + guard let self = self else { + return + } + + guard response.isSuccess else { + completion(response.error) + return + } + + let nextIndex = index+1 + if nextIndex < roomIds.count { + self.leaveAllRooms(from: roomIds, at: nextIndex, completion: completion) + } else { + completion(nil) + } + } + } + + private func leaveSpace(_ space: MXSpace) { + space.room.leave(completion: { [weak self] response in + guard let self = self else { + return + } + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .loaded) + + if let error = response.error { + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .error(error)) + } else { + self.process(viewAction: .dismiss) + } + }) + } +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModelType.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModelType.swift new file mode 100644 index 000000000..edaef39b3 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModelType.swift @@ -0,0 +1,36 @@ +// +// 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 SpaceMenuViewModelViewDelegate: AnyObject { + func spaceMenuViewModel(_ viewModel: SpaceMenuViewModelType, didUpdateViewState viewSate: SpaceMenuViewState) +} + +protocol SpaceMenuModelViewModelCoordinatorDelegate: AnyObject { + func spaceMenuViewModelDidDismiss(_ viewModel: SpaceMenuViewModelType) + func spaceMenuViewModel(_ viewModel: SpaceMenuViewModelType, didSelectItemWithId itemId: String) +} + +/// Protocol describing the view model used by `SpaceMenuViewController` +protocol SpaceMenuViewModelType { + var menuItems: [SpaceMenuListItemViewData] { get } + + var viewDelegate: SpaceMenuViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceMenuModelViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceMenuViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewState.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewState.swift new file mode 100644 index 000000000..9801ffaf5 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewState.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 + +/// SpaceMenuViewController view state +enum SpaceMenuViewState { + case loading + case loaded + case deselect + case leaveOptions(_ displayName: String, _ isAdmin: Bool) + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.swift new file mode 100644 index 000000000..dc4ef9881 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.swift @@ -0,0 +1,52 @@ +// +// 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 UIKit +import Reusable + +class SpaceChildSpaceViewCell: SpaceChildViewCell { + + @IBOutlet private weak var roomsIcon: UIImageView! + @IBOutlet private weak var roomNumberLabel: UILabel! + @IBOutlet private weak var spaceTagView: UIView! + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.spaceTagView.layer.masksToBounds = true + self.spaceTagView.layer.cornerRadius = 2 + } + + // MARK: - Public + + override func fill(with viewData: SpaceExploreRoomListItemViewData) { + super.fill(with: viewData) + + self.roomNumberLabel.text = "\(viewData.childInfo.childrenIds.count)" + self.topicLabel.text = VectorL10n.spaceTag + } + + override func update(theme: Theme) { + super.update(theme: theme) + + self.roomNumberLabel.font = theme.fonts.caption1 + self.roomNumberLabel.textColor = theme.colors.tertiaryContent + self.roomsIcon.tintColor = theme.colors.tertiaryContent + self.spaceTagView.backgroundColor = theme.colors.quinaryContent + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib new file mode 100644 index 000000000..2559fbcda --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.swift new file mode 100644 index 000000000..541fc6380 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.swift @@ -0,0 +1,83 @@ +// +// 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 UIKit +import Reusable + +class SpaceChildViewCell: UITableViewCell, Themable, NibReusable { + + // MARK: - Properties + + @IBOutlet internal weak var avatarView: AvatarView! + @IBOutlet internal weak var titleLabel: UILabel! + @IBOutlet internal weak var titleLabelTrailingMargin: NSLayoutConstraint! + @IBOutlet internal weak var selectionView: UIView! + @IBOutlet internal weak var userIconView: UIImageView! + @IBOutlet internal weak var membersLabel: UILabel! + @IBOutlet internal weak var topicLabel: UILabel! + @IBOutlet internal weak var suggestedLabel: UILabel! + + // MARK: - Private + + private var theme: Theme? + private var titleLabelDefaultTrailingMargin: CGFloat = 0 + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.selectionView.layer.cornerRadius = 8.0 + self.selectionView.layer.masksToBounds = true + self.titleLabelDefaultTrailingMargin = self.titleLabelTrailingMargin.constant + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + UIView.animate(withDuration: animated ? 0.3 : 0.0) { + self.selectionView.alpha = selected ? 1.0 : 0.0 + } + } + + // MARK: - Public + + func fill(with viewData: SpaceExploreRoomListItemViewData) { + self.avatarView.fill(with: viewData.avatarViewData) + self.titleLabel.text = viewData.childInfo.name ?? viewData.childInfo.canonicalAlias + self.membersLabel.text = "\(viewData.childInfo.activeMemberCount)" + self.topicLabel.text = viewData.childInfo.topic + self.suggestedLabel.text = viewData.childInfo.suggested ? VectorL10n.spacesSuggestedRoom : nil + self.titleLabelTrailingMargin.constant = viewData.childInfo.suggested ? self.titleLabelDefaultTrailingMargin : 0 + self.titleLabel.layoutIfNeeded() + } + + func update(theme: Theme) { + self.theme = theme + self.backgroundColor = theme.colors.background + self.avatarView.update(theme: theme) + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.calloutSB + self.selectionView.backgroundColor = theme.colors.separator + self.membersLabel.font = theme.fonts.caption1 + self.membersLabel.textColor = theme.colors.tertiaryContent + self.topicLabel.font = theme.fonts.caption1 + self.topicLabel.textColor = theme.colors.tertiaryContent + self.userIconView.tintColor = theme.colors.tertiaryContent + self.suggestedLabel.font = theme.fonts.caption2 + self.suggestedLabel.textColor = theme.colors.tertiaryContent + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.xib b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.xib new file mode 100644 index 000000000..d81bc28e3 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildViewCell.xib @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinator.swift new file mode 100644 index 000000000..9607d9e86 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinator.swift @@ -0,0 +1,67 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 UIKit + +final class SpaceExploreRoomCoordinator: SpaceExploreRoomCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private var spaceExploreRoomViewModel: SpaceExploreRoomViewModelType + private let spaceExploreRoomViewController: SpaceExploreRoomViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceExploreRoomCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceExploreRoomCoordinatorParameters) { + let spaceExploreRoomViewModel = SpaceExploreRoomViewModel(parameters: parameters) + let spaceExploreRoomViewController = SpaceExploreRoomViewController.instantiate(with: spaceExploreRoomViewModel) + self.spaceExploreRoomViewModel = spaceExploreRoomViewModel + self.spaceExploreRoomViewController = spaceExploreRoomViewController + } + + // MARK: - Public methods + + func start() { + self.spaceExploreRoomViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.spaceExploreRoomViewController + } +} + +// MARK: - SpaceExploreRoomViewModelCoordinatorDelegate +extension SpaceExploreRoomCoordinator: SpaceExploreRoomViewModelCoordinatorDelegate { + func spaceExploreRoomViewModel(_ viewModel: SpaceExploreRoomViewModelType, didSelect item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) { + self.delegate?.spaceExploreRoomCoordinator(self, didSelect: item, from: sourceView) + } + + func spaceExploreRoomViewModelDidCancel(_ viewModel: SpaceExploreRoomViewModelType) { + self.delegate?.spaceExploreRoomCoordinatorDidCancel(self) + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorParameters.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorParameters.swift new file mode 100644 index 000000000..cc3c97f76 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorParameters.swift @@ -0,0 +1,23 @@ +// +// 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 + +struct SpaceExploreRoomCoordinatorParameters { + let session: MXSession + let spaceId: String + let spaceName: String? +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorType.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorType.swift new file mode 100644 index 000000000..b092b17d6 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomCoordinatorType.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 SpaceExploreRoomCoordinatorDelegate: AnyObject { + func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, didSelect item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) + func spaceExploreRoomCoordinatorDidCancel(_ coordinator: SpaceExploreRoomCoordinatorType) +} + +/// `SpaceExploreRoomCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. +protocol SpaceExploreRoomCoordinatorType: Coordinator, Presentable { + var delegate: SpaceExploreRoomCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomListItemViewData.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomListItemViewData.swift new file mode 100644 index 000000000..632f04670 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomListItemViewData.swift @@ -0,0 +1,23 @@ +// +// 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 + +/// SpaceListViewCell view data +struct SpaceExploreRoomListItemViewData { + let childInfo: MXSpaceChildInfo + let avatarViewData: AvatarViewDataProtocol +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewAction.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewAction.swift new file mode 100644 index 000000000..aa1e67dc0 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewAction.swift @@ -0,0 +1,27 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 + +/// SpaceExploreRoomViewController view actions exposed to view model +enum SpaceExploreRoomViewAction { + case loadData + case complete(_ selectedItem: SpaceExploreRoomListItemViewData, _ sourceView: UIView?) + case searchChanged(_ text: String?) + case cancel +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.storyboard b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.storyboard new file mode 100644 index 000000000..5ccce16d7 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.storyboard @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.swift new file mode 100644 index 000000000..664493c2e --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewController.swift @@ -0,0 +1,273 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 UIKit + +final class SpaceExploreRoomViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let estimatedRowHeight: CGFloat = 64 + } + + // MARK: Outlets + + @IBOutlet private var tableSearchBar: UISearchBar! + @IBOutlet private var tableView: UITableView! + + // MARK: Private + + private var viewModel: SpaceExploreRoomViewModelType! + private var theme: Theme! + private var keyboardAvoider: KeyboardAvoider? + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + private var titleView: MainTitleView! + private var emptyView: RootTabEmptyView! + private var plusButtonImageView: UIImageView! + + private var itemDataList: [SpaceExploreRoomListItemViewData] = [] { + didSet { + tableView.reloadData() + } + } + + private var emptyViewArtwork: UIImage { + return ThemeService.shared().isCurrentThemeDark() ? Asset.Images.roomsEmptyScreenArtworkDark.image : Asset.Images.roomsEmptyScreenArtwork.image + } + + private var scrollViewHidden = true { + didSet { + UIView.animate(withDuration: 0.2) { + self.tableView.alpha = self.scrollViewHidden ? 0 : 1 + self.emptyView.alpha = self.scrollViewHidden ? 1 : 0 + } + } + } + + // MARK: - Setup + + class func instantiate(with viewModel: SpaceExploreRoomViewModelType) -> SpaceExploreRoomViewController { + let viewController = StoryboardScene.SpaceExploreRoomViewController.initialScene.instantiate() + viewController.viewModel = viewModel + viewController.theme = ThemeService.shared().theme + viewController.emptyView = RootTabEmptyView.instantiate() + return viewController + } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + + self.setupViews() + self.keyboardAvoider = KeyboardAvoider(scrollViewContainerView: self.view, scrollView: self.tableView) + self.activityPresenter = ActivityIndicatorPresenter() + self.errorPresenter = MXKErrorAlertPresentation() + + self.registerThemeServiceDidChangeThemeNotification() + self.update(theme: self.theme) + + self.viewModel.viewDelegate = self + + self.viewModel.process(viewAction: .loadData) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.keyboardAvoider?.startAvoiding() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.keyboardAvoider?.stopAvoiding() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + self.titleView.update(theme: theme) + self.tableView.backgroundColor = theme.colors.background + self.tableView.reloadData() + self.emptyView.update(theme: theme) + theme.applyStyle(onSearchBar: self.tableSearchBar) + } + + 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() { + let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in + self?.cancelButtonAction() + } + + self.navigationItem.rightBarButtonItem = cancelBarButtonItem + + self.vc_removeBackTitle() + + self.titleView = MainTitleView() + self.titleView.titleLabel.text = VectorL10n.titleRooms + self.navigationItem.titleView = self.titleView + + self.tableSearchBar.placeholder = VectorL10n.searchDefaultPlaceholder + + self.tableView.keyboardDismissMode = .interactive + self.setupTableView() + + self.emptyView.fill(with: self.emptyViewArtwork, title: VectorL10n.roomsEmptyViewTitle, informationText: VectorL10n.roomsEmptyViewInformation) + + self.plusButtonImageView = self.vc_addFAB(withImage: Asset.Images.roomsFloatingAction.image, target: self, action: #selector(addRoomAction(semder:))) + + self.emptyView.frame = CGRect(x: 0, y: self.tableSearchBar.frame.maxY, width: self.view.bounds.width, height: self.view.bounds.height - self.tableSearchBar.frame.maxY) + self.emptyView.autoresizingMask = [.flexibleHeight, .flexibleWidth] + self.emptyView.alpha = 0 + self.view.insertSubview(self.emptyView, at: 0) + } + + private func setupTableView() { + self.tableView.separatorStyle = .none + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.estimatedRowHeight = Constants.estimatedRowHeight + self.tableView.allowsSelection = true + self.tableView.register(cellType: SpaceChildViewCell.self) + self.tableView.register(cellType: SpaceChildSpaceViewCell.self) + self.tableView.tableFooterView = UIView() + } + + private func render(viewState: SpaceExploreRoomViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .spaceNameFound(let spaceName): + self.titleView.subtitleLabel.text = spaceName + case .loaded(let children): + self.renderLoaded(children: children) + case .emptySpace: + self.renderEmptySpace() + case .emptyFilterResult: + self.renderEmptyFilterResult() + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(children: [SpaceExploreRoomListItemViewData]) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.itemDataList = children + self.scrollViewHidden = false + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + private func renderEmptySpace() { + self.emptyView.fill(with: self.emptyViewArtwork, title: VectorL10n.spacesEmptySpaceTitle, informationText: VectorL10n.spacesEmptySpaceDetail) + self.scrollViewHidden = true + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + + private func renderEmptyFilterResult() { + self.emptyView.fill(with: self.emptyViewArtwork, title: VectorL10n.spacesNoResultFoundTitle, informationText: VectorL10n.spacesNoRoomFoundDetail) + self.scrollViewHidden = true + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + } + + // MARK: - Actions + + private func cancelButtonAction() { + self.viewModel.process(viewAction: .cancel) + } + + @objc private func addRoomAction(semder: UIView) { + self.errorPresenter.presentError(from: self, title: VectorL10n.spacesAddRoomsComingSoonTitle, message: VectorL10n.spacesComingSoonDetail, animated: true, handler: nil) + } + + // MARK: - UISearchBarDelegate + + override func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + self.viewModel.process(viewAction: .searchChanged(searchText)) + } +} + +// MARK: - UITableViewDataSource +extension SpaceExploreRoomViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.itemDataList.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let viewData = self.itemDataList[indexPath.row] + + let cell = viewData.childInfo.roomType == .space ? tableView.dequeueReusableCell(for: indexPath, cellType: SpaceChildSpaceViewCell.self) : tableView.dequeueReusableCell(for: indexPath, cellType: SpaceChildViewCell.self) + + cell.update(theme: self.theme) + cell.fill(with: viewData) + cell.selectionStyle = .none + + return cell + } +} + +// MARK: - UITableViewDelegate +extension SpaceExploreRoomViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + self.viewModel.process(viewAction: .complete(self.itemDataList[indexPath.row], tableView.cellForRow(at: indexPath))) + } +} + +// MARK: - SpaceExploreRoomViewModelViewDelegate +extension SpaceExploreRoomViewController: SpaceExploreRoomViewModelViewDelegate { + + func spaceExploreRoomViewModel(_ viewModel: SpaceExploreRoomViewModelType, didUpdateViewState viewSate: SpaceExploreRoomViewState) { + self.render(viewState: viewSate) + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModel.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModel.swift new file mode 100644 index 000000000..349530bb9 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModel.swift @@ -0,0 +1,140 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 + +final class SpaceExploreRoomViewModel: SpaceExploreRoomViewModelType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let spaceId: String + private let spaceName: String? + + private var currentOperation: MXHTTPOperation? + + private var itemDataList: [SpaceExploreRoomListItemViewData] = [] { + didSet { + self.updateFilteredItemList() + } + } + private var searchKeyword: String? = nil { + didSet { + self.updateFilteredItemList() + } + } + private var filteredItemDataList: [SpaceExploreRoomListItemViewData] = [] { + didSet { + if self.filteredItemDataList.isEmpty { + if self.itemDataList.isEmpty { + self.update(viewState: .emptySpace) + } else { + self.update(viewState: .emptyFilterResult) + } + } else { + self.update(viewState: .loaded(self.filteredItemDataList)) + } + } + } + + // MARK: Public + + weak var viewDelegate: SpaceExploreRoomViewModelViewDelegate? + weak var coordinatorDelegate: SpaceExploreRoomViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceExploreRoomCoordinatorParameters) { + self.session = parameters.session + self.spaceId = parameters.spaceId + self.spaceName = parameters.spaceName + } + + deinit { + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: SpaceExploreRoomViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .complete(let selectedItem, let sourceView): + self.coordinatorDelegate?.spaceExploreRoomViewModel(self, didSelect: selectedItem, from: sourceView) + case .cancel: + self.cancelOperations() + self.coordinatorDelegate?.spaceExploreRoomViewModelDidCancel(self) + case .searchChanged(let newText): + self.searchKeyword = newText + } + } + + // MARK: - Private + + private func loadData() { + if let spaceName = self.spaceName { + self.update(viewState: .spaceNameFound(spaceName)) + } + + self.update(viewState: .loading) + + self.currentOperation = self.session.spaceService.getSpaceChildrenForSpace(withId: self.spaceId, suggestedOnly: false, limit: nil, completion: { [weak self] response in + guard let self = self else { + return + } + + switch response { + case .success(let spaceSummary): + self.itemDataList = spaceSummary.childInfos.compactMap({ childInfo in + guard childInfo.parentIds.contains(self.spaceId) else { + return nil + } + + let avatarViewData = AvatarViewData(matrixItemId: childInfo.childRoomId, displayName: childInfo.displayName, avatarUrl: childInfo.avatarUrl, mediaManager: self.session.mediaManager, fallbackImage: .matrixItem(childInfo.childRoomId, childInfo.name)) + return SpaceExploreRoomListItemViewData(childInfo: childInfo, avatarViewData: avatarViewData) + }).sorted(by: { item1, item2 in + return !item2.childInfo.suggested || item1.childInfo.suggested + }) + case .failure(let error): + self.update(viewState: .error(error)) + } + }) + } + + private func update(viewState: SpaceExploreRoomViewState) { + self.viewDelegate?.spaceExploreRoomViewModel(self, didUpdateViewState: viewState) + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } + + private func updateFilteredItemList() { + guard let searchKeyword = self.searchKeyword?.lowercased(), !searchKeyword.isEmpty else { + self.filteredItemDataList = self.itemDataList + return + } + + self.filteredItemDataList = self.itemDataList.filter({ itemData in + return (itemData.childInfo.name?.lowercased().contains(searchKeyword) ?? false) || (itemData.childInfo.topic?.lowercased().contains(searchKeyword) ?? false) + }) + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModelType.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModelType.swift new file mode 100644 index 000000000..a5e463c75 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewModelType.swift @@ -0,0 +1,37 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 SpaceExploreRoomViewModelViewDelegate: AnyObject { + func spaceExploreRoomViewModel(_ viewModel: SpaceExploreRoomViewModelType, didUpdateViewState viewSate: SpaceExploreRoomViewState) +} + +protocol SpaceExploreRoomViewModelCoordinatorDelegate: AnyObject { + func spaceExploreRoomViewModel(_ viewModel: SpaceExploreRoomViewModelType, didSelect item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) + func spaceExploreRoomViewModelDidCancel(_ viewModel: SpaceExploreRoomViewModelType) +} + +/// Protocol describing the view model used by `SpaceExploreRoomViewController` +protocol SpaceExploreRoomViewModelType { + + var viewDelegate: SpaceExploreRoomViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceExploreRoomViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceExploreRoomViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewState.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewState.swift new file mode 100644 index 000000000..c7c8ae50e --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceExploreRoomViewState.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/ExploreRoom ShowSpaceExploreRoom +/* + 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 + +/// SpaceExploreRoomViewController view state +enum SpaceExploreRoomViewState { + case loading + case spaceNameFound(_ spaceName: String) + case loaded(_ children: [SpaceExploreRoomListItemViewData]) + case emptySpace + case emptyFilterResult + case error(Error) +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift new file mode 100644 index 000000000..e90ea7160 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -0,0 +1,185 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceRoomList ExploreRoom ShowSpaceExploreRoom +/* + 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 UIKit + +@objcMembers +final class ExploreRoomCoordinator: ExploreRoomCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private let navigationRouter: NavigationRouterType + private let session: MXSession + private let spaceId: String + private weak var roomDetailCoordinator: SpaceChildRoomDetailCoordinator? + + private lazy var slidingModalPresenter: SlidingModalPresenter = { + return SlidingModalPresenter() + }() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: ExploreRoomCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) + self.session = session + self.spaceId = spaceId + } + + // MARK: - Public methods + + func start() { + + let rootCoordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: self.spaceId, spaceName: self.session.spaceService.getSpace(withId: self.spaceId)?.summary?.displayname) + + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + self.navigationRouter.setRootModule(rootCoordinator) + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private methods + + private func pushSpace(with item: SpaceExploreRoomListItemViewData) { + let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: item.childInfo.childRoomId, spaceName: item.childInfo.name) + coordinator.start() + self.add(childCoordinator: coordinator) + self.navigationRouter.push(coordinator.toPresentable(), animated: true) { + self.remove(childCoordinator: coordinator) + } + } + + private func presentRoom(with item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) { + if let currentCoordinator = self.roomDetailCoordinator { + self.remove(childCoordinator: currentCoordinator) + } + + let summary = self.session.roomSummary(withRoomId: item.childInfo.childRoomId) + let isJoined = summary?.isJoined ?? false + + if isJoined { + self.navigateTo(roomWith: item.childInfo.childRoomId) + } else { + self.showRoomPreview(with: item, from: sourceView) + } + } + + private func showRoomPreview(with item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) { + let coordinator = self.createShowSpaceRoomDetailCoordinator(session: self.session, childInfo: item.childInfo) + coordinator.start() + self.add(childCoordinator: coordinator) + self.roomDetailCoordinator = coordinator + + if UIDevice.current.isPhone { + slidingModalPresenter.present(coordinator.toSlidingPresentable(), from: self.navigationRouter.toPresentable(), animated: true, completion: nil) + } else { + let viewController = coordinator.toPresentable() + viewController.modalPresentationStyle = .popover + if let sourceView = sourceView, let popoverPresentationController = viewController.popoverPresentationController { + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + } + + self.navigationRouter.present(viewController, animated: true) + } + } + + private func createShowSpaceExploreRoomCoordinator(session: MXSession, spaceId: String, spaceName: String?) -> SpaceExploreRoomCoordinator { + let coordinator = SpaceExploreRoomCoordinator(parameters: SpaceExploreRoomCoordinatorParameters(session: session, spaceId: spaceId, spaceName: spaceName)) + coordinator.delegate = self + return coordinator + } + + private func createShowSpaceRoomDetailCoordinator(session: MXSession, childInfo: MXSpaceChildInfo) -> SpaceChildRoomDetailCoordinator { + let coordinator = SpaceChildRoomDetailCoordinator(parameters: SpaceChildRoomDetailCoordinatorParameters(session: session, childInfo: childInfo)) + coordinator.delegate = self + return coordinator + } + + private func navigateTo(roomWith roomId: String) { + let roomDataSourceManager = MXKRoomDataSourceManager.sharedManager(forMatrixSession: self.session) + roomDataSourceManager?.roomDataSource(forRoom: roomId, create: true, onComplete: { [weak self] roomDataSource in + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) + guard let roomViewController = storyboard.instantiateViewController(withIdentifier: "RoomViewControllerStoryboardId") as? RoomViewController else { + return + } + + self?.navigationRouter.push(roomViewController, animated: true, popCompletion: nil) + roomViewController.displayRoom(roomDataSource) + roomViewController.navigationItem.leftItemsSupplementBackButton = true + roomViewController.showMissedDiscussionsBadge = false + }) + } +} + +// MARK: - ShowSpaceExploreRoomCoordinatorDelegate +extension ExploreRoomCoordinator: SpaceExploreRoomCoordinatorDelegate { + func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, didSelect item: SpaceExploreRoomListItemViewData, from sourceView: UIView?) { + if item.childInfo.roomType == .space { + self.pushSpace(with: item) + } else if item.childInfo.roomType == .room { + self.presentRoom(with: item, from: sourceView) + } + } + + func spaceExploreRoomCoordinatorDidCancel(_ coordinator: SpaceExploreRoomCoordinatorType) { + self.delegate?.exploreRoomCoordinatorDidComplete(self) + } +} + +// MARK: - ShowSpaceChildRoomDetailCoordinator +extension ExploreRoomCoordinator: SpaceChildRoomDetailCoordinatorDelegate { + func spaceChildRoomDetailCoordinator(_ coordinator: SpaceChildRoomDetailCoordinatorType, didOpenRoomWith roomId: String) { + self.navigationRouter.toPresentable().dismiss(animated: true) { + if let lastCoordinator = self.roomDetailCoordinator { + self.remove(childCoordinator: lastCoordinator) + } + self.navigateTo(roomWith: roomId) + } + } + + func spaceChildRoomDetailCoordinatorDidCancel(_ coordinator: SpaceChildRoomDetailCoordinatorType) { + if UIDevice.current.isPhone { + slidingModalPresenter.dismiss(animated: true) { + if let roomDetailCoordinator = self.roomDetailCoordinator { + self.remove(childCoordinator: roomDetailCoordinator) + } + } + } else { + self.roomDetailCoordinator?.toPresentable().dismiss(animated: true, completion: { + if let roomDetailCoordinator = self.roomDetailCoordinator { + self.remove(childCoordinator: roomDetailCoordinator) + } + }) + } + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorBridgePresenter.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..824801fb4 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorBridgePresenter.swift @@ -0,0 +1,97 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceRoomList ExploreRoom ShowSpaceExploreRoom +/* + 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 + +@objc protocol ExploreRoomCoordinatorBridgePresenterDelegate { + func exploreRoomCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ExploreRoomCoordinatorBridgePresenter) +} + +/// ExploreRoomCoordinatorBridgePresenter enables to start ExploreRoomCoordinator 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 ExploreRoomCoordinatorBridgePresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let spaceId: String + private var coordinator: ExploreRoomCoordinator? + + // MARK: Public + + weak var delegate: ExploreRoomCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + super.init() + } + + // MARK: - Public + + // NOTE: Default value feature is not compatible with Objective-C. + // func present(from viewController: UIViewController, animated: Bool) { + // self.present(from: viewController, animated: animated) + // } + + func present(from viewController: UIViewController, animated: Bool) { + let exploreRoomCoordinator = ExploreRoomCoordinator(session: self.session, spaceId: self.spaceId) + exploreRoomCoordinator.delegate = self + let presentable = exploreRoomCoordinator.toPresentable() + presentable.presentationController?.delegate = self + viewController.present(presentable, animated: animated, completion: nil) + exploreRoomCoordinator.start() + + self.coordinator = exploreRoomCoordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = self.coordinator else { + return + } + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + if let completion = completion { + completion() + } + } + } +} + +// MARK: - ExploreRoomCoordinatorDelegate +extension ExploreRoomCoordinatorBridgePresenter: ExploreRoomCoordinatorDelegate { + func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType) { + self.delegate?.exploreRoomCoordinatorBridgePresenterDelegateDidComplete(self) + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension ExploreRoomCoordinatorBridgePresenter: UIAdaptivePresentationControllerDelegate { + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.delegate?.exploreRoomCoordinatorBridgePresenterDelegateDidComplete(self) + } + +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorType.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorType.swift new file mode 100644 index 000000000..20bf8c2f1 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinatorType.swift @@ -0,0 +1,28 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Spaces/SpaceRoomList ExploreRoom ShowSpaceExploreRoom +/* + 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 ExploreRoomCoordinatorDelegate: AnyObject { + func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType) +} + +/// `ExploreRoomCoordinatorType` is a protocol describing a Coordinator that handle keybackup setup navigation flow. +protocol ExploreRoomCoordinatorType: Coordinator, Presentable { + var delegate: ExploreRoomCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinator.swift new file mode 100644 index 000000000..5ca2d1e12 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinator.swift @@ -0,0 +1,71 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 UIKit + +final class SpaceChildRoomDetailCoordinator: SpaceChildRoomDetailCoordinatorType { + + // MARK: - Properties + + // MARK: Private + + private var spaceChildRoomDetailViewModel: SpaceChildRoomDetailViewModelType + private let spaceChildRoomDetailViewController: SpaceChildRoomDetailViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: SpaceChildRoomDetailCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceChildRoomDetailCoordinatorParameters) { + let spaceChildRoomDetailViewModel = SpaceChildRoomDetailViewModel(parameters: parameters) + let spaceChildRoomDetailViewController = SpaceChildRoomDetailViewController.instantiate(with: spaceChildRoomDetailViewModel) + self.spaceChildRoomDetailViewModel = spaceChildRoomDetailViewModel + self.spaceChildRoomDetailViewController = spaceChildRoomDetailViewController + } + + // MARK: - Public methods + + func start() { + self.spaceChildRoomDetailViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.spaceChildRoomDetailViewController + } + + func toSlidingPresentable() -> UIViewController & SlidingModalPresentable { + return self.spaceChildRoomDetailViewController + } +} + +// MARK: - SpaceChildRoomDetailViewModelCoordinatorDelegate +extension SpaceChildRoomDetailCoordinator: SpaceChildRoomDetailViewModelCoordinatorDelegate { + func spaceChildRoomDetailViewModel(_ viewModel: SpaceChildRoomDetailViewModelType, didOpenRoomWith roomId: String) { + self.delegate?.spaceChildRoomDetailCoordinator(self, didOpenRoomWith: roomId) + } + + func spaceChildRoomDetailViewModelDidCancel(_ viewModel: SpaceChildRoomDetailViewModelType) { + self.delegate?.spaceChildRoomDetailCoordinatorDidCancel(self) + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorParameters.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorParameters.swift new file mode 100644 index 000000000..91f2c94aa --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorParameters.swift @@ -0,0 +1,22 @@ +// +// 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 + +struct SpaceChildRoomDetailCoordinatorParameters { + let session: MXSession + let childInfo: MXSpaceChildInfo +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorType.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorType.swift new file mode 100644 index 000000000..bc312dd65 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailCoordinatorType.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 SpaceChildRoomDetailCoordinatorDelegate: AnyObject { + func spaceChildRoomDetailCoordinator(_ coordinator: SpaceChildRoomDetailCoordinatorType, didOpenRoomWith roomId: String) + func spaceChildRoomDetailCoordinatorDidCancel(_ coordinator: SpaceChildRoomDetailCoordinatorType) +} + +/// `SpaceChildRoomDetailCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. +protocol SpaceChildRoomDetailCoordinatorType: Coordinator, Presentable { + var delegate: SpaceChildRoomDetailCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewAction.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewAction.swift new file mode 100644 index 000000000..3d63addda --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewAction.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 + +/// SpaceChildRoomDetailViewController view actions exposed to view model +enum SpaceChildRoomDetailViewAction { + case loadData + case complete + case cancel +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.storyboard b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.storyboard new file mode 100644 index 000000000..68e4a8550 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.storyboard @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.swift new file mode 100644 index 000000000..d6edac90f --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewController.swift @@ -0,0 +1,200 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 UIKit + +final class SpaceChildRoomDetailViewController: UIViewController { + + // MARK: - Constants + + private enum Constants { + static let popoverWidth: CGFloat = 300 + static let topicMaxHeight: CGFloat = 105 + } + + // MARK: Outlets + + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var closeButton: UIButton! + @IBOutlet private weak var joinButton: UIButton! + @IBOutlet private weak var joinButtonTopMargin: NSLayoutConstraint! + @IBOutlet private weak var joinButtonBottomMargin: NSLayoutConstraint! + @IBOutlet private weak var avatarView: RoomAvatarView! + @IBOutlet private weak var userIconView: UIImageView! + @IBOutlet private weak var membersLabel: UILabel! + @IBOutlet private weak var topicLabel: UILabel! + @IBOutlet private weak var topicScrollView: UIScrollView! + + // MARK: Private + + private var viewModel: SpaceChildRoomDetailViewModelType! + private var theme: Theme! + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + + // MARK: - Setup + + class func instantiate(with viewModel: SpaceChildRoomDetailViewModelType) -> SpaceChildRoomDetailViewController { + let viewController = StoryboardScene.SpaceChildRoomDetailViewController.initialScene.instantiate() + 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 + } + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + self.titleLabel.textColor = theme.textPrimaryColor + self.titleLabel.font = theme.fonts.title3SB + self.joinButton.backgroundColor = theme.colors.accent + self.joinButton.tintColor = theme.colors.background + self.joinButton.setTitleColor(theme.colors.background, for: .normal) + self.membersLabel.font = theme.fonts.caption1 + self.membersLabel.textColor = theme.colors.tertiaryContent + self.topicLabel.font = theme.fonts.caption1 + self.topicLabel.textColor = theme.colors.tertiaryContent + self.userIconView.tintColor = theme.colors.tertiaryContent + self.closeButton.backgroundColor = theme.roomInputTextBorder + self.closeButton.tintColor = theme.noticeSecondaryColor + } + + 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.title = VectorL10n.roomDetailsTitle + self.joinButton.layer.masksToBounds = true + self.joinButton.layer.cornerRadius = 8.0 + self.joinButton.setTitle(VectorL10n.join, for: .normal) + } + + private func render(viewState: SpaceChildRoomDetailViewState) { + switch viewState { + case .loading: + self.renderLoading() + case .loaded(let roomInfo, let avatarViewData, let isJoined): + self.renderLoaded(roomInfo: roomInfo, avatarViewData: avatarViewData, isJoined: isJoined) + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded(roomInfo: MXSpaceChildInfo, avatarViewData: AvatarViewData, isJoined: Bool) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.titleLabel.text = roomInfo.displayName + self.avatarView.fill(with: avatarViewData) + self.membersLabel.text = roomInfo.activeMemberCount == 1 ? VectorL10n.roomTitleOneMember : VectorL10n.roomTitleMembers("\(roomInfo.activeMemberCount)") + self.topicLabel.text = roomInfo.topic + self.joinButton .setTitle(isJoined ? VectorL10n.open : VectorL10n.join, for: .normal) + } + + 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 + } + + // MARK: - IBActions + + @IBAction private func closeAction(sender: UIButton) { + self.viewModel.process(viewAction: .cancel) + } + + @IBAction private func doneButtonAction(_ sender: Any) { + self.viewModel.process(viewAction: .complete) + } +} + + +// MARK: - SpaceChildRoomDetailViewModelViewDelegate +extension SpaceChildRoomDetailViewController: SpaceChildRoomDetailViewModelViewDelegate { + + func spaceChildRoomDetailViewModel(_ viewModel: SpaceChildRoomDetailViewModelType, didUpdateViewState viewSate: SpaceChildRoomDetailViewState) { + self.render(viewState: viewSate) + } +} + +// MARK: - SlidingModalPresentable + +extension SpaceChildRoomDetailViewController: SlidingModalPresentable { + + func allowsDismissOnBackgroundTap() -> Bool { + return true + } + + func layoutHeightFittingWidth(_ width: CGFloat) -> CGFloat { + return self.intrisicHeight(with: width) + self.joinButtonTopMargin.constant + self.joinButtonBottomMargin.constant + } + +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModel.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModel.swift new file mode 100644 index 000000000..299c42d4a --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModel.swift @@ -0,0 +1,98 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 + +final class SpaceChildRoomDetailViewModel: SpaceChildRoomDetailViewModelType { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let childInfo: MXSpaceChildInfo + + private var currentOperation: MXHTTPOperation? + private var userDisplayName: String? + private var isRoomJoined: Bool { + let summary = self.session.roomSummary(withRoomId: self.childInfo.childRoomId) + return summary?.isJoined ?? false + } + + // MARK: Public + + weak var viewDelegate: SpaceChildRoomDetailViewModelViewDelegate? + weak var coordinatorDelegate: SpaceChildRoomDetailViewModelCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: SpaceChildRoomDetailCoordinatorParameters) { + self.session = parameters.session + self.childInfo = parameters.childInfo + } + + deinit { + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: SpaceChildRoomDetailViewAction) { + switch viewAction { + case .loadData: + self.loadData() + case .complete: + if self.isRoomJoined { + self.coordinatorDelegate?.spaceChildRoomDetailViewModel(self, didOpenRoomWith: self.childInfo.childRoomId) + } else { + joinRoom() + } + case .cancel: + self.cancelOperations() + self.coordinatorDelegate?.spaceChildRoomDetailViewModelDidCancel(self) + } + } + + // MARK: - Private + + private func loadData() { + let avatarViewData = AvatarViewData(matrixItemId: self.childInfo.childRoomId, displayName: self.childInfo.displayName, avatarUrl: self.childInfo.avatarUrl, mediaManager: self.session.mediaManager, fallbackImage: .matrixItem(self.childInfo.childRoomId, self.childInfo.name)) + self.update(viewState: .loaded(self.childInfo, avatarViewData, self.isRoomJoined)) + } + + private func update(viewState: SpaceChildRoomDetailViewState) { + self.viewDelegate?.spaceChildRoomDetailViewModel(self, didUpdateViewState: viewState) + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } + + private func joinRoom() { + self.update(viewState: .loading) + self.session.joinRoom(self.childInfo.childRoomId) { [weak self] (response) in + guard let self = self else { return } + switch response { + case .success: + self.loadData() + case .failure(let error): + self.update(viewState: .error(error)) + } + } + } +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModelType.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModelType.swift new file mode 100644 index 000000000..2ab7785e6 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewModelType.swift @@ -0,0 +1,37 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 SpaceChildRoomDetailViewModelViewDelegate: AnyObject { + func spaceChildRoomDetailViewModel(_ viewModel: SpaceChildRoomDetailViewModelType, didUpdateViewState viewSate: SpaceChildRoomDetailViewState) +} + +protocol SpaceChildRoomDetailViewModelCoordinatorDelegate: AnyObject { + func spaceChildRoomDetailViewModel(_ viewModel: SpaceChildRoomDetailViewModelType, didOpenRoomWith roomId: String) + func spaceChildRoomDetailViewModelDidCancel(_ viewModel: SpaceChildRoomDetailViewModelType) +} + +/// Protocol describing the view model used by `SpaceChildRoomDetailViewController` +protocol SpaceChildRoomDetailViewModelType { + + var viewDelegate: SpaceChildRoomDetailViewModelViewDelegate? { get set } + var coordinatorDelegate: SpaceChildRoomDetailViewModelCoordinatorDelegate? { get set } + + func process(viewAction: SpaceChildRoomDetailViewAction) +} diff --git a/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewState.swift b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewState.swift new file mode 100644 index 000000000..7ef065394 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceRoomList/SpaceChildRoomDetail/SpaceChildRoomDetailViewState.swift @@ -0,0 +1,26 @@ +// File created from ScreenTemplate +// $ createScreen.sh Spaces/SpaceRoomList/SpaceChildRoomDetail ShowSpaceChildRoomDetail +/* + 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 + +/// SpaceChildRoomDetailViewController view state +enum SpaceChildRoomDetailViewState { + case loading + case loaded(_ roomInfo: MXSpaceChildInfo, _ avatarViewData: AvatarViewData, _ isJoined: Bool) + case error(Error) +} diff --git a/Riot/Modules/SplitView/SplitViewCoordinator.swift b/Riot/Modules/SplitView/SplitViewCoordinator.swift index db35d8ff9..31880d487 100644 --- a/Riot/Modules/SplitView/SplitViewCoordinator.swift +++ b/Riot/Modules/SplitView/SplitViewCoordinator.swift @@ -45,6 +45,9 @@ final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType { private weak var tabBarCoordinator: TabBarCoordinatorType? + // Indicate if coordinator has been started once + private var hasStartedOnce: Bool = false + // MARK: Public var childCoordinators: [Coordinator] = [] @@ -64,24 +67,39 @@ final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType { // MARK: - Public methods func start() { - self.splitViewController.delegate = self + self.start(with: nil) + } + + func start(with spaceId: String?) { - let tabBarCoordinator = self.createTabBarCoordinator() - tabBarCoordinator.delegate = self - tabBarCoordinator.splitViewMasterPresentableDelegate = self - tabBarCoordinator.start() - - let detailNavigationController = self.createDetailNavigationController() - - self.splitViewController.viewControllers = [tabBarCoordinator.toPresentable(), detailNavigationController] - - self.add(childCoordinator: tabBarCoordinator) - - self.tabBarCoordinator = tabBarCoordinator - self.masterPresentable = tabBarCoordinator - self.detailNavigationController = detailNavigationController - - self.parameters.router.setRootModule(self.splitViewController) + if hasStartedOnce == false { + self.hasStartedOnce = true + + self.splitViewController.delegate = self + + let tabBarCoordinator = self.createTabBarCoordinator() + tabBarCoordinator.delegate = self + tabBarCoordinator.splitViewMasterPresentableDelegate = self + tabBarCoordinator.start(with: spaceId) + + let detailNavigationController = self.createDetailNavigationController() + + self.splitViewController.viewControllers = [tabBarCoordinator.toPresentable(), detailNavigationController] + + self.add(childCoordinator: tabBarCoordinator) + + self.tabBarCoordinator = tabBarCoordinator + self.masterPresentable = tabBarCoordinator + self.detailNavigationController = detailNavigationController + + self.parameters.router.setRootModule(self.splitViewController) + } else { + // Pop to home screen when selecting a new space + self.popToHome(animated: true) { + // Update tabBarCoordinator selected space + self.tabBarCoordinator?.start(with: spaceId) + } + } } func toPresentable() -> UIViewController { diff --git a/Riot/Modules/SplitView/SplitViewCoordinatorType.swift b/Riot/Modules/SplitView/SplitViewCoordinatorType.swift index d87138978..e49a54879 100644 --- a/Riot/Modules/SplitView/SplitViewCoordinatorType.swift +++ b/Riot/Modules/SplitView/SplitViewCoordinatorType.swift @@ -26,6 +26,10 @@ protocol SplitViewCoordinatorType: Coordinator, Presentable { var delegate: SplitViewCoordinatorDelegate? { get } + /// Start coordinator by selecting a Space. + /// - Parameter spaceId: The id of the Space to use. + func start(with spaceId: String?) + func popToHome(animated: Bool, completion: (() -> Void)?) // TODO: Do not expose publicly this method diff --git a/Riot/Modules/TabBar/MainTitleView.swift b/Riot/Modules/TabBar/MainTitleView.swift new file mode 100644 index 000000000..701dc4d90 --- /dev/null +++ b/Riot/Modules/TabBar/MainTitleView.swift @@ -0,0 +1,65 @@ +// +// 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 + +@objcMembers +class MainTitleView: UIStackView, Themable { + + // MARK: - Properties + + public private(set) var titleLabel: UILabel! + public private(set) var subtitleLabel: UILabel! + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + // MARK: - Themable + + func update(theme: Theme) { + self.titleLabel.textColor = theme.colors.primaryContent + self.titleLabel.font = theme.fonts.calloutSB + + self.subtitleLabel.textColor = theme.colors.tertiaryContent + self.subtitleLabel.font = theme.fonts.footnote + } + + // MARK: - Private + + private func setupView() { + self.titleLabel = UILabel(frame: .zero) + self.titleLabel.backgroundColor = .clear + + self.subtitleLabel = UILabel(frame: .zero) + self.subtitleLabel.backgroundColor = .clear + + self.addArrangedSubview(titleLabel) + self.addArrangedSubview(subtitleLabel) + self.distribution = .equalCentering + self.axis = .vertical + self.alignment = .center + self.spacing = 0.5 + } +} diff --git a/Riot/Modules/TabBar/MasterTabBarController.h b/Riot/Modules/TabBar/MasterTabBarController.h index 1d5f41a12..536abeb41 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.h +++ b/Riot/Modules/TabBar/MasterTabBarController.h @@ -37,6 +37,13 @@ #define TABBAR_GROUPS_INDEX 4 #define TABBAR_COUNT 5 +typedef NS_ENUM(NSUInteger, MasterTabBarIndex) { + MasterTabBarIndexHome = TABBAR_HOME_INDEX, + MasterTabBarIndexFavourites = TABBAR_FAVOURITES_INDEX, + MasterTabBarIndexPeople = TABBAR_PEOPLE_INDEX, + MasterTabBarIndexRooms = TABBAR_ROOMS_INDEX, + MasterTabBarIndexGroups = TABBAR_GROUPS_INDEX +}; @protocol MasterTabBarControllerDelegate; @@ -152,6 +159,13 @@ - (void)presentReviewUnverifiedSessionsAlertIfNeededWithSession:(MXSession*)session; +/// Filter rooms for each tab data source with the given room parent id. +/// It should keep rooms having an ancestor with `roomParentId` as parent id. +/// @param roomParentId The room parent id used to filter rooms. +/// @param mxSession The matrix session in which the room filtering should be done. +- (void)filterRoomsWithParentId:(NSString*)roomParentId + inMatrixSession:(MXSession*)mxSession; + // Reference to the current auth VC. It is not nil only when the auth screen is displayed. @property (nonatomic, readonly) AuthenticationViewController *authViewController; @@ -187,6 +201,8 @@ // Set tab bar item controllers - (void)updateViewControllers:(NSArray*)viewControllers; +- (void)removeTabAt:(MasterTabBarIndex)index; + @end @@ -194,5 +210,6 @@ - (void)masterTabBarControllerDidCompleteAuthentication:(MasterTabBarController *)masterTabBarController; - (void)masterTabBarController:(MasterTabBarController*)masterTabBarController wantsToDisplayDetailViewController:(UIViewController*)detailViewController; +- (void)masterTabBarController:(MasterTabBarController*)masterTabBarController needsSideMenuIconWithNotification:(BOOL)displayNotification; @end diff --git a/Riot/Modules/TabBar/MasterTabBarController.m b/Riot/Modules/TabBar/MasterTabBarController.m index 8d93f7f78..846829256 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.m +++ b/Riot/Modules/TabBar/MasterTabBarController.m @@ -31,7 +31,7 @@ #import "Riot-Swift.h" -@interface MasterTabBarController () +@interface MasterTabBarController () { // Array of `MXSession` instances. NSMutableArray *mxSessionArray; @@ -61,6 +61,9 @@ // The groups data source GroupsDataSource *groupsDataSource; + + // Custom title view of the navigation bar + MainTitleView *titleView; } @property(nonatomic,getter=isHidden) BOOL hidden; @@ -106,22 +109,35 @@ [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. + self.delegate = self; + _authenticationInProgress = NO; // Note: UITabBarViewController shoud not be embed in a UINavigationController (https://github.com/vector-im/riot-ios/issues/3086) [self vc_removeBackTitle]; + [self setupTitleView]; + titleView.titleLabel.text = NSLocalizedStringFromTable(@"title_home", @"Vector", nil); + childViewControllers = [NSMutableArray array]; + + MXWeakify(self); + [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { + MXStrongifyAndReturnIfNil(self); + [self updateSideMenuNotifcationIcon]; + }]; } - (void)userInterfaceThemeDidChange { - [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; + id theme = ThemeService.shared.theme; + [theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; - [ThemeService.shared.theme applyStyleOnTabBar:self.tabBar]; - - self.view.backgroundColor = ThemeService.shared.theme.backgroundColor; + [theme applyStyleOnTabBar:self.tabBar]; + self.view.backgroundColor = theme.backgroundColor; + [titleView updateWithTheme:theme]; + [self setNeedsStatusBarAppearanceUpdate]; } @@ -267,6 +283,9 @@ [self initializeDataSources]; + // Need to be called in case of the controllers have been replaced + [self.selectedViewController viewWillAppear:NO]; + // Adjust the display of the icons in the tabbar. for (UITabBarItem *tabBarItem in self.tabBar.items) { @@ -281,6 +300,21 @@ tabBarItem.imageInsets = UIEdgeInsetsMake(5, 0, -5, 0); } } + + titleView.titleLabel.text = self.selectedViewController.accessibilityLabel; + + // Need to be called in case of the controllers have been replaced + [self.selectedViewController viewDidAppear:NO]; +} + +- (void)removeTabAt:(MasterTabBarIndex)tag +{ + NSInteger index = [self indexOfTabItemWithTag:tag]; + if (index != NSNotFound) { + NSMutableArray *viewControllers = [NSMutableArray arrayWithArray:self.viewControllers]; + [viewControllers removeObjectAtIndex:index]; + self.viewControllers = viewControllers; + } } #pragma mark - @@ -761,6 +795,43 @@ return foundViewController; } +- (void)filterRoomsWithParentId:(NSString*)roomParentId + inMatrixSession:(MXSession*)mxSession +{ + titleView.subtitleLabel.text = roomParentId ? [mxSession roomSummaryWithRoomId:roomParentId].displayname : nil; + + recentsDataSource.currentSpace = [mxSession.spaceService getSpaceWithId:roomParentId]; + [self updateSideMenuNotifcationIcon]; +} + +- (void)updateSideMenuNotifcationIcon +{ + BOOL displayNotification = NO; + + for (MXRoomSummary *summary in recentsDataSource.mxSession.spaceService.rootSpaceSummaries) { + if (summary.membership == MXMembershipInvite) { + displayNotification = YES; + break; + } + } + + if (!displayNotification) { + MXSpaceNotificationState *notificationState = [recentsDataSource.mxSession.spaceService.notificationCounter notificationStateForAllSpacesExcept: recentsDataSource.currentSpace.spaceId]; + + if (recentsDataSource.currentSpace) + { + MXSpaceNotificationState *homeNotificationState = recentsDataSource.mxSession.spaceService.notificationCounter.homeNotificationState; + displayNotification = notificationState.groupMissedDiscussionsCount > 0 || notificationState.groupMissedDiscussionsHighlightedCount > 0 || homeNotificationState.allCount > 0 || homeNotificationState.allHighlightCount > 0; + } + else + { + displayNotification = notificationState.groupMissedDiscussionsCount > 0 || notificationState.groupMissedDiscussionsHighlightedCount > 0; + } + } + + [self.masterTabBarDelegate masterTabBarController:self needsSideMenuIconWithNotification:displayNotification]; +} + #pragma mark - /** @@ -841,6 +912,12 @@ } } +-(void)setupTitleView +{ + titleView = [MainTitleView new]; + self.navigationItem.titleView = titleView; +} + - (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion { // Keep ref on presented view controller @@ -1230,4 +1307,11 @@ [self.masterTabBarDelegate masterTabBarControllerDidCompleteAuthentication:self]; } +#pragma mark - UITabBarControllerDelegate + +- (void)tabBarController:(UITabBarController *)tabBarController didSelectViewController:(UIViewController *)viewController +{ + titleView.titleLabel.text = viewController.accessibilityLabel; +} + @end diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index afe0b32cb..b0089693e 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -50,8 +50,13 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { private let navigationRouter: NavigationRouterType private let masterNavigationController: UINavigationController + private var currentSpaceId: String? private var homeViewControllerWrapperViewController: HomeViewControllerWithBannerWrapperViewController? + private var currentMatrixSession: MXSession? { + return parameters.userSessionsService.mainUserSession?.matrixSession + } + // MARK: Public // Must be used only internally @@ -69,26 +74,40 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { let masterNavigationController = RiotNavigationController() self.navigationRouter = NavigationRouter(navigationController: masterNavigationController) self.masterNavigationController = masterNavigationController - } + } // MARK: - Public methods func start() { - let masterTabBarController = self.createMasterTabBarController() - masterTabBarController.masterTabBarDelegate = self - self.masterTabBarController = masterTabBarController - self.navigationRouter.setRootModule(masterTabBarController) + self.start(with: nil) + } - // Add existing Matrix sessions if any - for userSession in self.parameters.userSessionsService.userSessions { - self.addMatrixSessionToMasterTabBarController(userSession.matrixSession) - } + func start(with spaceId: String?) { + self.currentSpaceId = spaceId - if BuildSettings.enableSideMenu { - self.setupSideMenuGestures() + // If start has been done once do setup view controllers again + if self.masterTabBarController == nil { + let masterTabBarController = self.createMasterTabBarController() + masterTabBarController.masterTabBarDelegate = self + self.masterTabBarController = masterTabBarController + self.navigationRouter.setRootModule(masterTabBarController) + + // Add existing Matrix sessions if any + for userSession in self.parameters.userSessionsService.userSessions { + self.addMatrixSessionToMasterTabBarController(userSession.matrixSession) + } + + if BuildSettings.enableSideMenu { + self.setupSideMenuGestures() + } + + self.registerUserSessionsServiceNotifications() } + + self.updateMasterTabBarController(with: spaceId) self.registerUserSessionsServiceNotifications() + self.registerSessionChange() if let homeViewController = homeViewControllerWrapperViewController { let versionCheckCoordinator = VersionCheckCoordinator(rootViewController: masterTabBarController, @@ -115,7 +134,11 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { popToHomeViewControllerCompletion = completion masterNavigationController.delegate = self - masterNavigationController.popToViewController(masterTabBarController, animated: animated) + if masterNavigationController.viewControllers.last == masterTabBarController { + self.navigationController(masterNavigationController, didShow: masterTabBarController, animated: false) + } else { + masterNavigationController.popToViewController(masterTabBarController, animated: animated) + } } else { // Select the Home tab masterTabBarController.selectedIndex = Int(TABBAR_HOME_INDEX) @@ -171,32 +194,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { tabBarController.navigationItem.rightBarButtonItem = searchBarButtonItem - var viewControllers: [UIViewController] = [] - - let homeViewController = self.createHomeViewController() - viewControllers.append(homeViewController) - - if RiotSettings.shared.homeScreenShowFavouritesTab { - let favouritesViewController = self.createFavouritesViewController() - viewControllers.append(favouritesViewController) - } - - if RiotSettings.shared.homeScreenShowPeopleTab { - let peopleViewController = self.createPeopleViewController() - viewControllers.append(peopleViewController) - } - - if RiotSettings.shared.homeScreenShowRoomsTab { - let roomsViewController = self.createRoomsViewController() - viewControllers.append(roomsViewController) - } - - if RiotSettings.shared.homeScreenShowCommunitiesTab { - let groupsViewController = self.createGroupsViewController() - viewControllers.append(groupsViewController) - } - - tabBarController.updateViewControllers(viewControllers) + self.updateTabControllers(for: tabBarController, showCommunities: true) return tabBarController } @@ -263,6 +261,41 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { gesture.delegate = self } + private func updateMasterTabBarController(with spaceId: String?) { + + self.updateTabControllers(for: self.masterTabBarController, showCommunities: spaceId == nil) + self.masterTabBarController.filterRooms(withParentId: spaceId, inMatrixSession: self.currentMatrixSession) + } + + private func updateTabControllers(for tabBarController: MasterTabBarController, showCommunities: Bool) { + var viewControllers: [UIViewController] = [] + + let homeViewController = self.createHomeViewController() + viewControllers.append(homeViewController) + + if RiotSettings.shared.homeScreenShowFavouritesTab { + let favouritesViewController = self.createFavouritesViewController() + viewControllers.append(favouritesViewController) + } + + if RiotSettings.shared.homeScreenShowPeopleTab { + let peopleViewController = self.createPeopleViewController() + viewControllers.append(peopleViewController) + } + + if RiotSettings.shared.homeScreenShowRoomsTab { + let roomsViewController = self.createRoomsViewController() + viewControllers.append(roomsViewController) + } + + if RiotSettings.shared.homeScreenShowCommunitiesTab && !(self.currentMatrixSession?.groups().isEmpty ?? false) && showCommunities { + let groupsViewController = self.createGroupsViewController() + viewControllers.append(groupsViewController) + } + + tabBarController.updateViewControllers(viewControllers) + } + // MARK: Navigation private func showSideMenu() { @@ -317,6 +350,10 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { } self.addMatrixSessionToMasterTabBarController(userSession.matrixSession) + + if let matrixSession = self.currentMatrixSession, matrixSession.groups().isEmpty { + self.masterTabBarController.removeTab(at: .groups) + } } @objc private func userSessionsServiceWillRemoveUserSession(_ notification: Notification) { @@ -338,6 +375,16 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { MXLog.debug("[TabBarCoordinator] masterTabBarController.removeMatrixSession") self.masterTabBarController.removeMatrixSession(matrixSession) } + + private func registerSessionChange() { + NotificationCenter.default.addObserver(self, selector: #selector(sessionDidSync(_:)), name: NSNotification.Name.mxSessionDidSync, object: nil) + } + + @objc private func sessionDidSync(_ notification: Notification) { + if self.currentMatrixSession?.groups().isEmpty ?? true { + self.masterTabBarController.removeTab(at: .groups) + } + } } // MARK: - UINavigationControllerDelegate @@ -380,6 +427,16 @@ extension TabBarCoordinator: MasterTabBarControllerDelegate { self.splitViewMasterPresentableDelegate?.splitViewMasterPresentable(self, wantsToDisplay: detailViewController) } + + func masterTabBarController(_ masterTabBarController: MasterTabBarController!, needsSideMenuIconWithNotification displayNotification: Bool) { + let image = displayNotification ? Asset.Images.sideMenuNotifIcon.image : Asset.Images.sideMenuIcon.image + let sideMenuBarButtonItem: MXKBarButtonItem = MXKBarButtonItem(image: image, style: .plain) { [weak self] in + self?.showSideMenu() + } + sideMenuBarButtonItem.accessibilityLabel = VectorL10n.sideMenuRevealActionAccessibilityLabel + + self.masterTabBarController.navigationItem.leftBarButtonItem = sideMenuBarButtonItem + } } // MARK: - UIGestureRecognizerDelegate diff --git a/Riot/Modules/TabBar/TabBarCoordinatorType.swift b/Riot/Modules/TabBar/TabBarCoordinatorType.swift index 50d1cc36e..8d916606a 100644 --- a/Riot/Modules/TabBar/TabBarCoordinatorType.swift +++ b/Riot/Modules/TabBar/TabBarCoordinatorType.swift @@ -27,6 +27,10 @@ protocol TabBarCoordinatorDelegate: AnyObject { protocol TabBarCoordinatorType: Coordinator, SplitViewMasterPresentable { var delegate: TabBarCoordinatorDelegate? { get } + + /// Start coordinator by selecting a Space. + /// - Parameter spaceId: The id of the Space to use. + func start(with spaceId: String?) func popToHome(animated: Bool, completion: (() -> Void)?) diff --git a/Riot/Modules/User/Avatar/UserAvatarViewData.swift b/Riot/Modules/User/Avatar/UserAvatarViewData.swift index ce7be8ba6..25c5f46f5 100644 --- a/Riot/Modules/User/Avatar/UserAvatarViewData.swift +++ b/Riot/Modules/User/Avatar/UserAvatarViewData.swift @@ -25,4 +25,8 @@ struct UserAvatarViewData: AvatarViewDataProtocol { var matrixItemId: String { return userId } + + var fallbackImage: AvatarFallbackImage? { + return .matrixItem(matrixItemId, displayName) + } } diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index b4d4b8e00..ae6eb39b2 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -38,3 +38,5 @@ #import "SettingsViewController.h" #import "BugReportViewController.h" #import "BuildInfo.h" +#import "RoomMemberDetailsViewController.h" +#import "Tools.h" diff --git a/changelog.d/4052.feature b/changelog.d/4052.feature new file mode 100644 index 000000000..6a8ba63bb --- /dev/null +++ b/changelog.d/4052.feature @@ -0,0 +1 @@ +Spaces chooser \ No newline at end of file diff --git a/changelog.d/4068.feature b/changelog.d/4068.feature new file mode 100644 index 000000000..541b68b2c --- /dev/null +++ b/changelog.d/4068.feature @@ -0,0 +1 @@ +SDK: Support Spaces summary \ No newline at end of file diff --git a/changelog.d/4493.feature b/changelog.d/4493.feature new file mode 100644 index 000000000..d183a05b2 --- /dev/null +++ b/changelog.d/4493.feature @@ -0,0 +1 @@ +Space home view inherits title from previously viewed tab \ No newline at end of file diff --git a/changelog.d/4494.feature b/changelog.d/4494.feature new file mode 100644 index 000000000..c4e51fc02 --- /dev/null +++ b/changelog.d/4494.feature @@ -0,0 +1 @@ +Added Space menu \ No newline at end of file diff --git a/changelog.d/4495.feature b/changelog.d/4495.feature new file mode 100644 index 000000000..c99fd2888 --- /dev/null +++ b/changelog.d/4495.feature @@ -0,0 +1 @@ +Filter rooms for a given space \ No newline at end of file diff --git a/changelog.d/4496.feature b/changelog.d/4496.feature new file mode 100644 index 000000000..16a2d2fa4 --- /dev/null +++ b/changelog.d/4496.feature @@ -0,0 +1 @@ +Space invite \ No newline at end of file diff --git a/changelog.d/4497.feature b/changelog.d/4497.feature new file mode 100644 index 000000000..e3e0c8ef5 --- /dev/null +++ b/changelog.d/4497.feature @@ -0,0 +1 @@ +Space preview bottom sheet \ No newline at end of file diff --git a/changelog.d/4498.feature b/changelog.d/4498.feature new file mode 100644 index 000000000..95a7ffd47 --- /dev/null +++ b/changelog.d/4498.feature @@ -0,0 +1 @@ +Handle space link \ No newline at end of file diff --git a/changelog.d/4500.feature b/changelog.d/4500.feature new file mode 100644 index 000000000..6a6d3703f --- /dev/null +++ b/changelog.d/4500.feature @@ -0,0 +1 @@ +Support suggested rooms \ No newline at end of file diff --git a/changelog.d/4501.feature b/changelog.d/4501.feature new file mode 100644 index 000000000..7fc97b20d --- /dev/null +++ b/changelog.d/4501.feature @@ -0,0 +1 @@ +Show suggested in room lists \ No newline at end of file diff --git a/changelog.d/4502.feature b/changelog.d/4502.feature new file mode 100644 index 000000000..3e5161a1e --- /dev/null +++ b/changelog.d/4502.feature @@ -0,0 +1 @@ +Show space name in navigation bar title view for each root tab bar navigation controllers \ No newline at end of file diff --git a/changelog.d/4503.feature b/changelog.d/4503.feature new file mode 100644 index 000000000..3c0554a42 --- /dev/null +++ b/changelog.d/4503.feature @@ -0,0 +1 @@ +Space switching \ No newline at end of file diff --git a/changelog.d/4509.feature b/changelog.d/4509.feature new file mode 100644 index 000000000..79c448152 --- /dev/null +++ b/changelog.d/4509.feature @@ -0,0 +1 @@ +Added Show spaces in left panel \ No newline at end of file diff --git a/changelog.d/4571.feature b/changelog.d/4571.feature new file mode 100644 index 000000000..8ea8e3416 --- /dev/null +++ b/changelog.d/4571.feature @@ -0,0 +1 @@ +Explore rooms \ No newline at end of file diff --git a/changelog.d/4682.feature b/changelog.d/4682.feature new file mode 100644 index 000000000..aa211090c --- /dev/null +++ b/changelog.d/4682.feature @@ -0,0 +1 @@ +Browsing users in a space \ No newline at end of file diff --git a/changelog.d/4840.bugfix b/changelog.d/4840.bugfix new file mode 100644 index 000000000..3f5fe2ccd --- /dev/null +++ b/changelog.d/4840.bugfix @@ -0,0 +1 @@ +No notification for space invitation \ No newline at end of file diff --git a/changelog.d/4845.bugfix b/changelog.d/4845.bugfix new file mode 100644 index 000000000..4b96bde54 --- /dev/null +++ b/changelog.d/4845.bugfix @@ -0,0 +1 @@ +Odd error message in Space member list \ No newline at end of file diff --git a/changelog.d/4846.bugfix b/changelog.d/4846.bugfix new file mode 100644 index 000000000..000a96c57 --- /dev/null +++ b/changelog.d/4846.bugfix @@ -0,0 +1 @@ +Space view has communities tab at the bottom of the screen \ No newline at end of file diff --git a/changelog.d/4848.bugfix b/changelog.d/4848.bugfix new file mode 100644 index 000000000..f8031319c --- /dev/null +++ b/changelog.d/4848.bugfix @@ -0,0 +1 @@ +Take user to space overview after joining space \ No newline at end of file diff --git a/changelog.d/4849.bugfix b/changelog.d/4849.bugfix new file mode 100644 index 000000000..3857a2727 --- /dev/null +++ b/changelog.d/4849.bugfix @@ -0,0 +1 @@ +Refresh suggested room list in the home view when room is (un)marked as suggested \ No newline at end of file diff --git a/changelog.d/4850.bugfix b/changelog.d/4850.bugfix new file mode 100644 index 000000000..08f6a121d --- /dev/null +++ b/changelog.d/4850.bugfix @@ -0,0 +1 @@ +Bring leaving space experience in line with Web \ No newline at end of file diff --git a/changelog.d/4851.bugfix b/changelog.d/4851.bugfix new file mode 100644 index 000000000..d183a05b2 --- /dev/null +++ b/changelog.d/4851.bugfix @@ -0,0 +1 @@ +Space home view inherits title from previously viewed tab \ No newline at end of file diff --git a/changelog.d/4852.bugfix b/changelog.d/4852.bugfix new file mode 100644 index 000000000..466235455 --- /dev/null +++ b/changelog.d/4852.bugfix @@ -0,0 +1 @@ +Remove search filter when switching space \ No newline at end of file diff --git a/changelog.d/4890.bugfix b/changelog.d/4890.bugfix new file mode 100644 index 000000000..f066ffee8 --- /dev/null +++ b/changelog.d/4890.bugfix @@ -0,0 +1 @@ +Explore rooms list in space has odd ordering \ No newline at end of file diff --git a/changelog.d/4982.feature b/changelog.d/4982.feature new file mode 100644 index 000000000..aa211090c --- /dev/null +++ b/changelog.d/4982.feature @@ -0,0 +1 @@ +Browsing users in a space \ No newline at end of file