mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
f2eaa8e80d
* Present the multi-account announcement when nothing else is showing The user session flow owns onboarding, verification and settings, so it presents the announcement in the tab sheet slot with the lowest priority: once the verification state is known, and whenever onboarding, settings or a verification sheet is dismissed. Routes and sign-out dismiss settings without triggering it, and it is marked as seen when presented. * Show the multi-account announcement on the home screen Following review, the announcement is a best-effort sheet on the home screen instead of a UserSessionFlowCoordinator state. It's presented when the room list appears with the flag on, the announcement armed and the session verified, and counts as seen once its content appears. - Restore UserSessionFlowCoordinator to develop. - HomeScreenViewModel presents the announcement through a binding and waits for a known verification state, so it never competes with the identity confirmation onboarding. - Add account only closes the sheet until the add account flow exists. - Rename MultiAccountAnnouncementScreen to MultiAccountAnnouncementView and drop its unused coordinator and view model protocol. - Size the sheet from the measured navigation bar height and match the Figma spacing; the hard-coded 44pt pushed the content under the button. * Present the multi-account announcement from the home screen view model The announcement no longer needs its own view model: the view takes the home screen's context and presents through a boolean binding, like the home screen banners. - Drop MultiAccountAnnouncementViewModel and its models. The view sends home screen actions to close or add an account and builds its own attributed subtitle. - Check the verification state when the room list appears instead of waiting for it. Restored sessions already know it by then, unverified ones get the identity confirmation cover first and the room list appears again once it's dismissed. Nothing is left subscribed, so the sheet can no longer turn up later wherever the user is.
326 lines
10 KiB
Swift
326 lines
10 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import Foundation
|
|
import UIKit
|
|
|
|
enum HomeScreenViewModelAction {
|
|
case presentRoom(roomIdentifier: String)
|
|
case detachRoom(roomIdentifier: String)
|
|
case presentRoomDetails(roomIdentifier: String)
|
|
case presentReportRoom(roomIdentifier: String)
|
|
case presentDeclineAndBlock(userID: String, roomID: String)
|
|
case presentSpace(SpaceRoomListProxyProtocol)
|
|
case roomLeft(roomIdentifier: String)
|
|
case transferOwnership(roomIdentifier: String)
|
|
case presentSecureBackupSettings
|
|
case presentRecoveryKeyScreen
|
|
case presentEncryptionResetScreen
|
|
case presentSettingsScreen
|
|
case presentFeedbackScreen
|
|
case presentStartChatScreen
|
|
case logout
|
|
}
|
|
|
|
enum HomeScreenViewAction {
|
|
case selectRoom(roomIdentifier: String)
|
|
case detachRoom(roomIdentifier: String)
|
|
case showRoomDetails(roomIdentifier: String)
|
|
case leaveRoom(roomIdentifier: String)
|
|
case confirmLeaveRoom(roomIdentifier: String)
|
|
case reportRoom(roomIdentifier: String)
|
|
case showSettings
|
|
case startChat
|
|
case setupRecovery
|
|
case confirmRecoveryKey
|
|
case resetEncryption
|
|
case skipRecoveryKeyConfirmation
|
|
case dismissNewSoundBanner
|
|
case screenAppeared
|
|
case multiAccountAnnouncementAppeared
|
|
case dismissMultiAccountAnnouncement
|
|
case addAccount
|
|
case updateVisibleItemRange(Range<Int>)
|
|
case spaceFilters
|
|
case markRoomAsUnread(roomIdentifier: String)
|
|
case markRoomAsRead(roomIdentifier: String)
|
|
case markRoomAsFavourite(roomIdentifier: String, isFavourite: Bool)
|
|
|
|
case acceptInvite(roomIdentifier: String)
|
|
case declineInvite(roomIdentifier: String)
|
|
}
|
|
|
|
enum HomeScreenRoomListMode: CustomStringConvertible {
|
|
/// Shows nothing briefly on launch as the cached rooms usually publish in time to go straight to them.
|
|
case awaitingCachedRooms
|
|
case skeletons
|
|
case empty
|
|
case rooms
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .awaitingCachedRooms:
|
|
return "Awaiting cached rooms"
|
|
case .skeletons:
|
|
return "Showing placeholders"
|
|
case .empty:
|
|
return "Showing empty state"
|
|
case .rooms:
|
|
return "Showing rooms"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum HomeScreenSecurityBannerMode: Equatable {
|
|
case none
|
|
case dismissed
|
|
case show(HomeScreenRecoveryKeyConfirmationBanner.State)
|
|
|
|
var isDismissed: Bool {
|
|
switch self {
|
|
case .dismissed: true
|
|
default: false
|
|
}
|
|
}
|
|
|
|
var isShown: Bool {
|
|
switch self {
|
|
case .show: true
|
|
default: false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HomeScreenViewState: BindableState {
|
|
var userProfile: UserProfile
|
|
|
|
var securityBannerMode = HomeScreenSecurityBannerMode.none
|
|
var shouldShowNewSoundBanner = false
|
|
|
|
var rooms: [HomeScreenRoom] = []
|
|
var roomListMode: HomeScreenRoomListMode = .awaitingCachedRooms
|
|
|
|
var hasPendingInvitations = false
|
|
|
|
var selectedRoomID: String?
|
|
|
|
var hideInviteAvatars = false
|
|
|
|
var reportRoomEnabled = false
|
|
|
|
var shouldShowSpaceFilters = false
|
|
var selectedSpaceFilter: SpaceServiceFilter?
|
|
|
|
/// Inline room list search is disabled when the dedicated global search tab is shown instead (see `UserSessionFlowCoordinator`).
|
|
var isRoomListSearchEnabled = true
|
|
|
|
var visibleRooms: [HomeScreenRoom] {
|
|
if roomListMode == .skeletons || roomListMode == .awaitingCachedRooms {
|
|
return placeholderRooms
|
|
}
|
|
|
|
return rooms
|
|
}
|
|
|
|
var bindings: HomeScreenViewStateBindings
|
|
|
|
var placeholderRooms: [HomeScreenRoom] {
|
|
(1...10).map { _ in
|
|
HomeScreenRoom.placeholder()
|
|
}
|
|
}
|
|
|
|
/// Used to hide all the rooms when the search field is focused and the query is empty
|
|
var shouldHideRoomList: Bool {
|
|
bindings.isSearchFieldFocused && bindings.searchQuery.isEmpty
|
|
}
|
|
|
|
var shouldShowEmptyFilterState: Bool {
|
|
!bindings.isSearchFieldFocused &&
|
|
(bindings.filtersState.isFiltering || selectedSpaceFilter != nil) &&
|
|
visibleRooms.isEmpty
|
|
}
|
|
|
|
var shouldShowFilters: Bool {
|
|
!bindings.isSearchFieldFocused && roomListMode == .rooms
|
|
}
|
|
|
|
var shouldShowBanner: Bool {
|
|
securityBannerMode.isShown || shouldShowNewSoundBanner
|
|
}
|
|
}
|
|
|
|
struct HomeScreenViewStateBindings {
|
|
var filtersState: RoomListFiltersState
|
|
var searchQuery = ""
|
|
var isSearchFieldFocused = false
|
|
|
|
var alertInfo: AlertInfo<UUID>?
|
|
var leaveRoomAlertItem: LeaveRoomAlertItem?
|
|
|
|
var spaceFiltersViewModel: ChatsSpaceFiltersScreenViewModel?
|
|
var isPresentingMultiAccountAnnouncement = false
|
|
}
|
|
|
|
enum CallBadgeType {
|
|
case voice, video, none
|
|
}
|
|
|
|
struct HomeScreenRoom: Identifiable, Equatable {
|
|
enum RoomType: Equatable {
|
|
case placeholder
|
|
case room
|
|
case invite(inviterDetails: RoomInviterDetails?)
|
|
case knock
|
|
}
|
|
|
|
static let placeholderLastMessage = AttributedString("Hidden last message")
|
|
|
|
/// The list item identifier is it's room identifier.
|
|
let id: String
|
|
|
|
/// The real room identifier this item points to
|
|
let roomID: String?
|
|
|
|
let type: RoomType
|
|
|
|
var inviter: RoomInviterDetails? {
|
|
if case .invite(let inviter) = type {
|
|
return inviter
|
|
}
|
|
return nil
|
|
}
|
|
|
|
let badges: Badges
|
|
struct Badges: Equatable {
|
|
let isDotShown: Bool
|
|
let notificationCount: UInt
|
|
let isMentionShown: Bool
|
|
let isMuteShown: Bool
|
|
let callBadgeType: CallBadgeType
|
|
}
|
|
|
|
let name: String
|
|
|
|
let isDirect: Bool
|
|
|
|
let isHighlighted: Bool
|
|
|
|
let isFavourite: Bool
|
|
|
|
let timestamp: String?
|
|
|
|
let lastMessage: AttributedString?
|
|
|
|
enum LastMessageState { case sending, failed }
|
|
let lastMessageState: LastMessageState?
|
|
|
|
let avatar: RoomAvatar
|
|
|
|
let statusEmoji: Character?
|
|
|
|
let canonicalAlias: String?
|
|
|
|
let isTombstoned: Bool
|
|
|
|
var displayedLastMessage: AttributedString? {
|
|
if isTombstoned {
|
|
AttributedString(L10n.screenRoomlistTombstonedRoomDescription)
|
|
} else if lastMessageState == .failed {
|
|
AttributedString(L10n.commonMessageFailedToSend)
|
|
} else {
|
|
lastMessage
|
|
}
|
|
}
|
|
|
|
static func placeholder() -> HomeScreenRoom {
|
|
HomeScreenRoom(id: UUID().uuidString,
|
|
roomID: nil,
|
|
type: .placeholder,
|
|
badges: .init(isDotShown: false, notificationCount: 0, isMentionShown: false, isMuteShown: false, callBadgeType: .none),
|
|
name: "Placeholder room name",
|
|
isDirect: false,
|
|
isHighlighted: false,
|
|
isFavourite: false,
|
|
timestamp: "Now",
|
|
lastMessage: placeholderLastMessage,
|
|
lastMessageState: nil,
|
|
avatar: .room(id: "", name: "", avatarURL: nil),
|
|
statusEmoji: nil,
|
|
canonicalAlias: nil,
|
|
isTombstoned: false)
|
|
}
|
|
}
|
|
|
|
extension HomeScreenRoom {
|
|
init(summary: RoomSummary,
|
|
showAllActivity: Bool = true,
|
|
seenInvites: Set<String> = []) {
|
|
let roomID = summary.id
|
|
|
|
let isUnseenInvite = summary.joinRequestType?.isInvite == true && !seenInvites.contains(roomID)
|
|
|
|
let isDotShown = if showAllActivity {
|
|
summary.hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || isUnseenInvite
|
|
} else {
|
|
(!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || summary.isMarkedUnread || isUnseenInvite
|
|
}
|
|
|
|
let isMentionShown = summary.hasUnreadMentions && !summary.isMuted
|
|
let isMuteShown = summary.isMuted
|
|
let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || isUnseenInvite
|
|
|
|
let callBadge = if summary.hasOngoingCall {
|
|
summary.activeCallIntent == .audio ? CallBadgeType.voice : CallBadgeType.video
|
|
} else {
|
|
CallBadgeType.none
|
|
}
|
|
|
|
let type: HomeScreenRoom.RoomType = switch summary.joinRequestType {
|
|
case .invite(let inviter): .invite(inviterDetails: inviter.map(RoomInviterDetails.init))
|
|
case .knock: .knock
|
|
case .none: .room
|
|
}
|
|
|
|
self.init(id: roomID,
|
|
roomID: summary.id,
|
|
type: type,
|
|
badges: .init(isDotShown: isDotShown,
|
|
notificationCount: summary.unreadNotificationsCount,
|
|
isMentionShown: isMentionShown,
|
|
isMuteShown: isMuteShown,
|
|
callBadgeType: callBadge),
|
|
name: summary.name,
|
|
isDirect: summary.isDirect,
|
|
isHighlighted: isHighlighted,
|
|
isFavourite: summary.isFavourite,
|
|
timestamp: summary.lastMessageDate?.formattedMinimal(),
|
|
lastMessage: summary.lastMessage,
|
|
lastMessageState: summary.homeScreenLastMessageState,
|
|
avatar: summary.avatar,
|
|
statusEmoji: summary.statusEmoji,
|
|
canonicalAlias: summary.canonicalAlias,
|
|
isTombstoned: summary.isTombstoned)
|
|
}
|
|
}
|
|
|
|
private extension RoomSummary {
|
|
var homeScreenLastMessageState: HomeScreenRoom.LastMessageState? {
|
|
if isTombstoned {
|
|
nil
|
|
} else {
|
|
switch lastMessageState {
|
|
case .sending: .sending
|
|
case .failed: .failed
|
|
case .none: .none
|
|
}
|
|
}
|
|
}
|
|
}
|