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