diff --git a/CHANGES.md b/CHANGES.md index 970c9527c..6d0f6fef5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,14 @@ +## Changes in 1.9.5 (2022-09-12) + +🐛 Bugfixes + +- Fix timeline items text height calculation ([#6702](https://github.com/vector-im/element-ios/pull/6702)) + +🚧 In development 🚧 + +- Device manager: Add other sessions section read only in user sessions overview screen. ([#6672](https://github.com/vector-im/element-ios/issues/6672)) + + ## Changes in 1.9.4 (2022-09-09) ✨ Features diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 2126ffd55..fbe364eb0 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.9.4 -CURRENT_PROJECT_VERSION = 1.9.4 +MARKETING_VERSION = 1.9.5 +CURRENT_PROJECT_VERSION = 1.9.5 diff --git a/Riot/Assets/Images.xcassets/DeviceManager/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/Contents.json new file mode 100644 index 000000000..7037289bf --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "device_type_desktop.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/device_type_desktop.svg b/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/device_type_desktop.svg new file mode 100644 index 000000000..de88604bc --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/device_type_desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/Contents.json new file mode 100644 index 000000000..edaabf82f --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "device_type_mobile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/device_type_mobile.svg b/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/device_type_mobile.svg new file mode 100644 index 000000000..f34e3dba9 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/device_type_mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/Contents.json new file mode 100644 index 000000000..1d57255bc --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "device_type_unknown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/device_type_unknown.svg b/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/device_type_unknown.svg new file mode 100644 index 000000000..8a7b7f1ba --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/device_type_unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/Contents.json new file mode 100644 index 000000000..30d46e64e --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "device_type_web.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/device_type_web.svg b/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/device_type_web.svg new file mode 100644 index 000000000..34424d375 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/device_type_web.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/Contents.json new file mode 100644 index 000000000..363025375 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_unverified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/user_session_unverified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/user_session_unverified.svg new file mode 100644 index 000000000..ee304f46e --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/user_session_unverified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/Contents.json new file mode 100644 index 000000000..cf9c2286a --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_session_verified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/user_session_verified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/user_session_verified.svg new file mode 100644 index 000000000..cc3459fd2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/user_session_verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index be11cec62..f346e5586 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2358,6 +2358,24 @@ To enable access, tap Settings> Location and select Always"; "user_sessions_overview_title" = "Sessions"; +"user_sessions_overview_other_sessions_section_title" = "OTHER SESSIONS"; +"user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore."; + +"user_session_verified" = "Verified session"; +"user_session_unverified" = "Unverified session"; +"user_session_verified_short" = "Verified"; +"user_session_unverified_short" = "Unverified"; + +// First item is client name and second item is session display name +"user_session_name" = "%@: %@"; + +"user_session_item_details" = "%@ · Last activity %@"; + +"device_name_desktop" = "%@ Desktop"; +"device_name_web" = "%@ Web"; +"device_name_mobile" = "%@ Mobile"; +"device_name_unknown" = "Unknown client"; + // MARK: - MatrixKit diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 5eb309bb8..5b0b1d986 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -101,6 +101,12 @@ internal class Asset: NSObject { internal static let findYourContactsFacepile = ImageAsset(name: "find_your_contacts_facepile") internal static let captureAvatar = ImageAsset(name: "capture_avatar") internal static let deleteAvatar = ImageAsset(name: "delete_avatar") + internal static let deviceTypeDesktop = ImageAsset(name: "device_type_desktop") + internal static let deviceTypeMobile = ImageAsset(name: "device_type_mobile") + internal static let deviceTypeUnknown = ImageAsset(name: "device_type_unknown") + internal static let deviceTypeWeb = ImageAsset(name: "device_type_web") + internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified") + internal static let userSessionVerified = ImageAsset(name: "user_session_verified") internal static let e2eBlocked = ImageAsset(name: "e2e_blocked") internal static let e2eUnencrypted = ImageAsset(name: "e2e_unencrypted") internal static let e2eWarning = ImageAsset(name: "e2e_warning") @@ -109,6 +115,8 @@ internal class Asset: NSObject { internal static let encryptionWarning = ImageAsset(name: "encryption_warning") internal static let favouritesEmptyScreenArtwork = ImageAsset(name: "favourites_empty_screen_artwork") internal static let favouritesEmptyScreenArtworkDark = ImageAsset(name: "favourites_empty_screen_artwork_dark") + internal static let allChatRecents = ImageAsset(name: "all_chat_recents") + internal static let allChatUnreads = ImageAsset(name: "all_chat_unreads") internal static let roomActionDirectChat = ImageAsset(name: "room_action_direct_chat") internal static let roomActionFavourite = ImageAsset(name: "room_action_favourite") internal static let roomActionLeave = ImageAsset(name: "room_action_leave") @@ -116,6 +124,7 @@ internal class Asset: NSObject { internal static let roomActionNotificationMuted = ImageAsset(name: "room_action_notification_muted") internal static let roomActionPriorityHigh = ImageAsset(name: "room_action_priority_high") internal static let roomActionPriorityLow = ImageAsset(name: "room_action_priority_low") + internal static let allChatEditLayout = ImageAsset(name: "all_chat_edit_layout") internal static let allChatsEditIcon = ImageAsset(name: "all_chats_edit_icon") internal static let allChatsEmptyListPlaceholderIcon = ImageAsset(name: "all_chats_empty_list_placeholder_icon") internal static let allChatsEmptyScreenArtwork = ImageAsset(name: "all_chats_empty_screen_artwork") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 2aff01bd9..42a3929a0 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1451,6 +1451,22 @@ public class VectorL10n: NSObject { public static var deviceDetailsTitle: String { return VectorL10n.tr("Vector", "device_details_title") } + /// %@ Desktop + public static func deviceNameDesktop(_ p1: String) -> String { + return VectorL10n.tr("Vector", "device_name_desktop", p1) + } + /// %@ Mobile + public static func deviceNameMobile(_ p1: String) -> String { + return VectorL10n.tr("Vector", "device_name_mobile", p1) + } + /// Unknown client + public static var deviceNameUnknown: String { + return VectorL10n.tr("Vector", "device_name_unknown") + } + /// %@ Web + public static func deviceNameWeb(_ p1: String) -> String { + return VectorL10n.tr("Vector", "device_name_web", p1) + } /// The other party cancelled the verification. public static var deviceVerificationCancelled: String { return VectorL10n.tr("Vector", "device_verification_cancelled") @@ -8451,6 +8467,38 @@ public class VectorL10n: NSObject { public static var userIdTitle: String { return VectorL10n.tr("Vector", "user_id_title") } + /// %@ · Last activity %@ + public static func userSessionItemDetails(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "user_session_item_details", p1, p2) + } + /// %@: %@ + public static func userSessionName(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "user_session_name", p1, p2) + } + /// Unverified session + public static var userSessionUnverified: String { + return VectorL10n.tr("Vector", "user_session_unverified") + } + /// Unverified + public static var userSessionUnverifiedShort: String { + return VectorL10n.tr("Vector", "user_session_unverified_short") + } + /// Verified session + public static var userSessionVerified: String { + return VectorL10n.tr("Vector", "user_session_verified") + } + /// Verified + public static var userSessionVerifiedShort: String { + return VectorL10n.tr("Vector", "user_session_verified_short") + } + /// For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. + public static var userSessionsOverviewOtherSessionsSectionInfo: String { + return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info") + } + /// OTHER SESSIONS + public static var userSessionsOverviewOtherSessionsSectionTitle: String { + return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_title") + } /// Sessions public static var userSessionsOverviewTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_title") diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m index 1a0998609..8ee0a6190 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -533,15 +533,7 @@ CGFloat verticalInset = measurementTextView.textContainerInset.top + measurementTextView.textContainerInset.bottom; CGFloat horizontalInset = measurementTextView.textContainer.lineFragmentPadding * 2; - CGSize size = [attributedText boundingRectWithSize:CGSizeMake(_maxTextViewWidth - horizontalInset, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - context:nil].size; - - //In iOS 7 and later, this method returns fractional sizes (in the size component of the returned rectangle); - // to use a returned size to size views, you must use raise its value to the nearest higher integer using the - // [ceil](https://developer.apple.com/documentation/kernel/1557272-ceil?changes=latest_major) function. - size.width = ceil(size.width); - size.height = ceil(size.height); + CGSize size = [self sizeForAttributedString:attributedText fittingWidth:_maxTextViewWidth - horizontalInset]; // The result is expected to contain the textView textContainer's paddings. Add them back if necessary if (removeVerticalInset == NO) { @@ -553,6 +545,27 @@ return size; } +// https://stackoverflow.com/questions/54497598/nsattributedstring-boundingrect-returns-wrong-height +- (CGSize)sizeForAttributedString:(NSAttributedString *)attributedString fittingWidth:(CGFloat)width +{ + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; + + CGRect boundingRect = CGRectMake(0.0, 0.0, width, CGFLOAT_MAX); + + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:boundingRect.size]; + textContainer.lineFragmentPadding = 0; + + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + [layoutManager addTextContainer: textContainer]; + + [textStorage addLayoutManager:layoutManager]; + [layoutManager glyphRangeForBoundingRect:boundingRect inTextContainer:textContainer]; + + CGRect rect = [layoutManager usedRectForTextContainer:textContainer]; + + return CGRectIntegral(rect).size; +} + #pragma mark - Properties - (MXSession*)mxSession diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index a2f2b6602..e52ac8e29 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -19,6 +19,7 @@ import Foundation /// The static list of mocked screens in RiotSwiftUI enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockUserSessionsOverviewScreenState.self, MockLiveLocationLabPromotionScreenState.self, MockLiveLocationSharingViewerScreenState.self, MockAuthenticationLoginScreenState.self, diff --git a/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarView.swift b/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarView.swift new file mode 100644 index 000000000..a24567171 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarView.swift @@ -0,0 +1,88 @@ +// +// 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 +import DesignKit + +/// Avatar view for device +struct DeviceAvatarView: View { + + @Environment(\.theme) var theme: ThemeSwiftUI + + var viewData: DeviceAvatarViewData + + var avatarSize: CGFloat = 40 + var badgeSize: CGFloat = 24 + + var body: some View { + ZStack(alignment: .bottomTrailing) { + + // Device image + VStack(alignment: .center) { + viewData.deviceType.image + } + .padding() + .frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize)) + .background(theme.colors.system) + .clipShape(Circle()) + + // Verification badge + if let isVerified = viewData.isVerified { + + Image(isVerified ? Asset.Images.userSessionVerified.name : Asset.Images.userSessionUnverified.name) + .frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize)) + .shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle()) + .background(theme.colors.background) + .clipShape(Circle()) + .offset(x: 10, y: 8) + } + } + .frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize)) + } +} + +struct DeviceAvatarViewListPreview: View { + + var viewDataList: [DeviceAvatarViewData] { + return [ + DeviceAvatarViewData(deviceType: .desktop, isVerified: true), + DeviceAvatarViewData(deviceType: .web, isVerified: true), + DeviceAvatarViewData(deviceType: .mobile, isVerified: true), + DeviceAvatarViewData(deviceType: .unknown, isVerified: true) + ] + } + + var body: some View { + HStack { + VStack(alignment: .center, spacing: 20) { + DeviceAvatarView(viewData: DeviceAvatarViewData.init(deviceType: .web, isVerified: true)) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .desktop, isVerified: false)) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .mobile, isVerified: true)) + DeviceAvatarView(viewData: DeviceAvatarViewData(deviceType: .unknown, isVerified: false)) + } + } + } +} + +struct DeviceAvatarView_Previews: PreviewProvider { + + static var previews: some View { + Group { + DeviceAvatarViewListPreview().theme(.light).preferredColorScheme(.light) + DeviceAvatarViewListPreview().theme(.dark).preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarViewData.swift b/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarViewData.swift new file mode 100644 index 000000000..19c6cfd1d --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/DeviceAvatar/DeviceAvatarViewData.swift @@ -0,0 +1,26 @@ +// +// 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 SwiftUI + +/// View data for DeviceAvatarView +struct DeviceAvatarViewData { + + let deviceType: DeviceType + + let isVerified: Bool? +} diff --git a/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType+Element.swift b/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType+Element.swift new file mode 100644 index 000000000..ecbad3537 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType+Element.swift @@ -0,0 +1,58 @@ +// +// 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 SwiftUI + +extension DeviceType { + + var image: Image { + + let image: Image + + switch self { + case .desktop: + image = Image(Asset.Images.deviceTypeDesktop.name) + case .web: + image = Image(Asset.Images.deviceTypeWeb.name) + case .mobile: + image = Image(Asset.Images.deviceTypeMobile.name) + case .unknown: + image = Image(Asset.Images.deviceTypeUnknown.name) + } + + return image + } + + var name: String { + let name: String + + let appName = AppInfo.current.displayName + + switch self { + case .desktop: + name = VectorL10n.deviceNameDesktop(appName) + case .web: + name = VectorL10n.deviceNameWeb(appName) + case .mobile: + name = VectorL10n.deviceNameMobile(appName) + case .unknown: + name = VectorL10n.deviceNameUnknown + } + + return name + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType.swift b/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType.swift new file mode 100644 index 000000000..1ae053b1a --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType.swift @@ -0,0 +1,26 @@ +// +// 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 SwiftUI + +/// Client type +enum DeviceType { + case desktop + case web + case mobile + case unknown +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionLastActivityFormatter.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionLastActivityFormatter.swift new file mode 100644 index 000000000..891e0919b --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionLastActivityFormatter.swift @@ -0,0 +1,42 @@ +// +// 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 + +/// Enables to build last activity date string +class UserSessionLastActivityFormatter { + + // MARK: - Constants + + private static var lastActivityDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + dateFormatter.doesRelativeDateFormatting = true + return dateFormatter + }() + + // MARK: - Public + + /// Session last activity string + func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { + + let date = Date(timeIntervalSince1970: lastActivityTimestamp) + + return UserSessionLastActivityFormatter.lastActivityDateFormatter.string(from: date) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionNameFormatter.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionNameFormatter.swift new file mode 100644 index 000000000..0aed1082f --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionFormatters/UserSessionNameFormatter.swift @@ -0,0 +1,37 @@ +// +// 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 + +/// Enables to build user session name +class UserSessionNameFormatter { + + /// Session name with client name and session display name + func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String { + + let sessionName: String + + let clientName = deviceType.name + + if let sessionDisplayName = sessionDisplayName { + sessionName = VectorL10n.userSessionName(clientName, sessionDisplayName) + } else { + sessionName = clientName + } + + return sessionName + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionInfo/UserSessionInfo.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionInfo/UserSessionInfo.swift new file mode 100644 index 000000000..6009bfcb2 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionInfo/UserSessionInfo.swift @@ -0,0 +1,78 @@ +// +// 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 + +/// Represents a user session information +struct UserSessionInfo: Identifiable { + + /// Delay after which session is considered inactive, 90 days + static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 + + // MARK: - Properties + + var id: String { + return sessionId + } + + /// The session identifier + let sessionId: String + + /// The session display name + let sessionName: String? + + /// The device type used by the session + let deviceType: DeviceType + + /// True to indicate that the session is verified + let isVerified: Bool + + /// The IP address where this device was last seen. + let lastSeenIP: String? + + /// Last time the session was active + let lastSeenTimestamp: TimeInterval? + + /// True to indicate that session has been used under `inactiveSessionDurationTreshold` value + let isSessionActive: Bool + + // MARK: - Setup + + init(sessionId: String, + sessionName: String?, + deviceType: DeviceType, + isVerified: Bool, + lastSeenIP: String?, + lastSeenTimestamp: TimeInterval?) { + + self.sessionId = sessionId + self.sessionName = sessionName + self.deviceType = deviceType + self.isVerified = isVerified + self.lastSeenIP = lastSeenIP + self.lastSeenTimestamp = lastSeenTimestamp + + if let lastSeenTimestamp = lastSeenTimestamp { + let elapsedTime = Date().timeIntervalSince1970 - lastSeenTimestamp + + let isSessionInactive = elapsedTime >= Self.inactiveSessionDurationTreshold + + self.isSessionActive = !isSessionInactive + } else { + self.isSessionActive = true + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 5abebc3ab..1494eb7c2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -31,6 +31,9 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private let userSessionsOverviewHostingController: UIViewController private var userSessionsOverviewViewModel: UserSessionsOverviewViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + // MARK: Public // Must be used only internally @@ -41,10 +44,15 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { init(parameters: UserSessionsOverviewCoordinatorParameters) { self.parameters = parameters - let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: UserSessionsOverviewService()) + let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: UserSessionsOverviewService(mxSession: parameters.session)) let view = UserSessionsOverview(viewModel: viewModel.context) userSessionsOverviewViewModel = viewModel - userSessionsOverviewHostingController = VectorHostingController(rootView: view) + + let hostingViewController = VectorHostingController(rootView: view) + + userSessionsOverviewHostingController = hostingViewController + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController) } // MARK: - Public @@ -55,8 +63,20 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { guard let self = self else { return } MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") switch result { - case .done: + case .cancel: self.completion?() + case .showAllUnverifiedSessions: + self.showAllUnverifiedSessions() + case .showAllInactiveSessions: + self.showAllInactiveSessions() + case .verifyCurrentSession: + self.startVerifyCurrentSession() + case .showCurrentSessionDetails: + self.showCurrentSessionDetails() + case .showAllOtherSessions: + self.showAllOtherSessions() + case .showUserSessionDetails(let sessionId): + self.showUserSessionDetails(sessionId: sessionId) } } } @@ -64,4 +84,43 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { func toPresentable() -> UIViewController { return self.userSessionsOverviewHostingController } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) { + loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } + + private func showAllUnverifiedSessions() { + // TODO + } + + private func showAllInactiveSessions() { + // TODO + } + + private func startVerifyCurrentSession() { + // TODO + } + + private func showCurrentSessionDetails() { + // TODO + } + + private func showUserSessionDetails(sessionId: String) { + // TODO + } + + private func showAllOtherSessions() { + // TODO + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 1f280ad1a..59ccf1e02 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -15,11 +15,123 @@ // import Foundation +import MatrixSDK class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { + // MARK: - Constants + + // MARK: - Properties + + // MARK: Private + + private let mxSession: MXSession + + // MARK: Public + + private(set) var lastOverviewData: UserSessionsOverviewData + // MARK: - Setup - init() { + init(mxSession: MXSession) { + self.mxSession = mxSession + + self.lastOverviewData = UserSessionsOverviewData(currentSessionInfo: nil, unverifiedSessionsInfo: [], inactiveSessionsInfo: [], otherSessionsInfo: []) + + self.setupInitialOverviewData() + } + + // MARK: - Public + + func fetchUserSessionsOverviewData(completion: @escaping (Result) -> Void) { + self.mxSession.matrixRestClient.devices { response in + switch response { + case .success(let devices): + let overviewData = self.userSessionsOverviewData(from: devices) + completion(.success(overviewData)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Private + + private func setupInitialOverviewData() { + let currentSessionInfo = self.getCurrentUserSessionInfoFromCache() + + self.lastOverviewData = UserSessionsOverviewData(currentSessionInfo: currentSessionInfo, unverifiedSessionsInfo: [], inactiveSessionsInfo: [], otherSessionsInfo: []) + } + + private func getCurrentUserSessionInfoFromCache() -> UserSessionInfo? { + guard let mainAccount = MXKAccountManager.shared().activeAccounts.first, let device = mainAccount.device else { + return nil + } + return self.userSessionInfo(from: device) + } + + private func userSessionInfo(from device: MXDevice) -> UserSessionInfo { + + let deviceInfo = self.getDeviceInfo(for: device.deviceId) + + let isSessionVerified = deviceInfo?.trustLevel.isVerified ?? false + + var lastSeenTs: TimeInterval? + + if device.lastSeenTs > 0 { + lastSeenTs = TimeInterval(device.lastSeenTs / 1000) + } + + return UserSessionInfo(sessionId: device.deviceId, + sessionName: device.displayName, + deviceType: .unknown, + isVerified: isSessionVerified, + lastSeenIP: device.lastSeenIp, + lastSeenTimestamp: lastSeenTs) + } + + private func getDeviceInfo(for deviceId: String) -> MXDeviceInfo? { + guard let userId = self.mxSession.myUserId else { + return nil + } + return self.mxSession.crypto.device(withDeviceId: deviceId, ofUser: userId) + } + + private func userSessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { + + let sortedDevices = devices.sorted { device1, device2 in + device1.lastSeenTs > device2.lastSeenTs + } + + let allUserSessionInfo = sortedDevices.map { device in + return self.userSessionInfo(from: device) + } + + var currentSessionInfo: UserSessionInfo? + + var unverifiedSessionsInfo: [UserSessionInfo] = [] + var inactiveSessionsInfo: [UserSessionInfo] = [] + var otherSessionsInfo: [UserSessionInfo] = [] + + for userSessionInfo in allUserSessionInfo { + if userSessionInfo.sessionId == self.mxSession.myDeviceId { + currentSessionInfo = userSessionInfo + } else { + otherSessionsInfo.append(userSessionInfo) + + if userSessionInfo.isVerified == false { + unverifiedSessionsInfo.append(userSessionInfo) + } + + if userSessionInfo.isSessionActive == false { + inactiveSessionsInfo.append(userSessionInfo) + } + } + } + + return UserSessionsOverviewData(currentSessionInfo: currentSessionInfo, + unverifiedSessionsInfo: unverifiedSessionsInfo, + inactiveSessionsInfo: inactiveSessionsInfo, + otherSessionsInfo: otherSessionsInfo) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index 61037fec8..f0aa43861 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -17,4 +17,26 @@ import Foundation class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { + + var lastOverviewData: UserSessionsOverviewData + + func fetchUserSessionsOverviewData(completion: @escaping (Result) -> Void) { + completion(.success(self.lastOverviewData)) + } + + init() { + let currentSessionInfo = UserSessionInfo(sessionId: "alice", sessionName: "iOS", deviceType: .mobile, isVerified: false, lastSeenIP: "10.0.0.10", lastSeenTimestamp: nil) + + let unverifiedSessionsInfo: [UserSessionInfo] = [] + + let inactiveSessionsInfo: [UserSessionInfo] = [] + + let otherSessionsInfo: [UserSessionInfo] = [ + UserSessionInfo(sessionId: "1", sessionName: "macOS", deviceType: .desktop, isVerified: true, lastSeenIP: "1.0.0.1", lastSeenTimestamp: (Date().timeIntervalSince1970 - 130000)), + UserSessionInfo(sessionId: "2", sessionName: "Firefox on Windows", deviceType: .web, isVerified: true, lastSeenIP: "2.0.0.2", lastSeenTimestamp: (Date().timeIntervalSince1970 - 100)), + UserSessionInfo(sessionId: "3", sessionName: "Android", deviceType: .mobile, isVerified: false, lastSeenIP: "3.0.0.3", lastSeenTimestamp: (Date().timeIntervalSince1970 - 10)) + ] + + self.lastOverviewData = UserSessionsOverviewData(currentSessionInfo: currentSessionInfo, unverifiedSessionsInfo: unverifiedSessionsInfo, inactiveSessionsInfo: inactiveSessionsInfo, otherSessionsInfo: otherSessionsInfo) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index be124d126..e7774be69 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -16,5 +16,17 @@ import Foundation -protocol UserSessionsOverviewServiceProtocol { +struct UserSessionsOverviewData { + + let currentSessionInfo: UserSessionInfo? + let unverifiedSessionsInfo: [UserSessionInfo] + let inactiveSessionsInfo: [UserSessionInfo] + let otherSessionsInfo: [UserSessionInfo] +} + +protocol UserSessionsOverviewServiceProtocol { + + var lastOverviewData: UserSessionsOverviewData { get } + + func fetchUserSessionsOverviewData(completion: @escaping (Result) -> Void) -> Void } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift index aa0cf5e87..136372d89 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift @@ -18,4 +18,5 @@ import XCTest import RiotSwiftUI class UserSessionsOverviewUITests: MockScreenTestCase { + // TODO: } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 949617ca6..5d01772fa 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -21,18 +21,36 @@ import Foundation // MARK: View model enum UserSessionsOverviewViewModelResult { - case done + case cancel + case showAllUnverifiedSessions + case showAllInactiveSessions + case verifyCurrentSession + case showCurrentSessionDetails + case showAllOtherSessions + case showUserSessionDetails(_ sessionId: String) } // MARK: View struct UserSessionsOverviewViewState: BindableState { + + var unverifiedSessionsViewData: [UserSessionListItemViewData] + + var inactiveSessionsViewData: [UserSessionListItemViewData] + + var currentSessionViewData: UserSessionListItemViewData? + + var otherSessionsViewData: [UserSessionListItemViewData] + + var showLoadingIndicator: Bool = false } enum UserSessionsOverviewViewAction { + case viewAppeared case verifyCurrentSession case viewCurrentSessionDetails case viewAllUnverifiedSessions case viewAllInactiveSessions case viewAllOtherSessions + case tapUserSession(_ sessionId: String) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index 93c28688c..0edaf96bb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -37,25 +37,80 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess init(userSessionsOverviewService: UserSessionsOverviewServiceProtocol) { self.userSessionsOverviewService = userSessionsOverviewService - let viewState = UserSessionsOverviewViewState() + let viewState = UserSessionsOverviewViewState(unverifiedSessionsViewData: [], inactiveSessionsViewData: [], currentSessionViewData: nil, otherSessionsViewData: []) super.init(initialViewState: viewState) + + self.updateViewState(with: userSessionsOverviewService.lastOverviewData) } // MARK: - Public override func process(viewAction: UserSessionsOverviewViewAction) { switch viewAction { + case .viewAppeared: + self.loadData() case .verifyCurrentSession: - break + self.completion?(.verifyCurrentSession) case .viewCurrentSessionDetails: - break + self.completion?(.showCurrentSessionDetails) case .viewAllUnverifiedSessions: - break + self.completion?(.showAllUnverifiedSessions) case .viewAllInactiveSessions: - break + self.completion?(.showAllInactiveSessions) case .viewAllOtherSessions: - break + self.completion?(.showAllOtherSessions) + case .tapUserSession(let sessionId): + self.completion?(.showUserSessionDetails(sessionId)) + } + } + + // MARK: - Private + + private func updateViewState(with userSessionsViewData: UserSessionsOverviewData) { + + let unverifiedSessionsViewData = self.userSessionListItemViewDataList(from: userSessionsViewData.unverifiedSessionsInfo) + let inactiveSessionsViewData = self.userSessionListItemViewDataList(from: userSessionsViewData.inactiveSessionsInfo) + + var currentSessionViewData: UserSessionListItemViewData? + + let otherSessionsViewData = self.userSessionListItemViewDataList(from: userSessionsViewData.otherSessionsInfo) + + + if let currentSessionInfo = userSessionsViewData.currentSessionInfo { + currentSessionViewData = UserSessionListItemViewData(userSessionInfo: currentSessionInfo) + } + + self.state.unverifiedSessionsViewData = unverifiedSessionsViewData + self.state.inactiveSessionsViewData = inactiveSessionsViewData + self.state.currentSessionViewData = currentSessionViewData + self.state.otherSessionsViewData = otherSessionsViewData + } + + private func userSessionListItemViewDataList(from userSessionInfoList: [UserSessionInfo]) -> [UserSessionListItemViewData] { + return userSessionInfoList.map { + return UserSessionListItemViewData(userSessionInfo: $0) + } + } + + private func loadData() { + + self.state.showLoadingIndicator = true + + self.userSessionsOverviewService.fetchUserSessionsOverviewData { [weak self] result in + guard let self = self else { + return + } + + self.state.showLoadingIndicator = false + + switch result { + case .success(let overViewData): + self.updateViewState(with: overViewData) + case .failure(let error): + // TODO + break + } } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift new file mode 100644 index 000000000..ae1b65893 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -0,0 +1,103 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct UserSessionListItem: View { + + // MARK: - Constants + + private enum LayoutConstants { + static let horizontalPadding: CGFloat = 15 + static let verticalPadding: CGFloat = 16 + static let avatarWidth: CGFloat = 40 + static let avatarRightMargin: CGFloat = 18 + } + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let viewData: UserSessionListItemViewData + + var onBackgroundTap: ((String) -> (Void))? = nil + + // MARK: - Body + + var body: some View { + Button(action: { onBackgroundTap?(self.viewData.sessionId) + }) { + VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) { + HStack(spacing: LayoutConstants.avatarRightMargin) { + DeviceAvatarView(viewData: viewData.deviceAvatarViewData) + VStack(alignment: .leading, spacing: 2) { + Text(viewData.sessionName) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + .multilineTextAlignment(.leading) + + Text(viewData.sessionDetails) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, LayoutConstants.horizontalPadding) + + // Separator + // Note: Separator leading is matching the text leading, we could use alignment guide in the future + Rectangle() + .fill(theme.colors.quinaryContent) + .frame(maxWidth: .infinity, alignment: .trailing) + .frame(height: 1.0) + .padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth) + } + .padding(.top, LayoutConstants.verticalPadding) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct UserSessionListPreview: View { + + let userSessionsOverviewService: UserSessionsOverviewServiceProtocol = MockUserSessionsOverviewService() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(userSessionsOverviewService.lastOverviewData.otherSessionsInfo) { userSessionInfo in + let viewData = UserSessionListItemViewData(userSessionInfo: userSessionInfo) + + UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in + + }) + } + } + } +} + +struct UserSessionListItem_Previews: PreviewProvider { + static var previews: some View { + Group { + UserSessionListPreview().theme(.light).preferredColorScheme(.light) + UserSessionListPreview().theme(.dark).preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift new file mode 100644 index 000000000..4acac069e --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -0,0 +1,84 @@ +// +// 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 + +/// View data for UserSessionListItem +struct UserSessionListItemViewData: Identifiable { + + // MARK: - Constants + + private static let userSessionNameFormatter = UserSessionNameFormatter() + private static let lastActivityDateFormatter = UserSessionLastActivityFormatter() + + // MARK: - Properties + + var id: String { + return sessionId + } + + let sessionId: String + + let sessionName: String + + let sessionDetails: String + + let deviceAvatarViewData: DeviceAvatarViewData + + // MARK: - Setup + + init(sessionId: String, + sessionDisplayName: String?, + deviceType: DeviceType, + isVerified: Bool, + lastActivityDate: TimeInterval?) { + + self.sessionId = sessionId + self.sessionName = Self.userSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) + self.sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) + self.deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified) + } + + // MARK: - Private + + private static func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String { + + let sessionDetailsString: String + + let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort + + var lastActivityDateString: String? + + if let lastActivityDate = lastActivityDate { + lastActivityDateString = Self.lastActivityDateFormatter.lastActivityDateString(from: lastActivityDate) + } + + if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { + sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString) + } else { + sessionDetailsString = sessionStatusText + } + + return sessionDetailsString + } +} + +extension UserSessionListItemViewData { + + init(userSessionInfo: UserSessionInfo) { + self.init(sessionId: userSessionInfo.sessionId, sessionDisplayName: userSessionInfo.sessionName, deviceType: userSessionInfo.deviceType, isVerified: userSessionInfo.isVerified, lastActivityDate: userSessionInfo.lastSeenTimestamp) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 1dc2983fb..f4b49c4ea 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -29,11 +29,59 @@ struct UserSessionsOverview: View { @ObservedObject var viewModel: UserSessionsOverviewViewModel.Context var body: some View { - VStack { + ScrollView { + + // Security recommendations section + if viewModel.viewState.unverifiedSessionsViewData.isEmpty == false || viewModel.viewState.inactiveSessionsViewData.isEmpty == false { + + // TODO: + } + + // Current session section + if let currentSessionViewData = viewModel.viewState.currentSessionViewData { + // TODO: + } + + // Other sessions section + if viewModel.viewState.otherSessionsViewData.isEmpty == false { + self.otherSessionsSection + } } - .background(theme.colors.background) + .background(theme.colors.system.ignoresSafeArea()) .frame(maxHeight: .infinity) .navigationTitle(VectorL10n.userSessionsOverviewTitle) + .activityIndicator(show: viewModel.viewState.showLoadingIndicator) + .onAppear() { + viewModel.send(viewAction: .viewAppeared) + } + } + + var otherSessionsSection: some View { + + SwiftUI.Section { + // Device list + LazyVStack(spacing: 0) { + ForEach(viewModel.viewState.otherSessionsViewData) { viewData in + UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in + viewModel.send(viewAction: .tapUserSession(sessionId)) + }) + } + } + .background(theme.colors.background) + } header: { + VStack(alignment: .leading) { + Text(VectorL10n.userSessionsOverviewOtherSessionsSectionTitle) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 10) + + Text(VectorL10n.userSessionsOverviewOtherSessionsSectionInfo) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 11) + } + .padding(.horizontal, 16) + } } }