Files
element-x-ios/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.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

195 lines
5.8 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 Foundation
import MatrixRustSDK
/// A quick summary of a Room, useful to describe and give quick informations for the room list
nonisolated struct RoomSummary {
enum JoinRequestType {
case invite(inviter: RoomMemberProxyProtocol?)
case knock
var isInvite: Bool {
switch self {
case .invite: true
default: false
}
}
var isKnock: Bool {
switch self {
case .knock: true
default: false
}
}
}
enum LastMessageState { case sending, failed }
let room: Room
let id: String
let joinRequestType: JoinRequestType?
let name: String
let isDirect: Bool
let isSpace: Bool
let avatarURL: URL?
let heroes: [UserProfileProxy]
let activeMembersCount: UInt
let lastMessage: AttributedString?
let lastMessageDate: Date?
let lastMessageState: LastMessageState?
let unreadMessagesCount: UInt
let unreadMentionsCount: UInt
let unreadNotificationsCount: UInt
let notificationMode: RoomNotificationModeProxy?
let canonicalAlias: String?
let alternativeAliases: Set<String>
let hasOngoingCall: Bool
let activeCallIntent: CallIntent?
let isMarkedUnread: Bool
let isFavourite: Bool
let isTombstoned: Bool
var hasUnreadMessages: Bool {
unreadMessagesCount > 0
}
var hasUnreadMentions: Bool {
unreadMentionsCount > 0
}
var hasUnreadNotifications: Bool {
unreadNotificationsCount > 0
}
var isMuted: Bool {
notificationMode == .mute
}
}
extension RoomSummary: CustomStringConvertible {
var description: String {
"""
RoomSummary: - id: \(id) \
- isDirect: \(isDirect) \
- unreadMessagesCount: \(unreadMessagesCount) \
- unreadMentionsCount: \(unreadMentionsCount) \
- unreadNotificationsCount: \(unreadNotificationsCount) \
- notificationMode: \(notificationMode?.rawValue ?? "nil")
"""
}
/// Used where summaries are shown in a list e.g. message forwarding,
/// global search, share destination list etc.
var roomListDescription: String {
if isDirect {
return canonicalAlias ?? ""
}
if let alias = canonicalAlias {
return alias
}
guard heroes.count > 0 else {
return ""
}
var heroComponents = heroes.compactMap(\.displayName)
let othersCount = Int(activeMembersCount) - heroes.count
if othersCount > 0 {
heroComponents.append(L10n.commonManyMembers(othersCount))
}
return heroComponents.formatted(.list(type: .and))
}
}
extension RoomSummary {
/// An empty summary used when the room info is momentarily unavailable (e.g. while the client tears
/// down on logout). Keeps the diff indices aligned with the SDK instead of crashing.
nonisolated static func placeholder(room: Room) -> RoomSummary {
RoomSummary(room: room,
id: room.id(),
joinRequestType: nil,
name: room.id(),
isDirect: false,
isSpace: false,
avatarURL: nil,
heroes: [],
activeMembersCount: 0,
lastMessage: nil,
lastMessageDate: nil,
lastMessageState: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
activeCallIntent: nil,
isMarkedUnread: false,
isFavourite: false,
isTombstoned: false)
}
init(room: Room, id: String, settingsMode: RoomNotificationModeProxy, hasUnreadMessages: Bool, hasUnreadMentions: Bool, hasUnreadNotifications: Bool) {
self.room = room
self.id = id
let string = "\(settingsMode) - messages: \(hasUnreadMessages) - mentions: \(hasUnreadMentions) - notifications: \(hasUnreadNotifications)"
name = string
isDirect = true
isSpace = false
avatarURL = nil
heroes = []
activeMembersCount = 0
lastMessage = AttributedString(string)
lastMessageDate = .mock
lastMessageState = nil
unreadMessagesCount = hasUnreadMessages ? 1 : 0
unreadMentionsCount = hasUnreadMentions ? 1 : 0
unreadNotificationsCount = hasUnreadNotifications ? 1 : 0
notificationMode = settingsMode
canonicalAlias = nil
alternativeAliases = []
hasOngoingCall = false
activeCallIntent = nil
joinRequestType = nil
isMarkedUnread = false
isFavourite = false
isTombstoned = false
}
/// This doesn't have to work properly for DM invites, the heroes are always empty
var avatar: RoomAvatar {
guard !isTombstoned else {
return .tombstoned
}
if isSpace {
return .space(id: id, name: name, avatarURL: avatarURL)
} else if isDirect, avatarURL == nil, heroes.count == 1 {
return .heroes(heroes)
} else {
return .room(id: id, name: name, avatarURL: avatarURL)
}
}
}