5720: Update from develop

This commit is contained in:
MaximeE
2022-03-22 09:41:25 +01:00
104 changed files with 2599 additions and 272 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "onboarding_avatar_camera.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,4 @@
<svg width="23" height="19" viewBox="0 0 23 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26126 0.625C7.93955 0.625 6.8205 1.56156 6.56548 2.83072C6.52727 3.02086 6.45364 3.20457 6.32574 3.35036L5.77376 3.97953C5.59971 4.17792 5.3486 4.29167 5.08469 4.29167H2.16659C1.15406 4.29167 0.333252 5.11248 0.333252 6.125V16.2083C0.333252 17.2209 1.15406 18.0417 2.16658 18.0417H20.4999C21.5124 18.0417 22.3333 17.2209 22.3333 16.2083V6.125C22.3333 5.11248 21.5124 4.29167 20.4999 4.29167H17.5818C17.3179 4.29167 17.0668 4.17792 16.8927 3.97953L16.3408 3.35036C16.2129 3.20457 16.1392 3.02086 16.101 2.83071C15.846 1.56156 14.727 0.625 13.4052 0.625H9.26126ZM14.9999 10.7083C14.9999 12.7333 13.3583 14.3749 11.3333 14.3749C9.30821 14.3749 7.66659 12.7333 7.66659 10.7083C7.66659 8.68321 9.30821 7.04159 11.3333 7.04159C13.3583 7.04159 14.9999 8.68321 14.9999 10.7083Z" fill="#737D8C"/>
<path d="M2.62492 2.91659C2.37179 2.91659 2.16659 3.12179 2.16659 3.37492C2.16659 3.62805 2.37179 3.83325 2.62492 3.83325H4.45825C4.71138 3.83325 4.91659 3.62805 4.91659 3.37492C4.91659 3.12179 4.71138 2.91659 4.45825 2.91659H2.62492Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "onboarding_avatar_edit.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -0,0 +1,4 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.63013 13.0435C3.64248 12.9655 3.67763 12.8929 3.73116 12.8349L15.1405 0.462333C15.2903 0.299929 15.5434 0.289681 15.7058 0.439442L18.0582 2.60876C18.2206 2.75852 18.2309 3.01158 18.0811 3.17399L6.67174 15.5465C6.6182 15.6046 6.54868 15.6455 6.47192 15.6641L3.66863 16.3437C3.39123 16.411 3.13469 16.1744 3.1793 15.8925L3.63013 13.0435Z" fill="#737D8C"/>
<path d="M1.83301 17.2204C1.00458 17.2204 0.333008 17.892 0.333008 18.7204C0.333008 19.5488 1.00458 20.2204 1.83301 20.2204L19.833 20.2204C20.6614 20.2204 21.333 19.5488 21.333 18.7204C21.333 17.892 20.6614 17.2204 19.833 17.2204L1.83301 17.2204Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "dark-theme-no-mentions.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28"><circle cx="23" cy="5" r="5" fill="#A9B2BC"/><path fill="#0DBD8B" fill-rule="evenodd" d="M16.07 6A7.004 7.004 0 0 0 22 11.93V21a3 3 0 0 1-3 3H7.667a1 1 0 0 0-.6.2L3.6 26.8A1 1 0 0 1 2 26V9a3 3 0 0 1 3-3h11.07ZM6 12a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H6Zm-1 5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "light-theme-no-mentions.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28"><circle cx="23" cy="5" r="5" fill="#737D8C"/><path fill="#0DBD8B" fill-rule="evenodd" d="M16.07 6A7.004 7.004 0 0 0 22 11.93V21a3 3 0 0 1-3 3H7.667a1 1 0 0 0-.6.2L3.6 26.8A1 1 0 0 1 2 26V9a3 3 0 0 1 3-3h11.07ZM6 12a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H6Zm-1 5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "light-and-dark-theme-mentions.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28"><circle cx="23" cy="5" r="5" fill="#FF5B55"/><path fill="#0DBD8B" fill-rule="evenodd" d="M16.07 6A7.004 7.004 0 0 0 22 11.93V21a3 3 0 0 1-3 3H7.667a1 1 0 0 0-.6.2L3.6 26.8A1 1 0 0 1 2 26V9a3 3 0 0 1 3-3h11.07ZM6 12a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H6Zm-1 5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@@ -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";

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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];

