mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
7f1b6086ae
* 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.
319 lines
9.6 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|