mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 01:03:01 +00:00
f91b7441e7
* Suggest recently visited rooms when forwarding messages The Forward to screen now shows up to 5 recently visited rooms in a Suggestions section, above a Chats section that lists every room. The suggestions are never searched: while searching, only the Chats section is shown and filtered. The source room, invites, spaces and upgraded rooms are never suggested, and the section is hidden when there is nothing to suggest. The suggestions are resolved from the room summaries rather than room proxies, so they show up without holding back the list, and they are re-resolved as the room list loads after a cold start. ClientProxy now exposes the recently visited room IDs for this. The recently visited rooms filter is now async, so callers choose its isolation instead of asserting it at runtime. * Apply review suggestions to the forwarding suggestions Rooms: - Each room is listed once. Suggested rooms are left out of Chats unless searching, where Chats lists every result. This also removes the duplicated row whose selection changed without the tapped row's animation. - The Chats header is hidden when the section has no rooms. Suggestions: - Resolve them once when the screen opens instead of on every room list update.
153 lines
6.1 KiB
Swift
153 lines
6.1 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 Compound
|
|
import MatrixRustSDKMocks
|
|
import SwiftUI
|
|
|
|
struct MessageForwardingScreen: View {
|
|
@ObservedObject var context: MessageForwardingScreenViewModel.Context
|
|
|
|
var body: some View {
|
|
Form {
|
|
if context.viewState.showsSuggestions {
|
|
Section {
|
|
ForEach(context.viewState.suggestedRooms, content: row)
|
|
} header: {
|
|
Text(L10n.commonSuggestions)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
|
|
Section {
|
|
ForEach(context.viewState.chats, content: row)
|
|
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
|
|
} header: {
|
|
if !context.viewState.chats.isEmpty {
|
|
Text(L10n.commonChats)
|
|
.compoundListSectionHeader()
|
|
.onAppear {
|
|
context.send(viewAction: .reachedTop)
|
|
}
|
|
}
|
|
} footer: {
|
|
emptyRectangle
|
|
.onAppear {
|
|
context.send(viewAction: .reachedBottom)
|
|
}
|
|
}
|
|
}
|
|
.compoundList()
|
|
.navigationTitle(L10n.commonForwardTo)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L10n.actionCancel) {
|
|
context.send(viewAction: .cancel)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(L10n.actionSend) {
|
|
context.send(viewAction: .send)
|
|
}
|
|
.disabled(context.viewState.selectedRoomIDs.isEmpty)
|
|
}
|
|
}
|
|
.searchController(query: $context.searchQuery, showsCancelButton: false)
|
|
.compoundSearchField()
|
|
.disableAutocorrection(true)
|
|
}
|
|
|
|
private func row(for room: MessageForwardingRoom) -> some View {
|
|
let isSelected = context.viewState.selectedRoomIDs.contains(room.id)
|
|
|
|
return MessageForwardingListRow(room: room,
|
|
isSelected: isSelected,
|
|
isDisabled: context.viewState.isAtRoomSelectionLimit && !isSelected,
|
|
context: context)
|
|
}
|
|
|
|
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
|
|
private var emptyRectangle: some View {
|
|
Rectangle()
|
|
.frame(width: 0, height: 0)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct MessageForwardingListRow: View {
|
|
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
|
|
|
let room: MessageForwardingRoom
|
|
let isSelected: Bool
|
|
let isDisabled: Bool
|
|
let context: MessageForwardingScreenViewModel.Context
|
|
|
|
var body: some View {
|
|
ListRow(label: .avatar(title: room.title,
|
|
description: room.description,
|
|
icon: avatar),
|
|
kind: .selection(isSelected: isSelected) {
|
|
context.send(viewAction: .selectRoom(roomID: room.id))
|
|
})
|
|
.disabled(isDisabled)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var avatar: some View {
|
|
if dynamicTypeSize < .accessibility3 {
|
|
RoomAvatarImage(avatar: room.avatar,
|
|
avatarSize: .room(on: .messageForwarding),
|
|
mediaProvider: context.mediaProvider)
|
|
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct MessageForwardingScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let viewModel = makeViewModel()
|
|
static let searchingViewModel = makeViewModel(searchQuery: "Foundation")
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
MessageForwardingScreen(context: viewModel.context)
|
|
}
|
|
.previewDisplayName("Suggestions")
|
|
.snapshotPreferences(expect: viewModel.context.$viewState.map(\.showsSuggestions))
|
|
|
|
ElementNavigationStack {
|
|
MessageForwardingScreen(context: searchingViewModel.context)
|
|
}
|
|
.previewDisplayName("Searching")
|
|
.snapshotPreferences(expect: searchingViewModel.context.$viewState.map { !$0.suggestedRooms.isEmpty && !$0.showsSuggestions },
|
|
precision: 0.999) // The search field's clear button renders inconsistently in snapshots.
|
|
}
|
|
|
|
static func makeViewModel(searchQuery: String? = nil) -> MessageForwardingScreenViewModel {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
clientProxy.recentlyVisitedRoomIDsReturnValue = .success(["2", "5", "3"])
|
|
clientProxy.roomSummaryForIdentifierClosure = { roomID in [RoomSummary].mockRooms.first { $0.id == roomID } }
|
|
|
|
let viewModel = MessageForwardingScreenViewModel(forwardingPayload: .init(ids: [.randomEvent],
|
|
roomID: "",
|
|
contents: [RoomMessageEventContentWithoutRelationSDKMock()]),
|
|
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
userIndicatorController: UserIndicatorControllerMock())
|
|
|
|
if let searchQuery {
|
|
viewModel.context.searchQuery = searchQuery
|
|
}
|
|
|
|
return viewModel
|
|
}
|
|
}
|