View File

@@ -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)
}
}

View File

@@ -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];
}

View File

@@ -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];

View File

@@ -2073,6 +2073,11 @@ static NSArray<NSNumber*> *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

View File

@@ -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) {

View File

@@ -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)
}
}
}
}

View File

@@ -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.")

View File

@@ -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];
}

View File

@@ -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?()
}
}

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="LiveLocationSharingBannerView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="104"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="live_location_icon" translatesAutoresizingMaskIntoConstraints="NO" id="hDt-s2-HU0">
<rect key="frame" x="15" y="62" width="24" height="24"/>
<constraints>
<constraint firstAttribute="width" secondItem="hDt-s2-HU0" secondAttribute="height" multiplier="1:1" id="LUH-tx-Vu9"/>
<constraint firstAttribute="width" constant="24" id="jBe-4U-Yb7"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Live location enabled" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7UG-mn-oZ3">
<rect key="frame" x="49" y="49" width="128.5" height="50"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="H2w-fv-3ba">
<rect key="frame" x="355" y="60.5" width="44" height="27"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="44" id="WZJ-7H-OFh"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
<state key="normal" title="Stop"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="boolean" keyPath="vc_adjustsFontForContentSizeCategory" value="YES"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="stopButtonAction:" destination="iN0-l3-epB" eventType="touchUpInside" id="rVA-YA-4J6"/>
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" red="0.050980392156862744" green="0.74117647058823533" blue="0.54509803921568623" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="H2w-fv-3ba" secondAttribute="trailing" constant="15" id="29I-X9-nEy"/>
<constraint firstAttribute="bottomMargin" secondItem="7UG-mn-oZ3" secondAttribute="bottom" constant="5" id="5TD-eA-uqi"/>
<constraint firstItem="7UG-mn-oZ3" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="topMargin" constant="5" id="919-NN-C1l"/>
<constraint firstItem="hDt-s2-HU0" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="15" id="FcV-b9-J6v"/>
<constraint firstItem="7UG-mn-oZ3" firstAttribute="leading" secondItem="hDt-s2-HU0" secondAttribute="trailing" constant="10" id="Lsz-9Z-xLO"/>
<constraint firstItem="H2w-fv-3ba" firstAttribute="centerY" secondItem="vUN-kp-3ea" secondAttribute="centerY" id="RTT-lt-BtI"/>
<constraint firstItem="hDt-s2-HU0" firstAttribute="centerY" secondItem="vUN-kp-3ea" secondAttribute="centerY" id="XgN-M3-jLz"/>
<constraint firstItem="H2w-fv-3ba" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="7UG-mn-oZ3" secondAttribute="trailing" constant="10" id="zWz-Me-wx1"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="iconImageView" destination="hDt-s2-HU0" id="1HC-Kc-Rxp"/>
<outlet property="stopButton" destination="H2w-fv-3ba" id="g7S-JF-xw5"/>
<outlet property="titleLabel" destination="7UG-mn-oZ3" id="bxZ-zI-Q7J"/>
</connections>
<point key="canvasLocation" x="137.68115942028987" y="-122.54464285714285"/>
</view>
</objects>
<resources>
<image name="live_location_icon" width="24" height="24"/>
</resources>
</document>

View File

@@ -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])

View File

@@ -459,4 +459,12 @@ extension RoomCoordinator: RoomViewControllerDelegate {
func roomViewControllerDidStopLoading(_ roomViewController: RoomViewController) {
stopLoading()
}
func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) {
// TODO:
}
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) {
// TODO:
}
}

View File

@@ -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

View File

@@ -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 () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
@@ -178,9 +182,6 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
// Time to display notification content in the timeline
MXTaskProfile *notificationTaskProfile;
// Reference to thread list bar button item, to update it easily later
BadgedBarButtonItem *threadListBarButtonItem;
}
@property (nonatomic, weak) IBOutlet UIView *overlayContainerView;
@@ -223,6 +224,13 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
// scroll state just before the layout change, and restore it after the layout.
@property (nonatomic) BOOL shouldScrollToBottomAfterLayout;
/// Handles all banners that should be displayed at the top of the timeline but that should not scroll with the timeline
@property (weak, nonatomic, nullable) IBOutlet UIStackView *topBannersStackView;
@property (nonatomic) BOOL shouldShowLiveLocationSharingBannerView;
@property (nonatomic, weak) LiveLocationSharingBannerView *liveLocationSharingBannerView;
@end
@implementation RoomViewController
@@ -230,6 +238,13 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
#pragma mark - Class methods
+ (void)initialize
{
kThreadListBarButtonItemContentInsetsNoDot = UIEdgeInsetsMake(0, 8, 0, 8);
kThreadListBarButtonItemContentInsetsDot = UIEdgeInsetsMake(0, 8, 6, 8);
kThreadListBarButtonItemImageSize = CGSizeMake(21, 21);
}
+ (UINib *)nib
{
return [UINib nibWithNibName:NSStringFromClass(self.class)
@@ -385,6 +400,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[self registerURLPreviewNotifications];
[self setupActions];
[self setupUserSuggestionViewIfNeeded];
}
- (void)userInterfaceThemeDidChange
@@ -461,7 +478,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
self.scrollToBottomBadgeLabel.badgeColor = ThemeService.shared.theme.tintColor;
[self updateThreadListBarButtonBadgeWith:self.mainSession.threadingService];
[threadListBarButtonItem updateWithTheme:ThemeService.shared.theme];
[self.liveLocationSharingBannerView updateWithTheme:ThemeService.shared.theme];
[self setNeedsStatusBarAppearanceUpdate];
}
@@ -1029,7 +1047,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
room:dataSource.room];
_userSuggestionCoordinator.delegate = self;
[self setupUserSuggestionView];
[self setupUserSuggestionViewIfNeeded];
}
- (void)onRoomDataSourceReady
@@ -1520,20 +1538,21 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
return item;
}
- (BadgedBarButtonItem *)threadListBarButtonItem
- (UIBarButtonItem *)threadListBarButtonItem
{
UIButton *button = [UIButton new];
UIImage *icon = [AssetImages.threadsIcon.image vc_resizedWith:CGSizeMake(21, 21)];
button.contentEdgeInsets = UIEdgeInsetsMake(4, 8, 4, 8);
[button setImage:icon
button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsNoDot;
button.imageView.contentMode = UIViewContentModeScaleAspectFit;
[button setImage:[AssetImages.threadsIcon.image vc_resizedWith:kThreadListBarButtonItemImageSize]
forState:UIControlStateNormal];
[button addTarget:self
action:@selector(onThreadListTapped:)
forControlEvents:UIControlEventTouchUpInside];
button.accessibilityLabel = [VectorL10n roomAccessibilityThreads];
return [[BadgedBarButtonItem alloc] initWithBaseButton:button
theme:ThemeService.shared.theme];
UIBarButtonItem *result = [[UIBarButtonItem alloc] initWithCustomView:button];
result.tag = kThreadListBarButtonItemTag;
return result;
}
- (void)setupRemoveJitsiWidgetRemoveView
@@ -1783,10 +1802,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
else
{
// in a regular timeline
BadgedBarButtonItem *itemThreadList = [self threadListBarButtonItem];
UIBarButtonItem *itemThreadList = [self threadListBarButtonItem];
[self updateThreadListBarButtonItem:itemThreadList
with:self.mainSession.threadingService];
[rightBarButtonItems insertObject:itemThreadList atIndex:0];
threadListBarButtonItem = itemThreadList;
[self updateThreadListBarButtonBadgeWith:self.mainSession.threadingService];
}
}
}
@@ -2353,10 +2372,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}
}
- (void)setupUserSuggestionView
- (void)setupUserSuggestionViewIfNeeded
{
if(!self.isViewLoaded) {
MXLogError(@"Failed setting up user suggestions. View not loaded.");
return;
}
@@ -2380,6 +2398,18 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[suggestionsViewController didMoveToParentViewController:self];
}
- (void)updateTopBanners
{
[self.view bringSubviewToFront:self.topBannersStackView];
[self.topBannersStackView vc_removeAllSubviews];
if (self.shouldShowLiveLocationSharingBannerView)
{
[self showLiveLocationBannerView];
}
}
#pragma mark - Jitsi
- (void)showJitsiCallWithWidget:(Widget*)widget
@@ -6831,41 +6861,82 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
- (void)updateThreadListBarButtonBadgeWith:(MXThreadingService *)service
{
if (!threadListBarButtonItem || !service)
[self updateThreadListBarButtonItem:nil with:service];
}
- (void)updateThreadListBarButtonItem:(UIBarButtonItem *)barButtonItem with:(MXThreadingService *)service
{
if (!service)
{
// there is no thread list bar button, ignore
return;
}
__block NSInteger replaceIndex = NSNotFound;
[self.navigationItem.rightBarButtonItems enumerateObjectsUsingBlock:^(UIBarButtonItem * _Nonnull item, NSUInteger index, BOOL * _Nonnull stop)
{
if (item.tag == kThreadListBarButtonItemTag)
{
replaceIndex = index;
*stop = YES;
}
}];
if (!barButtonItem && replaceIndex == NSNotFound)
{
// there is no thread list bar button item, and not provided another to update
// ignore
return;
}
UIBarButtonItem *threadListBarButtonItem = barButtonItem ?: [self threadListBarButtonItem];
UIButton *button = (UIButton *)threadListBarButtonItem.customView;
MXThreadNotificationsCount *notificationsCount = [service notificationsCountForRoom:self.roomDataSource.roomId];
if (notificationsCount.numberOfHighlightedThreads > 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<UIBarButtonItem*> *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

View File

@@ -13,7 +13,6 @@
<connections>
<outlet property="bubblesTableView" destination="BGD-sd-SQR" id="OG4-Tw-Ovt"/>
<outlet property="bubblesTableViewBottomConstraint" destination="1SD-y2-oTg" id="n8D-hT-eqt"/>
<outlet property="bubblesTableViewTopConstraint" destination="bFw-dg-qEr" id="KxM-H0-h2y"/>
<outlet property="inputBackgroundView" destination="Xt7-83-dQh" id="xoG-eb-zFB"/>
<outlet property="jumpToLastUnreadBanner" destination="S6r-bo-jxw" id="FSS-Be-E15"/>
<outlet property="jumpToLastUnreadBannerContainer" destination="S6H-Az-RCM" id="YlI-fu-OpT"/>
@@ -32,6 +31,7 @@
<outlet property="roomInputToolbarContainerHeightConstraint" destination="5eD-Fm-RDb" id="6ny-5w-1UA"/>
<outlet property="scrollToBottomBadgeLabel" destination="QHs-rM-UU8" id="wk7-PQ-9Jm"/>
<outlet property="scrollToBottomButton" destination="Ih9-EU-BOU" id="Wwg-gS-Sfp"/>
<outlet property="topBannersStackView" destination="3z2-8P-wlg" id="uf5-gw-zWi"/>
<outlet property="userSuggestionContainerView" destination="oni-F4-X1U" id="0js-Ji-8Mm"/>
<outlet property="view" destination="iN0-l3-epB" id="ieV-u7-rXU"/>
</connections>
@@ -41,8 +41,25 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" style="plain" separatorStyle="none" rowHeight="44" sectionHeaderHeight="22" sectionFooterHeight="22" translatesAutoresizingMaskIntoConstraints="NO" id="BGD-sd-SQR">
<rect key="frame" x="0.0" y="0.0" width="375" height="626"/>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="3z2-8P-wlg">
<rect key="frame" x="0.0" y="0.0" width="375" height="20.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hIS-uE-jlE">
<rect key="frame" x="0.0" y="0.0" width="375" height="20.5"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<variation key="default">
<mask key="subviews">
<exclude reference="hIS-uE-jlE"/>
</mask>
</variation>
</stackView>
<tableView contentMode="scaleToFill" ambiguous="YES" alwaysBounceVertical="YES" keyboardDismissMode="interactive" style="plain" separatorStyle="none" rowHeight="44" sectionHeaderHeight="22" sectionFooterHeight="22" translatesAutoresizingMaskIntoConstraints="NO" id="BGD-sd-SQR">
<rect key="frame" x="0.0" y="20.5" width="375" height="605.5"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityIdentifier" value="RoomVCBubblesTableView"/>
@@ -192,6 +209,7 @@
<constraint firstItem="54r-18-K1g" firstAttribute="top" secondItem="QpJ-1u-4ii" secondAttribute="top" id="7Ft-EV-Br0"/>
<constraint firstItem="gt1-EO-UVY" firstAttribute="leading" secondItem="QpJ-1u-4ii" secondAttribute="leading" id="8Ff-Ot-h3F"/>
<constraint firstItem="Xt7-83-dQh" firstAttribute="bottom" secondItem="iN0-l3-epB" secondAttribute="bottom" id="9g2-wm-4M9"/>
<constraint firstItem="3z2-8P-wlg" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="AN6-Eq-Po9"/>
<constraint firstItem="fmF-ad-erE" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="DtG-PR-F24"/>
<constraint firstItem="BGD-sd-SQR" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="ECb-mP-EOG"/>
<constraint firstAttribute="trailing" secondItem="BGD-sd-SQR" secondAttribute="trailing" id="EGD-cX-OGq"/>
@@ -200,17 +218,19 @@
<constraint firstItem="QHs-rM-UU8" firstAttribute="centerX" secondItem="Ih9-EU-BOU" secondAttribute="centerX" id="K8X-wJ-hkh"/>
<constraint firstItem="QHs-rM-UU8" firstAttribute="centerY" secondItem="Ih9-EU-BOU" secondAttribute="top" id="KL4-OU-cP6"/>
<constraint firstItem="gt1-EO-UVY" firstAttribute="trailing" secondItem="QpJ-1u-4ii" secondAttribute="trailing" id="L9A-P5-xeT"/>
<constraint firstItem="3z2-8P-wlg" firstAttribute="top" secondItem="QpJ-1u-4ii" secondAttribute="top" id="Nyp-Yf-EfD"/>
<constraint firstItem="XX4-n6-hCm" firstAttribute="leading" secondItem="QpJ-1u-4ii" secondAttribute="leading" id="Os4-cU-eQb"/>
<constraint firstItem="XX4-n6-hCm" firstAttribute="bottom" secondItem="nLd-BP-JAE" secondAttribute="top" id="QO8-nF-xys"/>
<constraint firstAttribute="trailing" secondItem="3z2-8P-wlg" secondAttribute="trailing" id="QPT-D1-rPy"/>
<constraint firstItem="nLd-BP-JAE" firstAttribute="leading" secondItem="QpJ-1u-4ii" secondAttribute="leading" id="T1Y-r9-bYV"/>
<constraint firstItem="XX4-n6-hCm" firstAttribute="trailing" secondItem="QpJ-1u-4ii" secondAttribute="trailing" id="Tij-mR-KZp"/>
<constraint firstItem="gt1-EO-UVY" firstAttribute="top" secondItem="QpJ-1u-4ii" secondAttribute="top" id="YfN-0Z-0bc"/>
<constraint firstItem="54r-18-K1g" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="aR5-rp-1Cp"/>
<constraint firstItem="gt1-EO-UVY" firstAttribute="bottom" secondItem="nLd-BP-JAE" secondAttribute="bottom" id="acJ-g8-R7x"/>
<constraint firstItem="nLd-BP-JAE" firstAttribute="top" secondItem="oni-F4-X1U" secondAttribute="bottom" id="ave-fu-X1D"/>
<constraint firstItem="BGD-sd-SQR" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="bFw-dg-qEr"/>
<constraint firstItem="oni-F4-X1U" firstAttribute="top" relation="greaterThanOrEqual" secondItem="QpJ-1u-4ii" secondAttribute="top" id="hzP-Ee-xzI"/>
<constraint firstItem="oni-F4-X1U" firstAttribute="leading" secondItem="QpJ-1u-4ii" secondAttribute="leading" id="k83-dd-hdL"/>
<constraint firstItem="BGD-sd-SQR" firstAttribute="top" secondItem="3z2-8P-wlg" secondAttribute="bottom" id="lqZ-Cq-40f"/>
<constraint firstAttribute="bottom" secondItem="nLd-BP-JAE" secondAttribute="bottom" id="omU-sm-3bK"/>
<constraint firstItem="nLd-BP-JAE" firstAttribute="trailing" secondItem="QpJ-1u-4ii" secondAttribute="trailing" id="pRw-S0-6WL"/>
<constraint firstItem="fmF-ad-erE" firstAttribute="top" secondItem="QpJ-1u-4ii" secondAttribute="top" id="qYq-nb-cu5"/>

View File

@@ -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];
}

