mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-29 07:42:40 +00:00
Merge pull request #6706 from vector-im/release/1.9.5/release
Release 1.9.5
This commit is contained in:
commit
67a57a5204
37 changed files with 1092 additions and 25 deletions
11
CHANGES.md
11
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
|
||||
|
|
|
@ -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
|
||||
|
|
6
Riot/Assets/Images.xcassets/DeviceManager/Contents.json
Normal file
6
Riot/Assets/Images.xcassets/DeviceManager/Contents.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_desktop.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "device_type_desktop.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15C2.45 15 1.97933 14.8043 1.588 14.413C1.196 14.021 1 13.55 1 13V2C1 1.45 1.196 0.979 1.588 0.587C1.97933 0.195667 2.45 0 3 0H19C19.55 0 20.021 0.195667 20.413 0.587C20.8043 0.979 21 1.45 21 2V13C21 13.55 20.8043 14.021 20.413 14.413C20.021 14.8043 19.55 15 19 15H3ZM3 13H19V2H3V13ZM1 18C0.716667 18 0.479333 17.904 0.288 17.712C0.096 17.5207 0 17.2833 0 17C0 16.7167 0.096 16.4793 0.288 16.288C0.479333 16.096 0.716667 16 1 16H21C21.2833 16 21.5207 16.096 21.712 16.288C21.904 16.4793 22 16.7167 22 17C22 17.2833 21.904 17.5207 21.712 17.712C21.5207 17.904 21.2833 18 21 18H1ZM3 13V2V13Z" fill="#737D8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 723 B |
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "device_type_mobile.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
3
Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/device_type_mobile.svg
vendored
Normal file
3
Riot/Assets/Images.xcassets/DeviceManager/device_type_mobile.imageset/device_type_mobile.svg
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="14" height="22" viewBox="0 0 14 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0.00999999L2 0C0.9 0 0 0.9 0 2V20C0 21.1 0.9 22 2 22H12C13.1 22 14 21.1 14 20V2C14 0.9 13.1 0.00999999 12 0.00999999ZM12 18H2V4H12V18Z" fill="#737D8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 269 B |
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_unknown.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "device_type_unknown.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 22.5C17.0751 22.5 22 17.5751 22 11.5C22 5.42487 17.0751 0.5 11 0.5C4.92487 0.5 0 5.42487 0 11.5C0 17.5751 4.92487 22.5 11 22.5ZM11.0002 18.2605C11.7596 18.2605 12.3752 17.6449 12.3752 16.8855C12.3752 16.1261 11.7596 15.5105 11.0002 15.5105C10.2408 15.5105 9.6252 16.1261 9.6252 16.8855C9.6252 17.6449 10.2408 18.2605 11.0002 18.2605ZM9.0899 9.42801C9.0899 8.3697 9.94859 7.51827 10.9996 7.51827C12.0476 7.51827 12.9093 8.38001 12.9093 9.42801C12.9093 9.91336 12.7018 10.0866 11.8839 10.6516C11.5215 10.902 11.0246 11.2498 10.6376 11.7599C10.2233 12.306 9.96838 12.9869 9.96838 13.8528H12.0309C12.0309 13.4287 12.1436 13.1873 12.2807 13.0065C12.4452 12.7897 12.6834 12.6061 13.0563 12.3485C13.0955 12.3215 13.1368 12.2933 13.18 12.264C13.8559 11.8042 14.9718 11.0452 14.9718 9.42801C14.9718 7.24094 13.1867 5.45577 10.9996 5.45577C8.8156 5.45577 7.0274 7.22452 7.0274 9.42801H9.0899Z" fill="#737D8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1 KiB |
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "device_type_web.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
3
Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/device_type_web.svg
vendored
Normal file
3
Riot/Assets/Images.xcassets/DeviceManager/device_type_web.imageset/device_type_web.svg
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.5H2C1.45 16.5 0.979333 16.3043 0.588 15.913C0.196 15.521 0 15.05 0 14.5V2.5C0 1.95 0.196 1.47933 0.588 1.088C0.979333 0.696 1.45 0.5 2 0.5H18C18.55 0.5 19.021 0.696 19.413 1.088C19.8043 1.47933 20 1.95 20 2.5V14.5C20 15.05 19.8043 15.521 19.413 15.913C19.021 16.3043 18.55 16.5 18 16.5ZM2 4.5V14.5H18V4.5H2Z" fill="#737D8C"/>
|
||||
</svg>
|
After Width: | Height: | Size: 445 B |
12
Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/user_session_unverified.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "user_session_unverified.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.75 6.7025V2.0375L6 0.5L11.25 2.0375V6.7025C11.25 11.4725 6 12.5 6 12.5C6 12.5 0.75 11.4725 0.75 6.7025Z" fill="#FF4B55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.4261 4.53601C5.39797 4.21539 5.63423 3.93414 5.95485 3.91164C6.26985 3.88914 6.5511 4.12539 6.58485 4.44601V4.53601L6.40485 6.78601C6.38797 6.99414 6.2136 7.15164 6.00548 7.15164H5.97173C5.77485 7.13476 5.62298 6.98289 5.6061 6.78601L5.4261 4.53601ZM6.49415 8.25355C6.49415 8.52693 6.27253 8.74855 5.99915 8.74855C5.72577 8.74855 5.50415 8.52693 5.50415 8.25355C5.50415 7.98016 5.72577 7.75854 5.99915 7.75854C6.27253 7.75854 6.49415 7.98016 6.49415 8.25355Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 764 B |
12
Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/user_session_verified.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "user_session_verified.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.75 6.2025V1.5375L6 0L11.25 1.5375V6.2025C11.25 10.9725 6 12 6 12C6 12 0.75 10.9725 0.75 6.2025Z" fill="#0DBD8B"/>
|
||||
<path d="M8.70809 3.4047C8.57309 3.2622 8.34809 3.2547 8.20559 3.3897L5.00309 6.3897L3.82559 5.5647C3.66809 5.4597 3.45059 5.4597 3.30059 5.5947C3.12059 5.7447 3.10559 6.0147 3.25559 6.1947L4.61309 7.7547C4.63559 7.7772 4.65809 7.8072 4.68809 7.8222C4.94309 8.0322 5.32559 7.9947 5.53559 7.7397L5.55809 7.7097L8.72309 3.8772C8.82809 3.7422 8.82809 3.5397 8.70809 3.4047Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 616 B |
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
26
RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType.swift
Normal file
26
RiotSwiftUI/Modules/UserSessions/DeviceType/DeviceType.swift
Normal file
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<UserSessionsOverviewData, Error>) -> 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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,26 @@
|
|||
import Foundation
|
||||
|
||||
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
|
||||
var lastOverviewData: UserSessionsOverviewData
|
||||
|
||||
func fetchUserSessionsOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> 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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<UserSessionsOverviewData, Error>) -> Void) -> Void
|
||||
}
|
||||
|
|
|
@ -18,4 +18,5 @@ import XCTest
|
|||
import RiotSwiftUI
|
||||
|
||||
class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
// TODO:
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue