Files
element-x-ios/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift
T
Stefan Ceriu 7f1b6086ae Search tab (#5754)
* Add a developer flag for the search tab
Introduces a globalSearchEnabled user preference (off by default) with a toggle in Developer Options, used to gate the new search experience.

* Support search role tabs in the tab coordinator
Migrates the NavigationTabCoordinator to SwiftUI's value-based Tab API and adds an isSearch option so a tab can adopt the iOS 26 .search role, which detaches it into the trailing search button in the tab bar.

* Moves room search to a new screen
Adds a new screen that filters the alternate room summary provider as you type, shows an empty state, and lists matching rooms with their matrix identifier. Supports hardware keyboard selection (arrow keys to move through results, return to open) by reaching into the system search field.

* Present search in a dedicated tab
Adds the search tab to the chats flow on iOS 26 when the flag is enabled, rendered as the detached .search role button. The Cmd+K shortcut now selects this tab, and the home screen's inline search is hidden while the tab is available.

* Remove the old global search screen
The dedicated search tab replaces the Cmd+K floating window, so the GlobalSearchScreen, its window plumbing in the WindowManager, and its presentation in the chats flow are all removed.
2026-06-19 13:50:17 +03:00

319 lines
9.6 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 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 {
case skeletons
case empty
case rooms
var description: String {
switch self {
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 {
let userID: String
var userDisplayName: String?
var userAvatarURL: URL?
var securityBannerMode = HomeScreenSecurityBannerMode.none
var shouldShowNewSoundBanner = false
var requiresExtraAccountSetup = false
var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .skeletons
var hasPendingInvitations = false
var selectedRoomID: String?
var hideInviteAvatars = false
var roomListActivityVisibility: RoomListActivityVisibility = .current
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 {
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?
}
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 isMentionShown: Bool
let isMuteShown: Bool
let callBadgeType: CallBadgeType
}
var hasUnreads = false
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 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, 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),
canonicalAlias: nil,
isTombstoned: false)
}
}
extension HomeScreenRoom {
init(summary: RoomSummary,
roomListActivityVisibility: RoomListActivityVisibility = .current,
seenInvites: Set<String> = []) {
let roomID = summary.id
let isUnseenInvite = summary.joinRequestType?.isInvite == true && !seenInvites.contains(roomID)
let isDotShown = switch roomListActivityVisibility {
case .current:
summary.hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || isUnseenInvite
case .hide, .show:
(!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,
isMentionShown: isMentionShown,
isMuteShown: isMuteShown,
callBadgeType: callBadge),
hasUnreads: summary.hasUnreadMessages,
name: summary.name,
isDirect: summary.isDirect,
isHighlighted: isHighlighted,
isFavourite: summary.isFavourite,
timestamp: summary.lastMessageDate?.formattedMinimal(),
lastMessage: summary.lastMessage,
lastMessageState: summary.homeScreenLastMessageState,
avatar: summary.avatar,
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
}
}
}
}