View File

@@ -22,4 +22,5 @@ import Foundation
enum SideMenuViewAction {
case loadData
case tap(menuItem: SideMenuItem, sourceView: UIView)
case tapHeader(sourceView: UIView)
}

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="V8j-Lb-PgC">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="V8j-Lb-PgC">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -28,29 +28,42 @@
</constraints>
</view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="pj0-XK-IJ2">
<rect key="frame" x="87" y="23" width="307" height="46"/>
<rect key="frame" x="87" y="26.5" width="307" height="39"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bbo-IX-VUb">
<rect key="frame" x="0.0" y="0.0" width="307" height="24"/>
<rect key="frame" x="0.0" y="0.0" width="307" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle3"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VWw-Gn-nd0">
<rect key="frame" x="0.0" y="28" width="307" height="18"/>
<rect key="frame" x="0.0" y="24.5" width="307" height="14.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="WR7-DB-ZZt">
<rect key="frame" x="20" y="20" width="374" height="52"/>
<color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain"/>
<connections>
<action selector="headerTapActionWithSender:" destination="V8j-Lb-PgC" eventType="touchUpInside" id="1Zl-nV-Owp"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="WR7-DB-ZZt" firstAttribute="leading" secondItem="NuF-pw-IzO" secondAttribute="leading" id="5h9-2q-GNI"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="pj0-XK-IJ2" secondAttribute="bottom" constant="20" id="GyA-NG-zuK"/>
<constraint firstItem="WR7-DB-ZZt" firstAttribute="top" secondItem="NuF-pw-IzO" secondAttribute="top" id="MXY-hN-UAR"/>
<constraint firstItem="pj0-XK-IJ2" firstAttribute="centerY" secondItem="NuF-pw-IzO" secondAttribute="centerY" id="Ryy-Un-b4P"/>
<constraint firstAttribute="trailing" secondItem="pj0-XK-IJ2" secondAttribute="trailing" constant="20" id="Y1J-eh-n41"/>
<constraint firstItem="WR7-DB-ZZt" firstAttribute="trailing" secondItem="pj0-XK-IJ2" secondAttribute="trailing" id="bgn-k8-OzN"/>
<constraint firstItem="pj0-XK-IJ2" firstAttribute="leading" secondItem="NuF-pw-IzO" secondAttribute="trailing" constant="15" id="dY6-O4-aq7"/>
<constraint firstItem="WR7-DB-ZZt" firstAttribute="bottom" secondItem="NuF-pw-IzO" secondAttribute="bottom" id="kVm-8h-9TN"/>
<constraint firstItem="NuF-pw-IzO" firstAttribute="leading" secondItem="uTs-MO-piF" secondAttribute="leading" constant="20" id="rSh-ot-aqo"/>
<constraint firstItem="NuF-pw-IzO" firstAttribute="top" secondItem="uTs-MO-piF" secondAttribute="top" constant="20" id="woS-eb-vCr"/>
</constraints>
@@ -114,7 +127,7 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="zK0-v6-7Wt" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-3198" y="-647"/>
<point key="canvasLocation" x="-3198.5507246376815" y="-647.54464285714278"/>
</scene>
</scenes>
</document>

