Files
element-x-ios/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift
T
Mauro f2eaa8e80d Show the multi-account announcement on the HomeScreen (#6265)
* 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.
2026-10-07 12:29:51 +02:00

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