Merge pull request #6706 from vector-im/release/1.9.5/release

Release 1.9.5
This commit is contained in:
Doug 2022-09-12 17:12:30 +01:00 committed by GitHub
commit 67a57a5204
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1092 additions and 25 deletions

View file

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

View file

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

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "device_type_desktop.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "device_type_mobile.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "device_type_unknown.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "device_type_web.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "user_session_unverified.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "user_session_verified.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
/// View data for DeviceAvatarView
struct DeviceAvatarViewData {
let deviceType: DeviceType
let isVerified: Bool?
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,4 +18,5 @@ import XCTest
import RiotSwiftUI
class UserSessionsOverviewUITests: MockScreenTestCase {
// TODO:
}

View file

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

View file

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

View file

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

View file

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

View file

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