View File

@@ -198,6 +198,10 @@ final class SideMenuViewController: UIViewController {
// MARK: - Actions
@IBAction func headerTapAction(sender: UIView) {
self.viewModel.process(viewAction: .tapHeader(sourceView: sender))
}
}
// MARK: - SideMenuViewModelViewDelegate

View File

@@ -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)
}
}

View File

@@ -474,6 +474,13 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate {
}
func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) {
// TODO:
}
func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) {
// TODO:
}
}
// MARK: - ContactsPickerCoordinatorDelegate

View File

@@ -31,7 +31,7 @@
@property (nonatomic, strong, readonly) NSSet<NSString *> *selectedRoomIdentifiers;
- (instancetype)initWithFileStore:(MXFileStore *)fileStore
credentials:(MXCredentials *)credentials;
session:(MXSession *)session;
- (void)selectRoomWithIdentifier:(NSString *)roomIdentifier animated:(BOOL)animated;

View File

@@ -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 <MXKRecentCellData *> *recentCellDatas;
@property NSMutableArray <MXKRecentCellData *> *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<MXCredentials *> *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<MXRoomSummaryProtocol> 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];

View File

@@ -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<MXRoom *> *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<ShareItemSenderProtocol>)itemSender
type:(ShareManagerType)type
{
@@ -94,17 +104,19 @@
session.crypto.warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now
NSMutableArray<MXRoom *> *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<NSError *> *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<MXCredentials *> *credentials, void (^completion)(BOOL didUpdateCredentials))) {
[[MXKAccountManager sharedManager] readAndWriteCredentials:handler];
} andUnauthenticatedHandler:nil];
fakeSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient];
}
- (void)didStartSending
{
[self.shareViewController showProgressIndicator];

View File

@@ -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<NSData *> *pendingImages;
@@ -641,7 +641,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode)
{
if (!RiotSettings.shared.showMediaCompressionPrompt)
{
[MXSDKOptions sharedInstance].videoConversionPresetName = AVCaptureSessionPreset1920x1080;
[MXSDKOptions sharedInstance].videoConversionPresetName = AVAssetExportPreset1920x1080;
sendVideo();
}
else

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -19,6 +19,6 @@ import UIKit
enum AvatarViewState {
case empty
case placeholder(String, Int)
case placeholder(Character, Int)
case avatar(UIImage)
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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<Int>?
}
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
}

