diff --git a/CHANGES.md b/CHANGES.md index 53b527969..040d7e5b4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +## Changes in 1.8.7 (2022-03-18) + +🙌 Improvements + +- Room: Allow ignoring invited users that have not joined a room yet ([#5866](https://github.com/vector-im/element-ios/issues/5866)) + + ## Changes in 1.8.6 (2022-03-14) 🙌 Improvements diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 164b076c5..e2f771c27 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.8.7 -CURRENT_PROJECT_VERSION = 1.8.7 +MARKETING_VERSION = 1.8.8 +CURRENT_PROJECT_VERSION = 1.8.8 diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index b9c639f15..8d3767215 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -369,7 +369,7 @@ final class BuildSettings: NSObject { static let authEnableRefreshTokens = false // MARK: - Onboarding - static let onboardingShowAccountPersonalisation = false + static let onboardingShowAccountPersonalization = false // MARK: - Unified Search static let unifiedSearchScreenShowPublicDirectory = true diff --git a/DesignKit/Variants/Fonts/ElementFonts.swift b/DesignKit/Variants/Fonts/ElementFonts.swift index d7538c905..e17984389 100644 --- a/DesignKit/Variants/Fonts/ElementFonts.swift +++ b/DesignKit/Variants/Fonts/ElementFonts.swift @@ -110,10 +110,10 @@ extension ElementFonts: Fonts { public var title2: SharedFont { let uiFont = self.font(forTextStyle: .title2) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title2) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -122,10 +122,10 @@ extension ElementFonts: Fonts { public var title2B: SharedFont { let uiFont = self.title2.uiFont.vc_bold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title2.bold()) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -134,10 +134,10 @@ extension ElementFonts: Fonts { public var title3: SharedFont { let uiFont = self.font(forTextStyle: .title3) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title3) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -146,10 +146,10 @@ extension ElementFonts: Fonts { public var title3SB: SharedFont { let uiFont = self.title3.uiFont.vc_semiBold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title3.weight(.semibold)) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -258,10 +258,10 @@ extension ElementFonts: Fonts { public var caption2: SharedFont { let uiFont = self.font(forTextStyle: .caption2) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .caption2) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -270,10 +270,10 @@ extension ElementFonts: Fonts { public var caption2SB: SharedFont { let uiFont = self.caption2.uiFont.vc_semiBold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .caption2.weight(.semibold)) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } diff --git a/Gemfile.lock b/Gemfile.lock index 610cbbadd..edfd3f855 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: CFPropertyList (3.0.5) rexml - activesupport (6.1.4.4) + activesupport (6.1.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -17,27 +17,27 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.541.0) - aws-sdk-core (3.124.0) + aws-partitions (1.568.0) + aws-sdk-core (3.130.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.52.0) - aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-kms (1.55.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.109.0) - aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-s3 (1.113.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) aws-sigv4 (1.4.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.0.3) - cocoapods (1.11.2) + cocoapods (1.11.3) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.11.3) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 1.4.0, < 2.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -52,7 +52,7 @@ GEM nap (~> 1.0) ruby-macho (>= 1.0, < 3.0) xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) + cocoapods-core (1.11.3) activesupport (>= 5.0, < 7) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -86,17 +86,18 @@ GEM escape (0.0.4) ethon (0.15.0) ffi (>= 1.15.0) - excon (0.89.0) - faraday (1.8.0) + excon (0.92.1) + faraday (1.10.0) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) @@ -105,14 +106,17 @@ GEM faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.6) - fastlane (2.199.0) + fastlane (2.205.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -157,13 +161,13 @@ GEM fastlane-plugin-versioning (0.5.0) fastlane-plugin-xcodegen (1.1.0) fastlane-plugin-brew (~> 0.1.1) - ffi (1.15.4) + ffi (1.15.5) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.14.0) + google-apis-androidpublisher_v3 (0.16.0) google-apis-core (>= 0.4, < 2.a) - google-apis-core (0.4.1) + google-apis-core (0.4.2) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -172,11 +176,11 @@ GEM retriable (>= 2.0, < 4.a) rexml webrick - google-apis-iamcredentials_v1 (0.9.0) + google-apis-iamcredentials_v1 (0.10.0) google-apis-core (>= 0.4, < 2.a) - google-apis-playcustomapp_v1 (0.6.0) + google-apis-playcustomapp_v1 (0.7.0) google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.10.0) + google-apis-storage_v1 (0.11.0) google-apis-core (>= 0.4, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) @@ -184,7 +188,7 @@ GEM google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) google-cloud-errors (1.2.0) - google-cloud-storage (1.35.0) + google-cloud-storage (1.36.1) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) @@ -192,8 +196,8 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.1.0) - faraday (>= 0.17.3, < 2.0) + googleauth (1.1.2) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) @@ -204,15 +208,15 @@ GEM http-cookie (1.0.4) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.8.11) + i18n (1.10.0) concurrent-ruby (~> 1.0) - jmespath (1.4.0) + jmespath (1.6.1) json (2.6.1) jwt (2.3.0) memoist (0.16.2) mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.1115) + mime-types-data (3.2022.0105) mini_magick (4.11.0) mini_mime (1.1.2) minitest (5.15.0) @@ -244,9 +248,9 @@ GEM ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.16.0) + signet (0.16.1) addressable (~> 2.8) - faraday (>= 0.17.3, < 2.0) + faraday (>= 0.17.5, < 3.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simctl (1.6.8) @@ -267,7 +271,7 @@ GEM uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8) + unf_ext (0.0.8.1) unicode-display_width (1.8.0) webrick (1.7.0) word_wrap (1.0.0) @@ -285,7 +289,7 @@ GEM rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) - zeitwerk (2.5.1) + zeitwerk (2.5.4) PLATFORMS ruby @@ -299,4 +303,4 @@ DEPENDENCIES xcode-install BUNDLED WITH - 2.2.32 + 2.3.9 diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json new file mode 100644 index 000000000..98829d4af --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_avatar_camera.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg new file mode 100644 index 000000000..50893f857 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json new file mode 100644 index 000000000..7026b6d89 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_avatar_edit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg new file mode 100644 index 000000000..f17013d91 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json similarity index 56% rename from Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/Contents.json rename to Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json index 0a20c899a..e312c8132 100644 --- a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "location_live_icon.png", + "filename" : "live_location_icon.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "location_live_icon@2x.png", + "filename" : "live_location_icon@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "location_live_icon@3x.png", + "filename" : "live_location_icon@3x.png", "idiom" : "universal", "scale" : "3x" } @@ -19,5 +19,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png new file mode 100644 index 000000000..15d2d7b11 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@2x.png b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@2x.png new file mode 100644 index 000000000..cc3a8fb4e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@3x.png b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@3x.png new file mode 100644 index 000000000..59282b8f1 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon.png b/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon.png deleted file mode 100644 index 6aa2e2639..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@2x.png b/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@2x.png deleted file mode 100644 index 4ed19fb09..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@3x.png b/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@3x.png deleted file mode 100644 index cabf83a92..000000000 Binary files a/Riot/Assets/Images.xcassets/Room/Location/location_live_icon.imageset/location_live_icon@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json new file mode 100644 index 000000000..de2178f44 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "dark-theme-no-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg new file mode 100644 index 000000000..ae6aa847b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json new file mode 100644 index 000000000..9d412b77e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "light-theme-no-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg new file mode 100644 index 000000000..f8468cbd2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json new file mode 100644 index 000000000..dd53ab236 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "light-and-dark-theme-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg new file mode 100644 index 000000000..2bf8d2125 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index d55b74231..51087b0b1 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -16,8 +16,23 @@ /** These strings will be ignored by Weblate. Useful for WIP **/ -// MARK: Onboarding Personalisation WIP +// MARK: Onboarding Personalization WIP "onboarding_congratulations_title" = "Congratulations!"; -"onboarding_congratulations_message" = "Your account\n%@\nhas been created."; -"onboarding_congratulations_personalise_button" = "Personalise profile"; +"onboarding_congratulations_message" = "Your account %@ has been created."; +"onboarding_congratulations_personalize_button" = "Personalise profile"; "onboarding_congratulations_home_button" = "Take me home"; + +"onboarding_personalization_save" = "Save and continue"; +"onboarding_personalization_skip" = "Skip this step"; + +"onboarding_display_name_title" = "Choose a display name"; +"onboarding_display_name_message" = "This will be shown when you send messages."; +"onboarding_display_name_placeholder" = "Display Name"; +"onboarding_display_name_hint" = "You can change this later"; +"onboarding_display_name_max_length" = "Your display name must be less than 256 characters"; + +"onboarding_avatar_title" = "Add a profile picture"; +"onboarding_avatar_message" = "You can change this anytime."; +"onboarding_avatar_accessibility_label" = "Profile picture"; + +"image_picker_action_files" = "Choose from files"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 931f07e75..cc748d6d3 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -57,7 +57,6 @@ "rename" = "Rename"; "collapse" = "collapse"; "send_to" = "Send to %@"; -"sending" = "Sending"; "close" = "Close"; "skip" = "Skip"; "joined" = "Joined"; @@ -75,6 +74,12 @@ "ok" = "OK"; "error" = "Error"; "suggest" = "Suggest"; +"edit" = "Edit"; + +// Activities +"loading" = "Loading"; +"sending" = "Sending"; +"saving" = "Saving"; // Call Bar "callbar_only_single_active" = "Tap to return to the call (%@)"; @@ -2086,13 +2091,14 @@ Tap the + to start adding people."; "location_sharing_settings_toggle_title" = "Enable location sharing"; +// MARK: Live location sharing + "location_sharing_live_share_title" = "Share live location"; - +"live_location_sharing_banner_title" = "Live location enabled"; +"live_location_sharing_banner_stop" = "Stop"; "location_sharing_static_share_title" = "Send my current location"; - "location_sharing_pin_drop_share_title" = "Send this location"; - // MARK: - MatrixKit diff --git a/Riot/Categories/Bundle.swift b/Riot/Categories/Bundle.swift index 055d9c64c..5b3430154 100644 --- a/Riot/Categories/Bundle.swift +++ b/Riot/Categories/Bundle.swift @@ -30,4 +30,9 @@ public extension Bundle { } return bundle } + + /// Whether or not the bundle is the RiotShareExtension. + var isShareExtension: Bool { + bundleURL.lastPathComponent.contains("RiotShareExtension.appex") + } } diff --git a/Riot/Categories/UIButton.swift b/Riot/Categories/UIButton.swift index 6f896bb58..195a2c244 100644 --- a/Riot/Categories/UIButton.swift +++ b/Riot/Categories/UIButton.swift @@ -14,7 +14,7 @@ limitations under the License. */ -import Foundation +import UIKit extension UIButton { @@ -51,4 +51,10 @@ extension UIButton { self.titleLabel?.adjustsFontForContentSizeCategory = newValue } } + + /// Set title font and enable Dynamic Type support + func vc_setTitleFont(_ font: UIFont) { + self.vc_adjustsFontForContentSizeCategory = true + self.titleLabel?.font = font + } } diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index d64969d94..469b4b1e7 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -124,6 +124,8 @@ internal class Asset: NSObject { internal static let onboardingSplashScreenPage3Dark = ImageAsset(name: "OnboardingSplashScreenPage3Dark") internal static let onboardingSplashScreenPage4 = ImageAsset(name: "OnboardingSplashScreenPage4") internal static let onboardingSplashScreenPage4Dark = ImageAsset(name: "OnboardingSplashScreenPage4Dark") + internal static let onboardingAvatarCamera = ImageAsset(name: "onboarding_avatar_camera") + internal static let onboardingAvatarEdit = ImageAsset(name: "onboarding_avatar_edit") internal static let onboardingCongratulationsIcon = ImageAsset(name: "onboarding_congratulations_icon") internal static let onboardingUseCaseCommunity = ImageAsset(name: "onboarding_use_case_community") internal static let onboardingUseCaseCommunityDark = ImageAsset(name: "onboarding_use_case_community_dark") @@ -169,7 +171,7 @@ internal class Asset: NSObject { internal static let videoCall = ImageAsset(name: "video_call") internal static let voiceCallHangonIcon = ImageAsset(name: "voice_call_hangon_icon") internal static let voiceCallHangupIcon = ImageAsset(name: "voice_call_hangup_icon") - internal static let locationLiveIcon = ImageAsset(name: "location_live_icon") + internal static let liveLocationIcon = ImageAsset(name: "live_location_icon") internal static let locationMarkerIcon = ImageAsset(name: "location_marker_icon") internal static let locationShareIcon = ImageAsset(name: "location_share_icon") internal static let locationUserMarker = ImageAsset(name: "location_user_marker") @@ -185,6 +187,9 @@ internal class Asset: NSObject { internal static let threadsFilter = ImageAsset(name: "threads_filter") internal static let threadsFilterApplied = ImageAsset(name: "threads_filter_applied") internal static let threadsIcon = ImageAsset(name: "threads_icon") + internal static let threadsIconGrayDotDark = ImageAsset(name: "threads_icon_gray_dot_dark") + internal static let threadsIconGrayDotLight = ImageAsset(name: "threads_icon_gray_dot_light") + internal static let threadsIconRedDot = ImageAsset(name: "threads_icon_red_dot") internal static let urlPreviewClose = ImageAsset(name: "url_preview_close") internal static let urlPreviewCloseDark = ImageAsset(name: "url_preview_close_dark") internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 193cc718d..cb9373175 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1651,6 +1651,10 @@ public class VectorL10n: NSObject { public static var e2eRoomKeyRequestTitle: String { return VectorL10n.tr("Vector", "e2e_room_key_request_title") } + /// Edit + public static var edit: String { + return VectorL10n.tr("Vector", "edit") + } /// Activities public static var emojiPickerActivityCategory: String { return VectorL10n.tr("Vector", "emoji_picker_activity_category") @@ -2703,6 +2707,18 @@ public class VectorL10n: NSObject { public static var less: String { return VectorL10n.tr("Vector", "less") } + /// Stop + public static var liveLocationSharingBannerStop: String { + return VectorL10n.tr("Vector", "live_location_sharing_banner_stop") + } + /// Live location enabled + public static var liveLocationSharingBannerTitle: String { + return VectorL10n.tr("Vector", "live_location_sharing_banner_title") + } + /// Loading + public static var loading: String { + return VectorL10n.tr("Vector", "loading") + } /// To discover contacts already using Matrix, %@ can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details. public static func localContactsAccessDiscoveryWarning(_ p1: String) -> String { return VectorL10n.tr("Vector", "local_contacts_access_discovery_warning", p1) @@ -5731,6 +5747,10 @@ public class VectorL10n: NSObject { public static var save: String { return VectorL10n.tr("Vector", "save") } + /// Saving + public static var saving: String { + return VectorL10n.tr("Vector", "saving") + } /// Search public static var searchDefaultPlaceholder: String { return VectorL10n.tr("Vector", "search_default_placeholder") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index efc032506..8be6e7646 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -10,22 +10,66 @@ import Foundation // swiftlint:disable function_parameter_count identifier_name line_length type_body_length public extension VectorL10n { + /// Choose from files + static var imagePickerActionFiles: String { + return VectorL10n.tr("Untranslated", "image_picker_action_files") + } + /// Profile picture + static var onboardingAvatarAccessibilityLabel: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_accessibility_label") + } + /// You can change this anytime. + static var onboardingAvatarMessage: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_message") + } + /// Add a profile picture + static var onboardingAvatarTitle: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_title") + } /// Take me home static var onboardingCongratulationsHomeButton: String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_home_button") } - /// Your account\n%@\nhas been created. + /// Your account %@ has been created. public static func onboardingCongratulationsMessage(_ p1: String) -> String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_message", p1) } /// Personalise profile - static var onboardingCongratulationsPersonaliseButton: String { - return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalise_button") + static var onboardingCongratulationsPersonalizeButton: String { + return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalize_button") } /// Congratulations! static var onboardingCongratulationsTitle: String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_title") } + /// You can change this later + static var onboardingDisplayNameHint: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_hint") + } + /// Your display name must be less than 256 characters + static var onboardingDisplayNameMaxLength: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_max_length") + } + /// This will be shown when you send messages. + static var onboardingDisplayNameMessage: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_message") + } + /// Display Name + static var onboardingDisplayNamePlaceholder: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_placeholder") + } + /// Choose a display name + static var onboardingDisplayNameTitle: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_title") + } + /// Save and continue + static var onboardingPersonalizationSave: String { + return VectorL10n.tr("Untranslated", "onboarding_personalization_save") + } + /// Skip this step + static var onboardingPersonalizationSkip: String { + return VectorL10n.tr("Untranslated", "onboarding_personalization_skip") + } } // swiftlint:enable function_parameter_count identifier_name line_length type_body_length diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 1333864ab..74880e69f 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -121,8 +121,13 @@ import AnalyticsEvents MXLog.debug("[Analytics] Started.") - // Catch and log crashes - MXLogger.logCrashes(true) + if Bundle.main.isShareExtension { + // Don't log crashes in the share extension + } else { + // Catch and log crashes + MXLogger.logCrashes(true) + } + MXLogger.setBuildVersion(AppInfo.current.buildInfo.readableBuildVersion) } diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index fef10f5b8..e218a1a11 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -251,7 +251,8 @@ extension AppCoordinator: LegacyAppDelegateDelegate { func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didAddMatrixSession session: MXSession!) { } - func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemoveMatrixSession session: MXSession!) { + func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemoveMatrixSession session: MXSession?) { + guard let session = session else { return } // Handle user session removal on clear cache. On clear cache the account has his session closed but the account is not removed. self.userSessionsService.removeUserSession(relatedToMatrixSession: session) } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index f7c2dff3b..85180036a 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -2048,7 +2048,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [[NSNotificationCenter defaultCenter] addObserverForName:kMXKAccountManagerDidSoftlogoutAccountNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXKAccount *account = notif.object; - [self removeMatrixSession:account.mxSession]; + + if (account.mxSession) + { + [self removeMatrixSession:account.mxSession]; + } // Return to authentication screen [self.masterTabBarController showSoftLogoutOnboardingFlowWithCredentials:account.mxCredentials]; diff --git a/Riot/Modules/Camera/CameraPresenter.swift b/Riot/Modules/Camera/CameraPresenter.swift index 12373bee9..863115d23 100644 --- a/Riot/Modules/Camera/CameraPresenter.swift +++ b/Riot/Modules/Camera/CameraPresenter.swift @@ -19,7 +19,7 @@ import UIKit import AVFoundation @objc protocol CameraPresenterDelegate: AnyObject { - func cameraPresenter(_ presenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) + func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage) func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL) func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter) } @@ -27,12 +27,6 @@ import AVFoundation /// CameraPresenter enables to present native camera @objc final class CameraPresenter: NSObject { - // MARK: - Constants - - private enum Constants { - static let jpegCompressionQuality: CGFloat = 1.0 - } - // MARK: - Properties // MARK: Private @@ -131,8 +125,8 @@ extension CameraPresenter: UIImagePickerControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let videoURL = info[.mediaURL] as? URL { self.delegate?.cameraPresenter(self, didSelectVideoAt: videoURL) - } else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage, let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) { - self.delegate?.cameraPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg) + } else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage { + self.delegate?.cameraPresenter(self, didSelectImage: image) } } diff --git a/Riot/Modules/Favorites/FavouritesViewController.m b/Riot/Modules/Favorites/FavouritesViewController.m index eb9499359..49942eeed 100644 --- a/Riot/Modules/Favorites/FavouritesViewController.m +++ b/Riot/Modules/Favorites/FavouritesViewController.m @@ -130,9 +130,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/Riot/Modules/Home/HomeViewController.m b/Riot/Modules/Home/HomeViewController.m index 0f51fd248..22db94190 100644 --- a/Riot/Modules/Home/HomeViewController.m +++ b/Riot/Modules/Home/HomeViewController.m @@ -600,9 +600,13 @@ { [self.collectionViewPaginationThrottler throttle:^{ NSInteger collectionViewSection = indexPath.section; + if (collectionView.numberOfSections <= collectionViewSection) + { + return; + } + NSInteger numberOfItemsInSection = [collectionView numberOfItemsInSection:collectionViewSection]; - if (collectionView.numberOfSections > collectionViewSection - && indexPath.item == numberOfItemsInSection - 1) + if (indexPath.item == numberOfItemsInSection - 1) { NSInteger tableViewSection = collectionView.tag; [self->recentsDataSource paginateInSection:tableViewSection]; diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index d0047f5f0..728a90fa1 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -2073,6 +2073,11 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; mxSession.syncFilterId, syncFilter.JSONDictionary); completion(NO); } + else if (!mxSession.store.allFilterIds.count) + { + MXLogDebug(@"[MXKAccount] There are no filters stored in this session, proceed as if no /sync was done before"); + completion(YES); + } else { // Check the filter is the one previously set diff --git a/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift b/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift index 1a1d20169..75135e8b2 100644 --- a/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift +++ b/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift @@ -27,6 +27,12 @@ import AVFoundation @objcMembers final class SingleImagePickerPresenter: NSObject { + // MARK: - Constants + + private enum Constants { + static let jpegCompressionQuality: CGFloat = 1.0 + } + // MARK: - Properties // MARK: Private @@ -117,8 +123,10 @@ final class SingleImagePickerPresenter: NSObject { // MARK: - CameraPresenterDelegate extension SingleImagePickerPresenter: CameraPresenterDelegate { - func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { - self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: uti) + func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImage image: UIImage) { + if let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) { + self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg) + } } func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter) { diff --git a/Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift b/Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift new file mode 100644 index 000000000..193a4cbee --- /dev/null +++ b/Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift @@ -0,0 +1,110 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import PhotosUI +import CommonKit + +@available(iOS 14.0, *) +protocol MediaPickerPresenterDelegate: AnyObject { + func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) + func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) +} + +/// A picker for photos and videos from the user's photo library on iOS 14+ using the +/// new `PHPickerViewController` that doesn't require permission to be granted. +/// +/// **Note:** If you need to support iOS 12 & 13, then you will need to use the older +/// `MediaPickerCoordinator`/`MediaPickerViewController` instead. +@available(iOS 14.0, *) +final class MediaPickerPresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private weak var pickerViewController: UIViewController? + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol? + private var loadingIndicator: UserIndicator? + + // MARK: Public + + weak var delegate: MediaPickerPresenterDelegate? + + // MARK: - Public + + // TODO: Support videos and multi-selection + func presentPicker(from presentingViewController: UIViewController, with filter: PHPickerFilter?, animated: Bool) { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = 1 + configuration.filter = filter + + let pickerViewController = PHPickerViewController(configuration: configuration) + pickerViewController.delegate = self + + self.pickerViewController = pickerViewController + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: pickerViewController) + + presentingViewController.present(pickerViewController, animated: true, completion: nil) + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let pickerViewController = pickerViewController else { return } + pickerViewController.dismiss(animated: animated, completion: completion) + } + + // MARK: - Private + + func showLoadingIndicator() { + loadingIndicator = indicatorPresenter?.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + func hideLoadingIndicator() { + loadingIndicator = nil + } +} + +// MARK: - PHPickerViewControllerDelegate +@available(iOS 14, *) +extension MediaPickerPresenter: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + // TODO: Handle videos and multi-selection + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { + self.delegate?.mediaPickerPresenterDidCancel(self) + return + } + + showLoadingIndicator() + + provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self else { return } + + guard let image = image as? UIImage else { + DispatchQueue.main.async { + self.hideLoadingIndicator() + self.delegate?.mediaPickerPresenterDidCancel(self) + } + return + } + + DispatchQueue.main.async { + self.hideLoadingIndicator() + self.delegate?.mediaPickerPresenter(self, didPickImage: image) + } + } + } +} diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index dd2a379ba..b9c99ad94 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -65,6 +65,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private var authenticationType: MXKAuthenticationType? private var session: MXSession? + private var shouldShowDisplayNameScreen = false + private var shouldShowAvatarScreen = false + /// Whether all of the onboarding steps have been completed or not. `false` if there are more screens to be shown. private var onboardingFinished = false /// Whether authentication is complete. `true` once authenticated, verified and the app is ready to be shown. @@ -182,6 +185,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } /// Displays the next view in the flow after the use case screen. + @available(iOS 14.0, *) private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { useCaseResult = result showAuthenticationScreen() @@ -247,12 +251,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.session = session self.authenticationType = authenticationType - // May need to move the spinner and key verification up to here in order to coordinate properly. - // Check whether another screen should be shown. if #available(iOS 14.0, *) { - if authenticationType == .register, let userId = session.credentials.userId, BuildSettings.onboardingShowAccountPersonalisation { - showCongratulationsScreen(userId: userId) + if authenticationType == .register, + let userId = session.credentials.userId, + let userSession = UserSessionsService.shared.userSession(withUserId: userId), + BuildSettings.onboardingShowAccountPersonalization { + checkHomeserverCapabilities(for: userSession) return } else if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: session) @@ -265,6 +270,24 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + /// Checks the capabilities of the user's homeserver in order to determine + /// whether or not the display name and avatar can be updated. + /// + /// Once complete this method will start the post authentication flow automatically. + @available(iOS 14.0, *) + private func checkHomeserverCapabilities(for userSession: UserSession) { + userSession.matrixSession.matrixRestClient.capabilities { [weak self] capabilities in + guard let self = self else { return } + self.shouldShowDisplayNameScreen = capabilities?.setDisplayName?.isEnabled == true + self.shouldShowAvatarScreen = capabilities?.setAvatarUrl?.isEnabled == true + + self.beginPostAuthentication(for: userSession) + } failure: { [weak self] _ in + MXLog.warning("[OnboardingCoordinator] Homeserver capabilities not returned. Skipping personalisation") + self?.beginPostAuthentication(for: userSession) + } + } + /// Displays the next view in the flow after the authentication screen. private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { isShowingAuthentication = false @@ -287,11 +310,19 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Post-Authentication + /// Starts the part of the flow that comes after authentication for new users. @available(iOS 14.0, *) - private func showCongratulationsScreen(userId: String) { + private func beginPostAuthentication(for userSession: UserSession) { + showCongratulationsScreen(for: userSession) + } + + /// Show the congratulations screen for new users. The screen will be configured based on the homeserver's capabilities. + @available(iOS 14.0, *) + private func showCongratulationsScreen(for userSession: UserSession) { MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen") - let parameters = OnboardingCongratulationsCoordinatorParameters(userId: userId) + let parameters = OnboardingCongratulationsCoordinatorParameters(userSession: userSession, + personalizationDisabled: !shouldShowDisplayNameScreen && !shouldShowAvatarScreen) let coordinator = OnboardingCongratulationsCoordinator(parameters: parameters) coordinator.completion = { [weak self, weak coordinator] result in @@ -308,21 +339,25 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the congratulations screen. @available(iOS 14.0, *) - private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsViewModelResult) { - if let session = session { - switch result { - case .personaliseProfile: - // TODO: Profile screens here instead. - if Analytics.shared.shouldShowAnalyticsPrompt { - showAnalyticsPrompt(for: session) - return - } - case .takeMeHome: - if Analytics.shared.shouldShowAnalyticsPrompt { - showAnalyticsPrompt(for: session) - return - } + private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { + switch result { + case .personalizeProfile(let userSession): + if shouldShowDisplayNameScreen { + showDisplayNameScreen(for: userSession) + return + } else if shouldShowAvatarScreen { + showAvatarScreen(for: userSession) + return + } else if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return + } + case .takeMeHome(let userSession): + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return } } @@ -330,6 +365,84 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + /// Show the display name personalization screen for new users using the supplied user session. + @available(iOS 14.0, *) + private func showDisplayNameScreen(for userSession: UserSession) { + MXLog.debug("[OnboardingCoordinator]: showDisplayNameScreen") + + let parameters = OnboardingDisplayNameCoordinatorParameters(userSession: userSession) + let coordinator = OnboardingDisplayNameCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] session in + guard let self = self, let coordinator = coordinator else { return } + self.displayNameCoordinator(coordinator, didCompleteWith: session) + } + + add(childCoordinator: coordinator) + coordinator.start() + + navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Displays the next view in the flow after the display name screen. + @available(iOS 14.0, *) + private func displayNameCoordinator(_ coordinator: OnboardingDisplayNameCoordinator, didCompleteWith userSession: UserSession) { + if shouldShowAvatarScreen { + showAvatarScreen(for: userSession) + return + } else if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return + } + + onboardingFinished = true + completeIfReady() + } + + /// Show the avatar personalization screen for new users using the supplied user session. + @available(iOS 14.0, *) + private func showAvatarScreen(for userSession: UserSession) { + MXLog.debug("[OnboardingCoordinator]: showAvatarScreen") + + let parameters = OnboardingAvatarCoordinatorParameters(userSession: userSession) + let coordinator = OnboardingAvatarCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] session in + guard let self = self, let coordinator = coordinator else { return } + self.avatarCoordinator(coordinator, didCompleteWith: session) + } + + add(childCoordinator: coordinator) + coordinator.start() + + if navigationRouter.modules.isEmpty || !shouldShowDisplayNameScreen { + navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + /// Displays the next view in the flow after the avatar screen. + @available(iOS 14.0, *) + private func avatarCoordinator(_ coordinator: OnboardingAvatarCoordinator, didCompleteWith userSession: UserSession) { + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return + } + + onboardingFinished = true + completeIfReady() + } + + /// Shows the analytics prompt for the supplied session. + /// + /// Check `Analytics.shared.shouldShowAnalyticsPrompt` before calling this method. @available(iOS 14.0, *) private func showAnalyticsPrompt(for session: MXSession) { MXLog.debug("[OnboardingCoordinator]: Invite the user to send analytics") @@ -351,6 +464,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the analytics screen. private func analyticsPromptCoordinatorDidComplete(_ coordinator: AnalyticsPromptCoordinator) { onboardingFinished = true completeIfReady() @@ -358,6 +472,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Finished + /// Calls the coordinator's completion handler if both `onboardingFinished` and `authenticationFinished` + /// are true. Otherwise displays any pending screens and waits to be called again. private func completeIfReady() { guard onboardingFinished else { MXLog.debug("[OnboardingCoordinator] Delaying onboarding completion until all screens have been shown.") diff --git a/Riot/Modules/People/PeopleViewController.m b/Riot/Modules/People/PeopleViewController.m index fc6b5b69e..0f8152c5e 100644 --- a/Riot/Modules/People/PeopleViewController.m +++ b/Riot/Modules/People/PeopleViewController.m @@ -105,9 +105,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift new file mode 100644 index 000000000..b6ad5e1c5 --- /dev/null +++ b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift @@ -0,0 +1,96 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Reusable +import UIKit + +@objcMembers +final class LiveLocationSharingBannerView: UIView, NibLoadable, Themable { + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet private weak var iconImageView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var stopButton: UIButton! + + // MARK: Private + + private var theme: Theme! + + // MARK: Public + + var didTapBackground: (() -> Void)? + var didTapStopButton: (() -> Void)? + + // MARK: - Setup + + static func instantiate() -> LiveLocationSharingBannerView { + let view = LiveLocationSharingBannerView.loadFromNib() + view.update(theme: ThemeService.shared().theme) + return view + } + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.setupBackgroundTapGestureRecognizer() + + self.titleLabel.text = VectorL10n.liveLocationSharingBannerTitle + self.stopButton.setTitle(VectorL10n.liveLocationSharingBannerStop, for: .normal) + } + + // MARK: - Public + + func update(theme: Theme) { + self.theme = theme + + let tintColor = theme.colors.background + + self.backgroundColor = theme.tintColor + + self.iconImageView.tintColor = tintColor + + self.titleLabel.textColor = tintColor + self.titleLabel.font = theme.fonts.footnote + + self.stopButton.vc_setTitleFont(theme.fonts.footnote) + self.stopButton.tintColor = tintColor + self.stopButton.setTitleColor(tintColor, for: .normal) + self.stopButton.setTitleColor(tintColor.withAlphaComponent(0.5), for: .highlighted) + } + + // MARK: - Private + + private func setupBackgroundTapGestureRecognizer() { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundViewTap(_:))) + self.addGestureRecognizer(tapGestureRecognizer) + } + + // MARK: - Actions + + @objc private func handleBackgroundViewTap(_ gestureRecognizer: UITapGestureRecognizer) { + self.didTapBackground?() + } + + @IBAction private func stopButtonAction(_ sender: Any) { + self.didTapStopButton?() + } +} diff --git a/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib new file mode 100644 index 000000000..f6c67f287 --- /dev/null +++ b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index 93cabe850..50f0871d5 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -630,7 +630,7 @@ } // Check whether the option Ignore may be presented - if (RiotSettings.shared.roomMemberScreenShowIgnore && self.mxRoomMember.membership == MXMembershipJoin) + if (RiotSettings.shared.roomMemberScreenShowIgnore) { // is he already ignored ? if (![self.mainSession isUserIgnored:self.mxRoomMember.userId]) diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index 84fd3ab62..c3799610a 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -459,4 +459,12 @@ extension RoomCoordinator: RoomViewControllerDelegate { func roomViewControllerDidStopLoading(_ roomViewController: RoomViewController) { stopLoading() } + + func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) { + // TODO: + } + + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { + // TODO: + } } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index de52c4978..1dcb69ece 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -277,6 +277,12 @@ didRequestEditForPollWithStartEvent:(MXEvent *)startEvent; */ - (void)roomViewControllerDidStopLoading:(RoomViewController *)roomViewController; +/// User tap live location sharing stop action +- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController; + +/// User tap live location sharing banner +- (void)roomViewControllerDidTapLiveLocationSharingBanner:(RoomViewController *)roomViewController; + @end NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 12fb57ec8..c63bbb11a 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -89,6 +89,10 @@ NSNotificationName const RoomCallTileTappedNotification = @"RoomCallTileTappedNotification"; NSNotificationName const RoomGroupCallTileTappedNotification = @"RoomGroupCallTileTappedNotification"; const NSTimeInterval kResizeComposerAnimationDuration = .05; +static const int kThreadListBarButtonItemTag = 99; +static UIEdgeInsets kThreadListBarButtonItemContentInsetsNoDot; +static UIEdgeInsets kThreadListBarButtonItemContentInsetsDot; +static CGSize kThreadListBarButtonItemImageSize; @interface RoomViewController () 0) { - threadListBarButtonItem.badgeText = [self threadListBadgeTextFor:notificationsCount.numberOfHighlightedThreads]; - threadListBarButtonItem.badgeBackgroundColor = ThemeService.shared.theme.colors.alert; + [button setImage:AssetImages.threadsIconRedDot.image + forState:UIControlStateNormal]; + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsDot; } else if (notificationsCount.numberOfNotifiedThreads > 0) { - threadListBarButtonItem.badgeText = [self threadListBadgeTextFor:notificationsCount.numberOfNotifiedThreads]; - threadListBarButtonItem.badgeBackgroundColor = ThemeService.shared.theme.noticeSecondaryColor; + if (ThemeService.shared.isCurrentThemeDark) + { + [button setImage:AssetImages.threadsIconGrayDotDark.image + forState:UIControlStateNormal]; + } + else + { + [button setImage:AssetImages.threadsIconGrayDotLight.image + forState:UIControlStateNormal]; + } + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsDot; } else { - // remove badge - threadListBarButtonItem.badgeText = nil; + [button setImage:[AssetImages.threadsIcon.image vc_resizedWith:kThreadListBarButtonItemImageSize] + forState:UIControlStateNormal]; + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsNoDot; } -} -- (NSString *)threadListBadgeTextFor:(NSUInteger)numberOfThreads -{ - if (numberOfThreads < 100) + if (replaceIndex == NSNotFound) { - return [NSString stringWithFormat:@"%tu", numberOfThreads]; + // there is no thread list bar button item, this was only an update + return; } - else + + UIBarButtonItem *originalItem = self.navigationItem.rightBarButtonItems[replaceIndex]; + UIButton *originalButton = (UIButton *)originalItem.customView; + if ([originalButton imageForState:UIControlStateNormal] == [button imageForState:UIControlStateNormal] + && UIEdgeInsetsEqualToEdgeInsets(originalButton.contentEdgeInsets, button.contentEdgeInsets)) { - return @"···"; + // no need to replace, it's the same + return; } + NSMutableArray *items = [self.navigationItem.rightBarButtonItems mutableCopy]; + items[replaceIndex] = threadListBarButtonItem; + self.navigationItem.rightBarButtonItems = items; } #pragma mark - RoomContextualMenuViewControllerDelegate @@ -7323,5 +7394,34 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self stopActivityIndicator]; } -@end +#pragma mark - Live location sharing +- (void)showLiveLocationBannerView +{ + if (self.liveLocationSharingBannerView) + { + return; + } + + LiveLocationSharingBannerView *bannerView = [LiveLocationSharingBannerView instantiate]; + + [bannerView updateWithTheme:ThemeService.shared.theme]; + + MXWeakify(self); + + bannerView.didTapBackground = ^{ + MXStrongifyAndReturnIfNil(self); + [self.delegate roomViewControllerDidTapLiveLocationSharingBanner:self]; + }; + + bannerView.didTapStopButton = ^{ + MXStrongifyAndReturnIfNil(self); + [self.delegate roomViewControllerDidStopLiveLocationSharing:self]; + }; + + [self.topBannersStackView addArrangedSubview:bannerView]; + + self.liveLocationSharingBannerView = bannerView; +} + +@end diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index e8b8bfd6e..783146c29 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -13,7 +13,6 @@ - @@ -32,6 +31,7 @@ + @@ -41,8 +41,25 @@ - - + + + + + + + + + + + + + @@ -192,6 +209,7 @@ + @@ -200,17 +218,19 @@ + + - + diff --git a/Riot/Modules/Rooms/RoomsViewController.m b/Riot/Modules/Rooms/RoomsViewController.m index 6e4e066d5..f7b54cf6a 100644 --- a/Riot/Modules/Rooms/RoomsViewController.m +++ b/Riot/Modules/Rooms/RoomsViewController.m @@ -110,9 +110,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/Riot/Modules/SideMenu/SideMenuViewAction.swift b/Riot/Modules/SideMenu/SideMenuViewAction.swift index b494e07dc..45adc68fa 100644 --- a/Riot/Modules/SideMenu/SideMenuViewAction.swift +++ b/Riot/Modules/SideMenu/SideMenuViewAction.swift @@ -22,4 +22,5 @@ import Foundation enum SideMenuViewAction { case loadData case tap(menuItem: SideMenuItem, sourceView: UIView) + case tapHeader(sourceView: UIView) } diff --git a/Riot/Modules/SideMenu/SideMenuViewController.storyboard b/Riot/Modules/SideMenu/SideMenuViewController.storyboard index 998474b8d..579a3332f 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.storyboard +++ b/Riot/Modules/SideMenu/SideMenuViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -28,29 +28,42 @@ - + + + + + + @@ -114,7 +127,7 @@ - + diff --git a/Riot/Modules/SideMenu/SideMenuViewController.swift b/Riot/Modules/SideMenu/SideMenuViewController.swift index f92be3bbd..344fb3764 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.swift +++ b/Riot/Modules/SideMenu/SideMenuViewController.swift @@ -198,6 +198,10 @@ final class SideMenuViewController: UIViewController { // MARK: - Actions + + @IBAction func headerTapAction(sender: UIView) { + self.viewModel.process(viewAction: .tapHeader(sourceView: sender)) + } } // MARK: - SideMenuViewModelViewDelegate diff --git a/Riot/Modules/SideMenu/SideMenuViewModel.swift b/Riot/Modules/SideMenu/SideMenuViewModel.swift index d1b013f97..0b0b84862 100644 --- a/Riot/Modules/SideMenu/SideMenuViewModel.swift +++ b/Riot/Modules/SideMenu/SideMenuViewModel.swift @@ -53,6 +53,8 @@ final class SideMenuViewModel: SideMenuViewModelType { self.loadData() case .tap(menuItem: let menuItem, sourceView: let sourceView): self.coordinatorDelegate?.sideMenuViewModel(self, didTapMenuItem: menuItem, fromSourceView: sourceView) + case .tapHeader(sourceView: let sourceView): + self.coordinatorDelegate?.sideMenuViewModel(self, didTapMenuItem: .settings, fromSourceView: sourceView) } } diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift index 276edf745..8cd8cc824 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -474,6 +474,13 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate { } + func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) { + // TODO: + } + + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { + // TODO: + } } // MARK: - ContactsPickerCoordinatorDelegate diff --git a/RiotShareExtension/Shared/ShareDataSource.h b/RiotShareExtension/Shared/ShareDataSource.h index 9dc36d5f5..92aef0c1d 100644 --- a/RiotShareExtension/Shared/ShareDataSource.h +++ b/RiotShareExtension/Shared/ShareDataSource.h @@ -31,7 +31,7 @@ @property (nonatomic, strong, readonly) NSSet *selectedRoomIdentifiers; - (instancetype)initWithFileStore:(MXFileStore *)fileStore - credentials:(MXCredentials *)credentials; + session:(MXSession *)session; - (void)selectRoomWithIdentifier:(NSString *)roomIdentifier animated:(BOOL)animated; diff --git a/RiotShareExtension/Shared/ShareDataSource.m b/RiotShareExtension/Shared/ShareDataSource.m index 83889c333..e097b5da7 100644 --- a/RiotShareExtension/Shared/ShareDataSource.m +++ b/RiotShareExtension/Shared/ShareDataSource.m @@ -20,7 +20,7 @@ @interface ShareDataSource () @property (nonatomic, strong, readonly) MXFileStore *fileStore; -@property (nonatomic, strong, readonly) MXCredentials *credentials; +@property (nonatomic, strong, readonly) MXSession *session; @property NSArray *recentCellDatas; @property NSMutableArray *visibleRoomCellDatas; @@ -32,12 +32,12 @@ @implementation ShareDataSource - (instancetype)initWithFileStore:(MXFileStore *)fileStore - credentials:(MXCredentials *)credentials + session:(MXSession *)session { if (self = [super init]) { _fileStore = fileStore; - _credentials = credentials; + _session = session; _internalSelectedRoomIdentifiers = [NSMutableSet set]; @@ -81,19 +81,13 @@ NSMutableArray *cellData = [NSMutableArray array]; - MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:self.credentials andOnUnrecognizedCertificateBlock:nil andPersistentTokenDataHandler:^(void (^handler)(NSArray *credentials, void (^completion)(BOOL didUpdateCredentials))) { - [[MXKAccountManager sharedManager] readAndWriteCredentials:handler]; - } andUnauthenticatedHandler:nil]; - // Add a fake matrix session to each room summary to provide it a REST client (used to handle correctly the room avatar). - MXSession *session = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; - for (id summary in summaries) { if (!summary.hiddenFromUser && summary.roomType == MXRoomTypeRoom) { if ([summary respondsToSelector:@selector(setMatrixSession:)]) { - [summary setMatrixSession:session]; + [summary setMatrixSession:self.session]; } MXKRecentCellData *recentCellData = [[MXKRecentCellData alloc] initWithRoomSummary:summary dataSource:nil]; diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m index ad7a75ad3..2233b352d 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -34,11 +34,21 @@ @property (nonatomic, strong) MXKAccount *userAccount; @property (nonatomic, strong) MXFileStore *fileStore; +/** + An array of rooms that the item is being shared to. This is to maintain a strong ref + to all necessary `MXRoom`s until sharing has completed. + */ +@property (nonatomic, strong) NSMutableArray *selectedRooms; + @end @implementation ShareManager +/// A fake matrix session used to provide summaries with a REST client to handle room avatars. +/// The session is stored statically to prevent new ones from being created for each share. +static MXSession *fakeSession; + - (instancetype)initWithShareItemSender:(id)itemSender type:(ShareManagerType)type { @@ -94,17 +104,19 @@ session.crypto.warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now - NSMutableArray *rooms = [NSMutableArray array]; + self.selectedRooms = [NSMutableArray array]; for (NSString *roomIdentifier in roomIdentifiers) { MXRoom *room = [MXRoom loadRoomFromStore:self.fileStore withRoomId:roomIdentifier matrixSession:session]; if (room) { - [rooms addObject:room]; + [self.selectedRooms addObject:room]; } } - [self.shareItemSender sendItemsToRooms:rooms success:^{ + [self.shareItemSender sendItemsToRooms:self.selectedRooms success:^{ + self.selectedRooms = nil; self.completionCallback(ShareManagerResultFinished); } failure:^(NSArray *errors) { + self.selectedRooms = nil; [self showFailureAlert:[VectorL10n roomEventFailedToSend]]; }]; @@ -174,6 +186,7 @@ // We consider the first enabled account. // TODO: Handle multiple accounts self.userAccount = [MXKAccountManager sharedManager].activeAccounts.firstObject; + [self checkFakeSession]; } // Reset the file store to reload the room data. @@ -183,12 +196,12 @@ _fileStore = nil; } - if (self.userAccount) + if (self.userAccount && fakeSession) { _fileStore = [[MXFileStore alloc] initWithCredentials:self.userAccount.mxCredentials]; ShareDataSource *roomDataSource = [[ShareDataSource alloc] initWithFileStore:_fileStore - credentials:self.userAccount.mxCredentials]; + session:fakeSession]; [self.shareViewController configureWithState:ShareViewControllerAccountStateConfigured roomDataSource:roomDataSource]; @@ -198,6 +211,27 @@ } } +- (void)checkFakeSession +{ + if (!self.userAccount) + { + return; + } + + if (fakeSession && [fakeSession.credentials.userId isEqualToString:self.userAccount.mxCredentials.userId]) + { + return; + } + + MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:self.userAccount.mxCredentials + andOnUnrecognizedCertificateBlock:nil + andPersistentTokenDataHandler:^(void (^handler)(NSArray *credentials, void (^completion)(BOOL didUpdateCredentials))) { + [[MXKAccountManager sharedManager] readAndWriteCredentials:handler]; + } andUnauthenticatedHandler:nil]; + + fakeSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; +} + - (void)didStartSending { [self.shareViewController showProgressIndicator]; diff --git a/RiotShareExtension/Sources/ShareItemSender.m b/RiotShareExtension/Sources/ShareItemSender.m index 1758622ec..2c11e2a46 100644 --- a/RiotShareExtension/Sources/ShareItemSender.m +++ b/RiotShareExtension/Sources/ShareItemSender.m @@ -35,7 +35,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) @interface ShareItemSender () -@property (nonatomic, strong, readonly) UIViewController *rootViewController; +@property (nonatomic, weak, readonly) UIViewController *rootViewController; @property (nonatomic, strong, readonly) ShareExtensionShareItemProvider *shareItemProvider; @property (nonatomic, strong, readonly) NSMutableArray *pendingImages; @@ -641,7 +641,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) { if (!RiotSettings.shared.showMediaCompressionPrompt) { - [MXSDKOptions sharedInstance].videoConversionPresetName = AVCaptureSessionPreset1920x1080; + [MXSDKOptions sharedInstance].videoConversionPresetName = AVAssetExportPreset1920x1080; sendVideo(); } else diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift index 816498006..96c917259 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift @@ -71,13 +71,14 @@ struct AnalyticsPrompt: View { Text(VectorL10n.analyticsPromptTitle(AppInfo.current.displayName)) .font(theme.fonts.title2B) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.primaryContent) .padding(.bottom, 2) messageText .font(theme.fonts.body) - .foregroundColor(theme.colors.secondaryContent) .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) Divider() .background(theme.colors.quinaryContent) @@ -117,8 +118,11 @@ struct AnalyticsPrompt: View { .padding(.top, 50) .padding(.horizontal, horizontalPadding) } + .frame(maxWidth: OnboardingConstants.maxContentWidth) + .frame(maxWidth: .infinity) buttons + .frame(maxWidth: OnboardingConstants.maxContentWidth) .padding(.horizontal, horizontalPadding) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) } diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift index ccc315a73..5bed53bd5 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift @@ -35,22 +35,15 @@ struct AvatarImage: View { case .empty: ProgressView() case .placeholder(let firstCharacter, let colorIndex): - Text(firstCharacter) - .padding(4) - .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .foregroundColor(.white) - .background(theme.colors.namesAndAvatars[colorIndex]) - .clipShape(Circle()) - // Make the text resizable (i.e. Make it large and then allow it to scale down) - .font(.system(size: 200)) - .minimumScaleFactor(0.001) + PlaceholderAvatarImage(firstCharacter: firstCharacter, + colorIndex: colorIndex) case .avatar(let image): Image(uiImage: image) .resizable() - .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .clipShape(Circle()) } } + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .clipShape(Circle()) .onAppear { viewModel.inject(dependencies: dependencies) viewModel.loadAvatar( diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift new file mode 100644 index 000000000..93f0a7186 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift @@ -0,0 +1,60 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +/// A reusable view that will show a standard placeholder avatar with the +/// supplied character and colour index for the `namesAndAvatars` color array. +/// +/// This view has a forced 1:1 aspect ratio but will appear very large until a `.frame` +/// modifier is applied. +struct PlaceholderAvatarImage: View { + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let firstCharacter: Character + let colorIndex: Int + + // MARK: - Views + + var body: some View { + ZStack { + theme.colors.namesAndAvatars[colorIndex] + + Text(String(firstCharacter)) + .padding(4) + .foregroundColor(.white) + // Make the text resizable (i.e. Make it large and then allow it to scale down) + .font(.system(size: 200)) + .minimumScaleFactor(0.001) + } + .aspectRatio(1, contentMode: .fill) + } +} + +@available(iOS 14.0, *) +struct Previews_TemplateAvatarImage_Previews: PreviewProvider { + static var previews: some View { + PlaceholderAvatarImage(firstCharacter: "X", colorIndex: 1) + .clipShape(Circle()) + .frame(width: 150, height: 100) + } +} diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift index 5908583b0..e0bb01e97 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -35,7 +35,7 @@ struct SpaceAvatarImage: View { case .empty: ProgressView() case .placeholder(let firstCharacter, let colorIndex): - Text(firstCharacter) + Text(String(firstCharacter)) .padding(10) .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) .foregroundColor(.white) diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift index 1a5f8f032..2b49dc289 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -42,10 +42,11 @@ class AvatarViewModel: InjectableObject, ObservableObject { colorCount: Int, avatarSize: AvatarSize) { - self.viewState = .placeholder( - firstCharacterCapitalized(displayName), - stableColorIndex(matrixItemId: matrixItemId, colorCount: colorCount) - ) + let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName, + matrixItemId: matrixItemId, + colorCount: colorCount) + + self.viewState = .placeholder(placeholderViewModel.firstCharacterCapitalized, placeholderViewModel.stableColorIndex) guard let mxContentUri = mxContentUri, mxContentUri.count > 0 else { return @@ -60,31 +61,4 @@ class AvatarViewModel: InjectableObject, ObservableObject { } .store(in: &cancellables) } - - /// Get the first character of a string capialized or else an empty string. - /// - Parameter string: The input string to get the capitalized letter from. - /// - Returns: The capitalized first letter. - private func firstCharacterCapitalized(_ string: String?) -> String { - guard let character = string?.first else { - return "" - } - return String(character).capitalized - } - - /// Provides the same color each time for a specified matrixId - /// - /// Same algorithm as in AvatarGenerator. - /// - Parameters: - /// - matrixItemId: the matrix id used as input to create the stable index. - /// - colorCount: The number of total colors we want to index in to. - /// - Returns: The stable index. - private func stableColorIndex(matrixItemId: String, colorCount: Int) -> Int { - // Sum all characters - let sum = matrixItemId.utf8 - .map({ UInt($0) }) - .reduce(0, +) - // modulo the color count - return Int(sum) % colorCount - } - } diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift index 203d3f9e9..cac2a70d4 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift @@ -19,6 +19,6 @@ import UIKit enum AvatarViewState { case empty - case placeholder(String, Int) + case placeholder(Character, Int) case avatar(UIImage) } diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift new file mode 100644 index 000000000..d5c131b39 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift @@ -0,0 +1,47 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Simple view model that computes the placeholder avatar properties. +struct PlaceholderAvatarViewModel { + /// The displayname used to create the `firstCharacterCapitalized`. + let displayName: String? + /// The matrix id used as input to create the `stableColorIndex` from. + let matrixItemId: String + /// The number of total colors available for the `stableColorIndex`. + let colorCount: Int + + /// Get the first character of the display name capitalized or else a space character. + var firstCharacterCapitalized: Character { + return displayName?.capitalized.first ?? " " + } + + /// Provides the same color each time for a specified matrixId + /// + /// Same algorithm as in AvatarGenerator. + /// - Parameters: + /// - matrixItemId: the matrix id used as input to create the stable index. + /// - Returns: The stable index. + var stableColorIndex: Int { + // Sum all characters + let sum = matrixItemId.utf8 + .map({ UInt($0) }) + .reduce(0, +) + // modulo the color count + return Int(sum) % colorCount + } +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 3c5909a4c..7201176c3 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,6 +20,8 @@ import Foundation @available(iOS 14.0, *) enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockOnboardingAvatarScreenState.self, + MockOnboardingDisplayNameScreenState.self, MockOnboardingCongratulationsScreenState.self, MockOnboardingUseCaseSelectionScreenState.self, MockOnboardingSplashScreenScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift index 5824cbc85..b2d0206fc 100644 --- a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift @@ -23,29 +23,34 @@ struct PrimaryActionButtonStyle: ButtonStyle { var customColor: Color? = nil + private var fontColor: Color { + // Always white unless disabled with a dark theme. + .white.opacity(theme.isDark && !isEnabled ? 0.3 : 1.0) + } + + private var backgroundColor: Color { + customColor ?? theme.colors.accent + } + func makeBody(configuration: Self.Configuration) -> some View { configuration.label .padding(12.0) .frame(maxWidth: .infinity) - .foregroundColor(.white) + .foregroundColor(fontColor) .font(theme.fonts.body) - .background(backgroundColor(configuration.isPressed)) - .opacity(isEnabled ? 1.0 : 0.6) + .background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed))) .cornerRadius(8.0) } - func backgroundColor(_ isPressed: Bool) -> Color { - if let customColor = customColor { - return customColor - } - - return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent + func backgroundOpacity(when isPressed: Bool) -> CGFloat { + guard isEnabled else { return 0.3 } + return isPressed ? 0.6 : 1.0 } } @available(iOS 14.0, *) struct PrimaryActionButtonStyle_Previews: PreviewProvider { - static var previews: some View { + static var buttons: some View { Group { VStack { Button("Enabled") { } @@ -67,4 +72,11 @@ struct PrimaryActionButtonStyle_Previews: PreviewProvider { .padding() } } + + static var previews: some View { + buttons + .theme(.light).preferredColorScheme(.light) + buttons + .theme(.dark).preferredColorScheme(.dark) + } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift new file mode 100644 index 000000000..70a9651d2 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -0,0 +1,198 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import CommonKit + +struct OnboardingAvatarCoordinatorParameters { + let userSession: UserSession +} + +@available(iOS 14.0, *) +final class OnboardingAvatarCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: OnboardingAvatarCoordinatorParameters + private let onboardingAvatarHostingController: VectorHostingController + private var onboardingAvatarViewModel: OnboardingAvatarViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + + private lazy var cameraPresenter: CameraPresenter = { + let presenter = CameraPresenter() + presenter.delegate = self + return presenter + }() + + private lazy var mediaPickerPresenter: MediaPickerPresenter = { + let presenter = MediaPickerPresenter() + presenter.delegate = self + return presenter + }() + + private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: parameters.userSession.matrixSession, + initialRange: 0, + andRange: 1.0) + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSession) -> Void)? + + // MARK: - Setup + + init(parameters: OnboardingAvatarCoordinatorParameters) { + self.parameters = parameters + let viewModel = OnboardingAvatarViewModel(userId: parameters.userSession.userId, + displayName: parameters.userSession.account.userDisplayName, + avatarColorCount: DefaultThemeSwiftUI().colors.namesAndAvatars.count) + let view = OnboardingAvatarScreen(viewModel: viewModel.context) + onboardingAvatarViewModel = viewModel + onboardingAvatarHostingController = VectorHostingController(rootView: view) + onboardingAvatarHostingController.vc_removeBackTitle() + onboardingAvatarHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingAvatarHostingController) + } + + + // MARK: - Public + + func start() { + MXLog.debug("[OnboardingAvatarCoordinator] did start.") + onboardingAvatarViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[OnboardingAvatarCoordinator] OnboardingAvatarViewModel did complete with result: \(result).") + switch result { + case .pickImage: + self.pickImage() + case .takePhoto: + self.takePhoto() + case .save(let avatar): + self.setAvatar(avatar) + case .skip: + self.completion?(self.parameters.userSession) + } + } + } + + func toPresentable() -> UIViewController { + return self.onboardingAvatarHostingController + } + + // MARK: - Private + + /// Show a blocking activity indicator whilst saving. + private func startWaiting() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopWaiting() { + waitingIndicator = nil + } + + /// Present an image picker for the device photo library. + private func pickImage() { + let controller = toPresentable() + mediaPickerPresenter.presentPicker(from: controller, with: .images, animated: true) + } + + /// Present a camera view to take a photo to use for the avatar. + private func takePhoto() { + let controller = toPresentable() + cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) + } + + /// Set the supplied image as user's avatar, completing the screen's display if successful. + func setAvatar(_ image: UIImage?) { + guard let image = image else { + MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.") + return + } + + startWaiting() + + guard let avatarData = MXKTools.forceImageOrientationUp(image)?.jpegData(compressionQuality: 0.5) else { + MXLog.error("[OnboardingAvatarCoordinator] Failed to create jpeg data.") + self.stopWaiting() + self.onboardingAvatarViewModel.processError(nil) + return + } + + mediaUploader.uploadData(avatarData, filename: nil, mimeType: "image/jpeg") { [weak self] urlString in + guard let self = self else { return } + + guard let urlString = urlString else { + MXLog.error("[OnboardingAvatarCoordinator] Missing URL string for avatar.") + self.stopWaiting() + self.onboardingAvatarViewModel.processError(nil) + return + } + + self.parameters.userSession.account.setUserAvatarUrl(urlString) { [weak self] in + guard let self = self else { return } + self.stopWaiting() + self.completion?(self.parameters.userSession) + } failure: { [weak self] error in + guard let self = self else { return } + self.stopWaiting() + self.onboardingAvatarViewModel.processError(error as NSError?) + } + } failure: { [weak self] error in + guard let self = self else { return } + self.stopWaiting() + self.onboardingAvatarViewModel.processError(error as NSError?) + } + } +} + +// MARK: - MediaPickerPresenterDelegate + +@available(iOS 14.0, *) +extension OnboardingAvatarCoordinator: MediaPickerPresenterDelegate { + func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) { + onboardingAvatarViewModel.updateAvatarImage(with: image) + presenter.dismiss(animated: true, completion: nil) + } + + func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} + +// MARK: - CameraPresenterDelegate + +@available(iOS 14.0, *) +extension OnboardingAvatarCoordinator: CameraPresenterDelegate { + func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage) { + onboardingAvatarViewModel.updateAvatarImage(with: image) + presenter.dismiss(animated: true, completion: nil) + } + + func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL) { + presenter.dismiss(animated: true, completion: nil) + } + + func cameraPresenterDidCancel(_ presenter: CameraPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift new file mode 100644 index 000000000..8dff96b67 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.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 SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case placeholderAvatar(userId: String, displayName: String) + case userSelectedAvatar(userId: String, displayName: String) + + /// The associated screen + var screenType: Any.Type { + OnboardingAvatarScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockOnboardingAvatarScreenState] { + let userId = "@example:matrix.org" + let displayName = "Jane" + + return [ + .placeholderAvatar(userId: userId, displayName: displayName), + .userSelectedAvatar(userId: userId, displayName: displayName) + ] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count + let viewModel: OnboardingAvatarViewModel + switch self { + case .placeholderAvatar(let userId, let displayName): + viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) + case .userSelectedAvatar(let userId, let displayName): + viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) + viewModel.updateAvatarImage(with: Asset.Images.appSymbol.image) + } + + return ( + [self, viewModel], + AnyView(OnboardingAvatarScreen(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} + +@available(iOS 14.0, *) +extension MockOnboardingAvatarScreenState: CustomStringConvertible { + // Added to have different descriptions in the SwiftUI target's list. + var description: String { + switch self { + case .placeholderAvatar: + return "placeholderAvatar" + case .userSelectedAvatar: + return "userSelectedAvatar" + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift new file mode 100644 index 000000000..7f0187ebb --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift @@ -0,0 +1,63 @@ +// +// 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 + +// MARK: View model + +enum OnboardingAvatarViewModelResult { + /// The user would like to choose an image from their photo library. + case pickImage + /// The user would like to take a photo to use as their avatar. + case takePhoto + /// The user would like to set specified image as their avatar. + case save(UIImage?) + /// Move on to the next screen in the flow without setting an avatar. + case skip +} + +// MARK: View + +struct OnboardingAvatarViewState: BindableState { + /// The letter shown in the placeholder avatar. + let placeholderAvatarLetter: Character + /// The color index to use for the placeholder avatar's background. + let placeholderAvatarColorIndex: Int + /// The image selected by the user to use as their avatar. + var avatar: UIImage? + var bindings: OnboardingAvatarBindings + + /// The image shown in the avatar's button. + var buttonImage: ImageAsset { + avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit + } +} + +struct OnboardingAvatarBindings { + /// The currently displayed alert's info value otherwise `nil`. + var alertInfo: AlertInfo? +} + +enum OnboardingAvatarViewAction { + /// The user would like to choose an image from their photo library. + case pickImage + /// The user would like to take a photo to use as their avatar. + case takePhoto + /// The user would like to save their chosen avatar image. + case save + /// Move on to the next screen in the flow without setting an avatar. + case skip +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift new file mode 100644 index 000000000..72376b34d --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.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 SwiftUI +import Combine + +@available(iOS 14, *) +typealias OnboardingAvatarViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingAvatarViewModel: OnboardingAvatarViewModelType, OnboardingAvatarViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((OnboardingAvatarViewModelResult) -> Void)? + + // MARK: - Setup + + init(userId: String, displayName: String?, avatarColorCount: Int) { + let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName, matrixItemId: userId, colorCount: avatarColorCount) + let initialViewState = OnboardingAvatarViewState(placeholderAvatarLetter: placeholderViewModel.firstCharacterCapitalized, + placeholderAvatarColorIndex: placeholderViewModel.stableColorIndex, + bindings: OnboardingAvatarBindings()) + super.init(initialViewState: initialViewState) + } + + // MARK: - Public + + override func process(viewAction: OnboardingAvatarViewAction) { + switch viewAction { + case .pickImage: + completion?(.pickImage) + case .takePhoto: + completion?(.takePhoto) + case .save: + completion?(.save(state.avatar)) + case .skip: + completion?(.skip) + } + } + + func updateAvatarImage(with image: UIImage?) { + state.avatar = image + } + + func processError(_ error: NSError?) { + state.bindings.alertInfo = AlertInfo(error: error) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift new file mode 100644 index 000000000..0e757a9dd --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.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 SwiftUI + +protocol OnboardingAvatarViewModelProtocol { + + var completion: ((OnboardingAvatarViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: OnboardingAvatarViewModelType.Context { get } + + /// Update the view model to show the image that the user has picked. + func updateAvatarImage(with image: UIImage?) + + /// Update the view model to show that an error has occurred. + /// - Parameter error: The error to be displayed or `nil` to display a generic alert. + func processError(_ error: NSError?) +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift new file mode 100644 index 000000000..f9cc94e70 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift @@ -0,0 +1,70 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingAvatarUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockOnboardingAvatarScreenState.self + } + + override class func createTest() -> MockScreenTest { + return OnboardingAvatarUITests(selector: #selector(verifyOnboardingAvatarScreen)) + } + + func verifyOnboardingAvatarScreen() throws { + guard let screenState = screenState as? MockOnboardingAvatarScreenState else { fatalError("no screen") } + switch screenState { + case .placeholderAvatar(let userId, let displayName): + verifyPlaceholderAvatar(userId: userId, displayName: displayName) + case .userSelectedAvatar: + verifyUserSelectedAvatar() + } + } + + func verifyPlaceholderAvatar(userId: String, displayName: String) { + guard let firstLetter = displayName.uppercased().first else { + XCTFail("Unable to get the first letter of the display name.") + return + } + + let placeholderAvatar = app.staticTexts["placeholderAvatar"] + XCTAssertTrue(placeholderAvatar.exists, "The placeholder avatar should be shown.") + XCTAssertEqual(placeholderAvatar.label, String(firstLetter), "The placeholder avatar should show the first letter of the display name.") + + let avatarImage = app.images["avatarImage"] + XCTAssertFalse(avatarImage.exists, "The avatar image should be hidden as no selection has been made.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") + } + + func verifyUserSelectedAvatar() { + let placeholderAvatar = app.otherElements["placeholderAvatar"] + XCTAssertFalse(placeholderAvatar.exists, "The placeholder avatar should be hidden.") + + let avatarImage = app.images["avatarImage"] + XCTAssertTrue(avatarImage.exists, "The selected avatar should be shown.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.") + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift new file mode 100644 index 000000000..a65df871e --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift @@ -0,0 +1,57 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingAvatarViewModelTests: XCTestCase { + private enum Constants { + static let userId = "@user:matrix.org" + static let displayName = "Alice" + static let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count + static let avatarImage = Asset.Images.appSymbol.image + } + + var viewModel: OnboardingAvatarViewModelProtocol! + var context: OnboardingAvatarViewModelType.Context! + + override func setUpWithError() throws { + viewModel = OnboardingAvatarViewModel(userId: Constants.userId, + displayName: Constants.displayName, + avatarColorCount: Constants.avatarColorCount) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.placeholderAvatarLetter, "A") + XCTAssertNil(context.viewState.avatar) + XCTAssertNil(context.viewState.bindings.alertInfo) + } + + func testUpdatingAvatar() { + // Given the default view model + XCTAssertNil(context.viewState.avatar, "The default view state should not have an avatar.") + + // When updating the image + viewModel.updateAvatarImage(with: Constants.avatarImage) + + // Then the view state should contain the new image + XCTAssertEqual(context.viewState.avatar, Constants.avatarImage, "The view state should contain the new avatar image.") + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift new file mode 100644 index 000000000..7e611900a --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -0,0 +1,157 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DesignKit + +@available(iOS 14.0, *) +struct OnboardingAvatarScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + @State private var isPresentingPickerSelection = false + + // MARK: Public + + @ObservedObject var viewModel: OnboardingAvatarViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + avatar + .padding(.horizontal, 2) + .padding(.bottom, 40) + + header + .padding(.bottom, 40) + + buttons + } + .padding(.horizontal) + .padding(.top, 8) + .frame(maxWidth: OnboardingConstants.maxContentWidth) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + } + + + /// The user's avatar along with a picker button + var avatar: some View { + Group { + if let avatarImage = viewModel.viewState.avatar { + Image(uiImage: avatarImage) + .resizable() + .scaledToFill() + .accessibilityIdentifier("avatarImage") + } else { + PlaceholderAvatarImage(firstCharacter: viewModel.viewState.placeholderAvatarLetter, + colorIndex: viewModel.viewState.placeholderAvatarColorIndex) + .accessibilityIdentifier("placeholderAvatar") + } + } + .clipShape(Circle()) + .frame(width: 120, height: 120) + .overlay(cameraButton, alignment: .bottomTrailing) + .onTapGesture { isPresentingPickerSelection = true } + .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } + .accessibilityElement(children: .ignore) + .accessibilityLabel(VectorL10n.onboardingAvatarAccessibilityLabel) + .accessibilityValue(VectorL10n.edit) + } + + /// The button to indicate the user can tap to select an avatar + /// Note: The whole avatar is tappable to make this easier. + var cameraButton: some View { + ZStack { + Circle() + .foregroundColor(theme.colors.background) + .shadow(color: .black.opacity(0.15), radius: 2.4, y: 2.4) + + Image(viewModel.viewState.buttonImage.name) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + .frame(width: 40, height: 40) + } + + /// The action sheet that asks how the user would like to set their avatar. + var pickerSelectionActionSheet: ActionSheet { + ActionSheet(title: Text(VectorL10n.onboardingAvatarTitle), buttons: [ + .default(Text(VectorL10n.imagePickerActionCamera)) { + viewModel.send(viewAction: .takePhoto) + }, + .default(Text(VectorL10n.imagePickerActionLibrary)) { + viewModel.send(viewAction: .pickImage) + }, + .cancel() + ]) + } + + /// The screen's title and message views. + var header: some View { + VStack(spacing: 8) { + Text(VectorL10n.onboardingAvatarTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.onboardingAvatarMessage) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The main action buttons in the form. + var buttons: some View { + VStack(spacing: 8) { + Button(VectorL10n.onboardingPersonalizationSave) { + viewModel.send(viewAction: .save) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.avatar == nil) + .accessibilityIdentifier("saveButton") + + Button { viewModel.send(viewAction: .skip) } label: { + Text(VectorL10n.onboardingPersonalizationSkip) + .font(theme.fonts.body) + .padding(12) + } + } + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct OnboardingAvatar_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingAvatarScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift index 6bed87cc2..47f6ee54b 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -17,7 +17,18 @@ import SwiftUI struct OnboardingCongratulationsCoordinatorParameters { - let userId: String + /// The user session used to determine the user ID to display. + let userSession: UserSession + /// When `true` the "Personalise Profile" button will be hidden, preventing the + /// user from setting a displayname or avatar. + let personalizationDisabled: Bool +} + +enum OnboardingCongratulationsCoordinatorResult { + /// Show the display name and/or avatar screens for the user to personalize their profile. + case personalizeProfile(UserSession) + /// Continue the flow by skipping the display name and avatar screens. + case takeMeHome(UserSession) } final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { @@ -34,7 +45,7 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: ((OnboardingCongratulationsViewModelResult) -> Void)? + var completion: ((OnboardingCongratulationsCoordinatorResult) -> Void)? // MARK: - Setup @@ -42,7 +53,9 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { init(parameters: OnboardingCongratulationsCoordinatorParameters) { self.parameters = parameters - let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userId) + // TODO: Add confetti when personalizationDisabled is false + let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId, + personalizationDisabled: parameters.personalizationDisabled) let view = OnboardingCongratulationsScreen(viewModel: viewModel.context) onboardingCongratulationsViewModel = viewModel onboardingCongratulationsHostingController = VectorHostingController(rootView: view) @@ -54,7 +67,13 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { onboardingCongratulationsViewModel.completion = { [weak self] result in guard let self = self else { return } MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).") - self.completion?(result) + + switch result { + case .personalizeProfile: + self.completion?(.personalizeProfile(self.parameters.userSession)) + case .takeMeHome: + self.completion?(.takeMeHome(self.parameters.userSession)) + } } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift index 4aa617f1c..f2b1773bc 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift @@ -24,7 +24,8 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { // A case for each state you want to represent // with specific, minimal associated data that will allow you // mock that screen. - case congratulations + case regular + case personalizationDisabled /// The associated screen var screenType: Any.Type { @@ -33,14 +34,18 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") + let viewModel: OnboardingCongratulationsViewModel - // can simulate service and viewModel actions here if needs be. + switch self { + case .regular: + viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") + case .personalizationDisabled: + viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com", personalizationDisabled: true) + } return ( [self, viewModel], - AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context) - .addDependency(MockAvatarService.example)) + AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context)) ) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift index d8eb9e926..1a83694dc 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift @@ -21,14 +21,15 @@ import Foundation // MARK: View model enum OnboardingCongratulationsViewModelResult { - case personaliseProfile + case personalizeProfile case takeMeHome } // MARK: View struct OnboardingCongratulationsViewState: BindableState { - var userId: String + let userId: String + let personalizationDisabled: Bool } enum OnboardingCongratulationsViewAction { diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift index 26a7ebfdd..4e7924015 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift @@ -33,8 +33,9 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType // MARK: - Setup - init(userId: String, initialCount: Int = 0) { - super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId)) + init(userId: String, personalizationDisabled: Bool = false) { + super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId, + personalizationDisabled: personalizationDisabled)) } // MARK: - Public @@ -42,7 +43,7 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType override func process(viewAction: OnboardingCongratulationsViewAction) { switch viewAction { case .personaliseProfile: - completion?(.personaliseProfile) + completion?(.personalizeProfile) case .takeMeHome: completion?(.takeMeHome) } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift index 5b46e5c97..f82b46245 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift @@ -31,10 +31,26 @@ class OnboardingCongratulationsUITests: MockScreenTest { func verifyOnboardingCongratulationsScreen() throws { guard let screenState = screenState as? MockOnboardingCongratulationsScreenState else { fatalError("no screen") } switch screenState { - case .congratulations: - // There isn't anything to test here - break + case .regular: + verifyButtons() + case .personalizationDisabled: + verifyButtonsWhenPersonalizationIsDisabled() } } - + + func verifyButtons() { + let personalizeButton = app.buttons["personalizeButton"] + XCTAssertTrue(personalizeButton.exists, "The personalization button should be shown.") + + let homeButton = app.buttons["homeButton"] + XCTAssertTrue(homeButton.exists, "The home button should always be shown.") + } + + func verifyButtonsWhenPersonalizationIsDisabled() { + let personalizeButton = app.buttons["personalizeButton"] + XCTAssertFalse(personalizeButton.exists, "The personalization button should be hidden.") + + let homeButton = app.buttons["homeButton"] + XCTAssertTrue(homeButton.exists, "The home button should always be shown.") + } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift index 892f904fc..244eba3a1 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift @@ -45,7 +45,7 @@ struct OnboardingCongratulationsScreen: View { Spacer() - buttons + footer .padding(.horizontal, horizontalPadding) .padding(.bottom, 24) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) @@ -62,8 +62,10 @@ struct OnboardingCongratulationsScreen: View { /// The main content of the view to be shown in a scroll view. var mainContent: some View { - VStack(spacing: 62) { + VStack(spacing: 42) { Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() + .frame(width: 90, height: 90) .accessibilityHidden(true) VStack(spacing: 8) { @@ -79,23 +81,45 @@ struct OnboardingCongratulationsScreen: View { } } - /// The action buttons shown at the bottom of the view. - var buttons: some View { + @ViewBuilder + var footer: some View { + if viewModel.viewState.personalizationDisabled { + homeButton + } else { + actionButtons + } + } + + /// The default action buttons shown at the bottom of the view. + var actionButtons: some View { VStack(spacing: 12) { Button { viewModel.send(viewAction: .personaliseProfile) } label: { - Text(VectorL10n.onboardingCongratulationsPersonaliseButton) - .font(theme.fonts.bodySB) + Text(VectorL10n.onboardingCongratulationsPersonalizeButton) + .font(theme.fonts.body) .foregroundColor(theme.colors.accent) } .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + .accessibilityIdentifier("personalizeButton") Button { viewModel.send(viewAction: .takeMeHome) } label: { Text(VectorL10n.onboardingCongratulationsHomeButton) .font(theme.fonts.body) .padding(.vertical, 12) } + .accessibilityIdentifier("homeButton") } } + + /// The single "Take me home" button shown when personlization isn't supported. + var homeButton: some View { + Button { viewModel.send(viewAction: .takeMeHome) } label: { + Text(VectorL10n.onboardingCongratulationsHomeButton) + .font(theme.fonts.body) + .foregroundColor(theme.colors.accent) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + .accessibilityIdentifier("homeButton") + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift new file mode 100644 index 000000000..3e96952ae --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -0,0 +1,107 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import CommonKit + +struct OnboardingDisplayNameCoordinatorParameters { + let userSession: UserSession +} + +@available(iOS 14.0, *) +final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: OnboardingDisplayNameCoordinatorParameters + private let onboardingDisplayNameHostingController: VectorHostingController + private var onboardingDisplayNameViewModel: OnboardingDisplayNameViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSession) -> Void)? + + // MARK: - Setup + + init(parameters: OnboardingDisplayNameCoordinatorParameters) { + self.parameters = parameters + + // Don't pre-fill the display name from the MXID to encourage the user to enter something + let viewModel = OnboardingDisplayNameViewModel() + + let view = OnboardingDisplayNameScreen(viewModel: viewModel.context) + onboardingDisplayNameViewModel = viewModel + onboardingDisplayNameHostingController = VectorHostingController(rootView: view) + onboardingDisplayNameHostingController.vc_removeBackTitle() + onboardingDisplayNameHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingDisplayNameHostingController) + } + + // MARK: - Public + func start() { + MXLog.debug("[OnboardingDisplayNameCoordinator] did start.") + onboardingDisplayNameViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[OnboardingDisplayNameCoordinator] OnboardingDisplayNameViewModel did complete.") + + switch result { + case .save(let displayName): + self.setDisplayName(displayName) + case .skip: + self.completion?(self.parameters.userSession) + } + } + } + + func toPresentable() -> UIViewController { + return self.onboardingDisplayNameHostingController + } + + // MARK: - Private + + /// Show a blocking activity indicator whilst saving. + private func startWaiting() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopWaiting() { + waitingIndicator = nil + } + + /// Set the supplied string as user's display name, completing the screen's display if successful. + private func setDisplayName(_ displayName: String) { + startWaiting() + + parameters.userSession.account.setUserDisplayName(displayName) { [weak self] in + guard let self = self else { return } + self.stopWaiting() + self.completion?(self.parameters.userSession) + } failure: { [weak self] error in + guard let self = self else { return } + self.stopWaiting() + self.onboardingDisplayNameViewModel.processError(error as NSError?) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift new file mode 100644 index 000000000..797908a9e --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift @@ -0,0 +1,63 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case emptyTextField + case filledTextField(displayName: String) + case longDisplayName(displayName: String) + + /// The associated screen + var screenType: Any.Type { + OnboardingDisplayNameScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockOnboardingDisplayNameScreenState] { + [ + MockOnboardingDisplayNameScreenState.emptyTextField, + MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User"), + MockOnboardingDisplayNameScreenState.longDisplayName(displayName: """ + Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner. + """) + ] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: OnboardingDisplayNameViewModel + switch self { + case .emptyTextField: + viewModel = OnboardingDisplayNameViewModel() + case .filledTextField(let displayName), .longDisplayName(displayName: let displayName): + viewModel = OnboardingDisplayNameViewModel(displayName: displayName) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift new file mode 100644 index 000000000..22857f69c --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift @@ -0,0 +1,55 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: View model + +enum OnboardingDisplayNameViewModelResult { + /// The user would like to save the entered display name. + case save(String) + /// Move on to the next screen in the flow without setting a display name. + case skip +} + +// MARK: View + +struct OnboardingDisplayNameViewState: BindableState { + var bindings: OnboardingDisplayNameBindings + /// Any error that occurred during display name validation otherwise `nil`. + var validationErrorMessage: String? + + /// The string to be displayed in the text field's footer. + var textFieldFooterMessage: String { + validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint + } +} + +struct OnboardingDisplayNameBindings { + /// The display name string entered by the user. + var displayName: String + /// The currently displayed alert's info value otherwise `nil`. + var alertInfo: AlertInfo? +} + +enum OnboardingDisplayNameViewAction { + /// The display name needs validation. + case validateDisplayName + /// The user would like to save the entered display name. + case save + /// Move on to the next screen in the flow without setting a display name. + case skip +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift new file mode 100644 index 000000000..e72b0b7be --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift @@ -0,0 +1,70 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14, *) +typealias OnboardingDisplayNameViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, OnboardingDisplayNameViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((OnboardingDisplayNameViewModelResult) -> Void)? + + // MARK: - Setup + + init(displayName: String = "") { + super.init(initialViewState: OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: displayName))) + validateDisplayName() + } + + // MARK: - Public + + override func process(viewAction: OnboardingDisplayNameViewAction) { + switch viewAction { + case .validateDisplayName: + validateDisplayName() + case .save: + completion?(.save(state.bindings.displayName)) + case .skip: + completion?(.skip) + } + } + + func processError(_ error: NSError?) { + state.bindings.alertInfo = AlertInfo(error: error) + } + + // MARK: - Private + + /// Checks for a display name that exceeds 256 characters and updates the footer error if needed. + private func validateDisplayName() { + if state.bindings.displayName.count > 256 { + guard state.validationErrorMessage == nil else { return } + state.validationErrorMessage = VectorL10n.onboardingDisplayNameMaxLength + } else if state.validationErrorMessage != nil { + state.validationErrorMessage = nil + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift new file mode 100644 index 000000000..d03c8416e --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift @@ -0,0 +1,28 @@ +// +// 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 OnboardingDisplayNameViewModelProtocol { + + var completion: ((OnboardingDisplayNameViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: OnboardingDisplayNameViewModelType.Context { get } + + /// Update the view model to show that an error has occurred. + /// - Parameter error: The error to be displayed or `nil` to display a generic alert. + func processError(_ error: NSError?) +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift new file mode 100644 index 000000000..30aa2ba50 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift @@ -0,0 +1,87 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingDisplayNameUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockOnboardingDisplayNameScreenState.self + } + + override class func createTest() -> MockScreenTest { + return OnboardingDisplayNameUITests(selector: #selector(verifyOnboardingDisplayNameScreen)) + } + + func verifyOnboardingDisplayNameScreen() throws { + guard let screenState = screenState as? MockOnboardingDisplayNameScreenState else { fatalError("no screen") } + switch screenState { + case .emptyTextField: + verifyEmptyTextField() + case .filledTextField(let displayName): + verifyDisplayName(displayName: displayName) + case .longDisplayName(displayName: let displayName): + verifyLongDisplayName(displayName: displayName) + } + } + + func verifyEmptyTextField() { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, VectorL10n.onboardingDisplayNamePlaceholder, "When the textfield is empty, the value should match the placeholder.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when no text is set.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") + } + + func verifyDisplayName(displayName: String) { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when an acceptable name is entered.") + } + + func verifyLongDisplayName(displayName: String) { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameMaxLength, "The footer should display an error when the display name is too long.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift new file mode 100644 index 000000000..7f49a2aa2 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift @@ -0,0 +1,64 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingDisplayNameViewModelTests: XCTestCase { + var viewModel: OnboardingDisplayNameViewModel! + var context: OnboardingDisplayNameViewModelType.Context! + + override func setUpWithError() throws { + viewModel = nil + context = nil + } + + func setUp(with displayName: String) { + viewModel = OnboardingDisplayNameViewModel(displayName: displayName) + context = viewModel.context + } + + func testValidDisplayName() { + // Given a short display name + let displayName = "Alice" + setUp(with: displayName) + + // When validating the display name + viewModel.process(viewAction: .validateDisplayName) + + // Then no error message should be set + XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.") + XCTAssertNil(context.viewState.validationErrorMessage, "There should not be an error message in the view state.") + } + + func testInvalidDisplayName() { + // Given a short display name + let displayName = """ + Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner. + """ + setUp(with: displayName) + + // When validating the display name + viewModel.process(viewAction: .validateDisplayName) + + // Then no error message should be set + XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.") + XCTAssertNotNil(context.viewState.validationErrorMessage, "There should be an error message in the view state.") + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift new file mode 100644 index 000000000..a678e1aa3 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.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 SwiftUI + +@available(iOS 14.0, *) +struct OnboardingDisplayNameScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @State private var isEditingTextField = false + + private var textFieldFooterColor: Color { + viewModel.viewState.validationErrorMessage == nil ? theme.colors.tertiaryContent : theme.colors.alert + } + + // MARK: Public + + @ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context + + // MARK: - Views + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.bottom, 32) + + textField + .padding(.horizontal, 2) + .padding(.bottom, 20) + + buttons + } + .padding(.horizontal) + .padding(.top, 8) + .frame(maxWidth: OnboardingConstants.maxContentWidth) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + .onChange(of: viewModel.displayName) { _ in + viewModel.send(viewAction: .validateDisplayName) + } + } + + /// The icon, title and message views. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) + .background(Circle().foregroundColor(.white).padding(2)) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.onboardingDisplayNameTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.onboardingDisplayNameMessage) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The text field used to enter the displayname along with a hint. + var textField: some View { + VStack(spacing: 4) { + TextField(VectorL10n.onboardingDisplayNamePlaceholder, text: $viewModel.displayName) { + isEditingTextField = $0 + } + .textFieldStyle(BorderedInputFieldStyle(theme: _theme, + isEditing: isEditingTextField, + isError: viewModel.viewState.validationErrorMessage != nil)) + + Text(viewModel.viewState.textFieldFooterMessage) + .font(theme.fonts.footnote) + .foregroundColor(textFieldFooterColor) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("textFieldFooter") + } + } + + /// The main action buttons in the form. + var buttons: some View { + VStack(spacing: 8) { + Button(VectorL10n.onboardingPersonalizationSave) { + viewModel.send(viewAction: .save) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.displayName.isEmpty || viewModel.viewState.validationErrorMessage != nil) + .accessibilityIdentifier("saveButton") + + Button { viewModel.send(viewAction: .skip) } label: { + Text(VectorL10n.onboardingPersonalizationSkip) + .font(theme.fonts.body) + .padding(12) + } + } + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct OnboardingDisplayName_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift index d031d5826..fb0293acf 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift @@ -20,13 +20,14 @@ protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable { var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set } } +@available(iOS 14.0, *) final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinatorProtocol { // MARK: - Properties // MARK: Private - private let onboardingSplashScreenHostingController: UIViewController + private let onboardingSplashScreenHostingController: VectorHostingController private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol // MARK: Public @@ -37,14 +38,12 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator // MARK: - Setup - @available(iOS 14.0, *) init() { let viewModel = OnboardingSplashScreenViewModel() let view = OnboardingSplashScreen(viewModel: viewModel.context) onboardingSplashScreenViewModel = viewModel - let hostingController = VectorHostingController(rootView: view) - hostingController.vc_removeBackTitle() - onboardingSplashScreenHostingController = hostingController + onboardingSplashScreenHostingController = VectorHostingController(rootView: view) + onboardingSplashScreenHostingController.vc_removeBackTitle() } // MARK: - Public diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift index e6473eeb4..e900c3845 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift @@ -16,13 +16,14 @@ import SwiftUI +@available(iOS 14.0, *) final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable { // MARK: - Properties // MARK: Private - private let onboardingUseCaseHostingController: UIViewController + private let onboardingUseCaseHostingController: VectorHostingController private var onboardingUseCaseViewModel: OnboardingUseCaseViewModelProtocol // MARK: Public @@ -33,16 +34,14 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable { // MARK: - Setup - @available(iOS 14.0, *) init() { let viewModel = OnboardingUseCaseViewModel() let view = OnboardingUseCaseSelectionScreen(viewModel: viewModel.context) onboardingUseCaseViewModel = viewModel - let hostingController = VectorHostingController(rootView: view) - hostingController.vc_removeBackTitle() - hostingController.enableNavigationBarScrollEdgeAppearance = true - onboardingUseCaseHostingController = hostingController + onboardingUseCaseHostingController = VectorHostingController(rootView: view) + onboardingUseCaseHostingController.vc_removeBackTitle() + onboardingUseCaseHostingController.enableNavigationBarScrollEdgeAppearance = true } // MARK: - Public diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingOptionButton.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingOptionButton.swift index 9247da882..2c64a3258 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingOptionButton.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingOptionButton.swift @@ -54,7 +54,7 @@ struct LocationSharingOptionButton_Previews: PreviewProvider { LocationSharingOptionButton(text: "Share live location") { } content: { - LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.locationLiveIcon.image) + LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.liveLocationIcon.image) } } } diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift index f3f7e417e..43f77cc92 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/View/LocationSharingView.swift @@ -99,7 +99,7 @@ struct LocationSharingView: View { LocationSharingOptionButton(text: VectorL10n.locationSharingLiveShareTitle) { // TODO: - Start live location sharing } content: { - LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.locationLiveIcon.image) + LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.liveLocationIcon.image) } .disabled(!context.viewState.shareButtonEnabled) } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 51a3aa7f7..da4e3fbad 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -46,8 +46,8 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo super.init(initialViewState: UserSuggestionViewState(items: items)) - userSuggestionService.items.sink { items in - self.state.items = items.map({ item in + userSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map({ item in UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) }) }.store(in: &cancellables) diff --git a/SiriIntents/IntentHandler.m b/SiriIntents/IntentHandler.m index 8220d9fed..94d120a4f 100644 --- a/SiriIntents/IntentHandler.m +++ b/SiriIntents/IntentHandler.m @@ -27,6 +27,12 @@ // Build Settings @property (nonatomic) id configuration; +/** + The room that is currently being used to send a message. This is to ensure a + strong ref is maintained on the `MXRoom` until sending has completed. + */ +@property (nonatomic) MXRoom *selectedRoom; + @end @implementation IntentHandler @@ -242,17 +248,22 @@ [session setStore:fileStore success:^{ MXStrongifyAndReturnIfNil(session); - MXRoom *room = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; + self.selectedRoom = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; // Do not warn for unknown devices. We have cross-signing now session.crypto.warnOnUnknowDevices = NO; - [room sendTextMessage:intent.content - threadId:nil - success:^(NSString *eventId) { + MXWeakify(self); + [self.selectedRoom sendTextMessage:intent.content + threadId:nil + success:^(NSString *eventId) { completeWithCode(INSendMessageIntentResponseCodeSuccess); + MXStrongifyAndReturnIfNil(self); + self.selectedRoom = nil; } failure:^(NSError *error) { completeWithCode(INSendMessageIntentResponseCodeFailure); + MXStrongifyAndReturnIfNil(self); + self.selectedRoom = nil; }]; } failure:^(NSError *error) { diff --git a/changelog.d/5058.bugfix b/changelog.d/5058.bugfix new file mode 100644 index 000000000..c1bba1101 --- /dev/null +++ b/changelog.d/5058.bugfix @@ -0,0 +1 @@ +UserSuggestionViewModel: Fix retain cycle diff --git a/changelog.d/5500.change b/changelog.d/5500.change new file mode 100644 index 000000000..bcfe8ba8a --- /dev/null +++ b/changelog.d/5500.change @@ -0,0 +1 @@ +Change behaviour of avatar/self in left menu to match common paradigm and take user to their own profile/settings \ No newline at end of file diff --git a/changelog.d/5547.bugfix b/changelog.d/5547.bugfix new file mode 100644 index 000000000..570df2392 --- /dev/null +++ b/changelog.d/5547.bugfix @@ -0,0 +1 @@ +Home: Fix crash when pressing tabs diff --git a/changelog.d/5652.wip b/changelog.d/5652.wip new file mode 100644 index 000000000..0d173fbd5 --- /dev/null +++ b/changelog.d/5652.wip @@ -0,0 +1 @@ +Onboarding: Add screens for setting a display name and avatar when signing up for the first time. \ No newline at end of file diff --git a/changelog.d/5805.bugfix b/changelog.d/5805.bugfix new file mode 100644 index 000000000..831afcca9 --- /dev/null +++ b/changelog.d/5805.bugfix @@ -0,0 +1 @@ +Share Extension: Stop logging crashes due to intentional exception that frees up memory and handle changes to MXRoom in the SDK. diff --git a/changelog.d/5846.bugfix b/changelog.d/5846.bugfix new file mode 100644 index 000000000..d37861d55 --- /dev/null +++ b/changelog.d/5846.bugfix @@ -0,0 +1 @@ +Authentication: Fix a crash that occurred when using the app with an account that had a soft logout. \ No newline at end of file diff --git a/changelog.d/5853.change b/changelog.d/5853.change new file mode 100644 index 000000000..c35b4255a --- /dev/null +++ b/changelog.d/5853.change @@ -0,0 +1 @@ +RoomViewController: Remove thread list bar button item badge count. diff --git a/changelog.d/5857.wip b/changelog.d/5857.wip new file mode 100644 index 000000000..80369f90c --- /dev/null +++ b/changelog.d/5857.wip @@ -0,0 +1 @@ +Location sharing: Handle live location banner view in room screen. \ No newline at end of file diff --git a/changelog.d/5873.bugfix b/changelog.d/5873.bugfix new file mode 100644 index 000000000..cf3c09280 --- /dev/null +++ b/changelog.d/5873.bugfix @@ -0,0 +1 @@ +MXAccount: Do not clear cache if there are no stored filters diff --git a/changelog.d/pr-5876.bugfix b/changelog.d/pr-5876.bugfix new file mode 100644 index 000000000..aca03fdfa --- /dev/null +++ b/changelog.d/pr-5876.bugfix @@ -0,0 +1 @@ +Fix user suggestions not showing up when re-entering a room. \ No newline at end of file