View File

@@ -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<OnboardingAvatarViewState,
Never,
OnboardingAvatarViewAction>
@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)
}
}

View File

@@ -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?)
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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))
)
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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.")
}
}

View File

@@ -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

View File

@@ -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?)
}
}
}

View File

@@ -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))
)
}
}

View File

@@ -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<Int>?
}
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
}

View File

@@ -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<OnboardingDisplayNameViewState,
Never,
OnboardingDisplayNameViewAction>
@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
}
}
}

View File

@@ -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?)
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -27,6 +27,12 @@
// Build Settings
@property (nonatomic) id<Configurable> 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) {

1
changelog.d/5058.bugfix Normal file
View File

@@ -0,0 +1 @@
UserSuggestionViewModel: Fix retain cycle

1
changelog.d/5500.change Normal file
View File

@@ -0,0 +1 @@
Change behaviour of avatar/self in left menu to match common paradigm and take user to their own profile/settings

1
changelog.d/5547.bugfix Normal file
View File

@@ -0,0 +1 @@
Home: Fix crash when pressing tabs

1
changelog.d/5652.wip Normal file
View File

@@ -0,0 +1 @@
Onboarding: Add screens for setting a display name and avatar when signing up for the first time.

1
changelog.d/5805.bugfix Normal file
View File

@@ -0,0 +1 @@
Share Extension: Stop logging crashes due to intentional exception that frees up memory and handle changes to MXRoom in the SDK.

1
changelog.d/5846.bugfix Normal file
View File

@@ -0,0 +1 @@
Authentication: Fix a crash that occurred when using the app with an account that had a soft logout.

Some files were not shown because too many files have changed in this diff Show More