mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Fixes #978 - Message forwarding support
* Add support for message forwarding * Move the TimelineProvider within the RoomProxy to avoid it going out of sync and have it automatically register for timeline updates on init * Implement single selection form button style for message forwarding * Add searching and filter out the source room from the list * Fixed emoji screen initial search bar height * Add view models tests * Add changelog * Update strings * Tweaks following code review
This commit is contained in:
@@ -43,7 +43,7 @@ struct AnalyticsPromptScreenStrings {
|
||||
let point3 = L10n.screenAnalyticsPromptSettings
|
||||
|
||||
init(termsURL: URL) {
|
||||
let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove(InfoPlistReader.main.bundleDisplayName))
|
||||
let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove)
|
||||
|
||||
// Create the 'read terms' with a placeholder.
|
||||
let linkPlaceholder = "{link}"
|
||||
|
||||
@@ -45,7 +45,7 @@ struct EmojiPickerScreen: View {
|
||||
.navigationTitle(L10n.commonReactions)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.searchable(text: $searchString)
|
||||
.searchable(text: $searchString, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.compoundSearchField()
|
||||
.onChange(of: searchString) { _ in
|
||||
context.send(viewAction: .search(searchString: searchString))
|
||||
|
||||
@@ -53,7 +53,7 @@ struct HomeScreenRoomCell: View {
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.roomName(room.name))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ViewBuilder @MainActor
|
||||
var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
LoadableAvatarImage(url: room.avatarURL,
|
||||
|
||||
@@ -79,7 +79,7 @@ struct InviteUsersScreen: View {
|
||||
imageProvider: context.imageProvider)
|
||||
}
|
||||
.disabled(context.viewState.isUserDisabled(user))
|
||||
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user))))
|
||||
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: context.viewState.isUserSelected(user))))
|
||||
}
|
||||
} header: {
|
||||
if let title = context.viewState.usersSection.title {
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MessageForwardingScreenCoordinatorParameters {
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
let sourceRoomID: String
|
||||
}
|
||||
|
||||
enum MessageForwardingScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case send(roomID: String)
|
||||
}
|
||||
|
||||
final class MessageForwardingScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: MessageForwardingScreenCoordinatorParameters
|
||||
private var viewModel: MessageForwardingScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<MessageForwardingScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
|
||||
var actions: AnyPublisher<MessageForwardingScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: MessageForwardingScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = MessageForwardingScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
|
||||
sourceRoomID: parameters.sourceRoomID)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.actionsSubject.send(.dismiss)
|
||||
case .send(let roomID):
|
||||
self?.actionsSubject.send(.send(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(MessageForwardingScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MessageForwardingScreenViewModelAction {
|
||||
case dismiss
|
||||
case send(roomID: String)
|
||||
}
|
||||
|
||||
struct MessageForwardingScreenViewState: BindableState {
|
||||
var rooms: [MessageForwardingRoom] = []
|
||||
var selectedRoomID: String?
|
||||
|
||||
var visibleRooms: [MessageForwardingRoom] {
|
||||
if bindings.searchQuery.isEmpty {
|
||||
return rooms
|
||||
}
|
||||
|
||||
return rooms.filter { $0.name.localizedStandardContains(bindings.searchQuery) }
|
||||
}
|
||||
|
||||
var bindings = MessageForwardingScreenViewStateBindings()
|
||||
}
|
||||
|
||||
struct MessageForwardingScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
}
|
||||
|
||||
enum MessageForwardingScreenViewAction {
|
||||
case cancel
|
||||
case send
|
||||
case selectRoom(roomID: String)
|
||||
}
|
||||
|
||||
struct MessageForwardingRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let alias: String?
|
||||
let avatarURL: URL?
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias MessageForwardingScreenViewModelType = StateStoreViewModel<MessageForwardingScreenViewState, MessageForwardingScreenViewAction>
|
||||
|
||||
class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, MessageForwardingScreenViewModelProtocol {
|
||||
private let roomSummaryProvider: RoomSummaryProviderProtocol?
|
||||
private let sourceRoomID: String
|
||||
|
||||
private var actionsSubject: PassthroughSubject<MessageForwardingScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<MessageForwardingScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
||||
sourceRoomID: String) {
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
self.sourceRoomID = sourceRoomID
|
||||
|
||||
super.init(initialViewState: MessageForwardingScreenViewState())
|
||||
|
||||
roomSummaryProvider.roomListPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateRooms()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
updateRooms()
|
||||
}
|
||||
|
||||
override func process(viewAction: MessageForwardingScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .send:
|
||||
guard let roomID = state.selectedRoomID else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
actionsSubject.send(.send(roomID: roomID))
|
||||
case .selectRoom(let roomID):
|
||||
state.selectedRoomID = roomID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateRooms() {
|
||||
guard let roomSummaryProvider else {
|
||||
MXLog.error("Room summary provider unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
MXLog.info("Updating rooms")
|
||||
|
||||
var rooms = [MessageForwardingRoom]()
|
||||
|
||||
for summary in roomSummaryProvider.roomListPublisher.value {
|
||||
switch summary {
|
||||
case .empty, .invalidated:
|
||||
continue
|
||||
case .filled(let details):
|
||||
if details.id == sourceRoomID {
|
||||
continue
|
||||
}
|
||||
|
||||
let room = MessageForwardingRoom(id: details.id, name: details.name, alias: details.canonicalAlias, avatarURL: details.avatarURL)
|
||||
rooms.append(room)
|
||||
}
|
||||
}
|
||||
|
||||
state.rooms = rooms
|
||||
|
||||
MXLog.info("Finished updating rooms")
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol MessageForwardingScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<MessageForwardingScreenViewModelAction, Never> { get }
|
||||
var context: MessageForwardingScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MessageForwardingScreen: View {
|
||||
@ObservedObject var context: MessageForwardingScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
MessageForwardingRoomCell(room: room, context: context)
|
||||
.buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: context.viewState.selectedRoomID == room.id)))
|
||||
}
|
||||
}
|
||||
.formSectionStyle()
|
||||
}
|
||||
.listRowSeparator(.automatic)
|
||||
.elementFormStyle()
|
||||
.navigationTitle(L10n.commonForwardMessage)
|
||||
.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.selectedRoomID == nil)
|
||||
}
|
||||
}
|
||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.compoundSearchField()
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageForwardingRoomCell: View {
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
let room: MessageForwardingRoom
|
||||
let context: MessageForwardingScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
context.send(viewAction: .selectRoom(roomID: room.id))
|
||||
} label: {
|
||||
HStack(spacing: 16.0) {
|
||||
avatar
|
||||
|
||||
VStack(alignment: .leading, spacing: 2.0) {
|
||||
Text(room.name)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(room.alias ?? room.id)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder @MainActor
|
||||
var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
LoadableAvatarImage(url: room.avatarURL,
|
||||
name: room.name,
|
||||
contentID: room.id,
|
||||
avatarSize: .room(on: .messageForwarding),
|
||||
imageProvider: context.imageProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct MessageForwardingScreen_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms))
|
||||
let viewModel = MessageForwardingScreenViewModel(roomSummaryProvider: summaryProvider, sourceRoomID: "")
|
||||
|
||||
NavigationStack {
|
||||
MessageForwardingScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,13 +89,6 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
// MARK: - Private
|
||||
|
||||
private func setupRoomSubscription() {
|
||||
switch roomProxy.registerTimelineListenerIfNeeded() {
|
||||
case .success, .failure(.roomListenerAlreadyRegistered):
|
||||
break
|
||||
case .failure:
|
||||
MXLog.error("Failed to register a room listener in room's details for the room \(roomProxy.id)")
|
||||
}
|
||||
|
||||
roomProxy.updatesPublisher
|
||||
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] _ in
|
||||
|
||||
@@ -32,6 +32,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentRoomDetails
|
||||
case presentEmojiPicker(itemID: String)
|
||||
case presentRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
case presentMessageForwarding(itemID: String)
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -54,6 +55,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func start() {
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
@@ -77,6 +79,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
|
||||
case .displayRoomMemberDetails(let member):
|
||||
actionsSubject.send(.presentRoomMemberDetails(member: member))
|
||||
case .displayMessageForwarding(let itemID):
|
||||
actionsSubject.send(.presentMessageForwarding(itemID: itemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ enum RoomScreenViewModelAction {
|
||||
case displayDocumentPicker
|
||||
case displayMediaUploadPreviewScreen(url: URL)
|
||||
case displayRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
case displayMessageForwarding(itemID: String)
|
||||
}
|
||||
|
||||
enum RoomScreenComposerMode: Equatable {
|
||||
|
||||
@@ -330,12 +330,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
var actions: [TimelineItemMenuAction] = [
|
||||
.reply, .copyPermalink
|
||||
.reply
|
||||
]
|
||||
|
||||
if timelineItem is EventBasedMessageTimelineItemProtocol {
|
||||
actions.append(contentsOf: [.copy, .quote])
|
||||
actions.append(contentsOf: [.forward(itemID: itemId), .copy, .quote])
|
||||
}
|
||||
|
||||
actions.append(.copyPermalink)
|
||||
|
||||
if item.isEditable {
|
||||
actions.append(.edit)
|
||||
@@ -403,6 +405,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: buildReplyContent(for: eventTimelineItem))
|
||||
|
||||
state.composerMode = .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails)
|
||||
case .forward(let itemID):
|
||||
callback?(.displayMessageForwarding(itemID: itemID))
|
||||
case .viewSource:
|
||||
let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id)
|
||||
MXLog.info(debugInfo)
|
||||
|
||||
@@ -37,6 +37,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case copyPermalink
|
||||
case redact
|
||||
case reply
|
||||
case forward(itemID: String)
|
||||
case viewSource
|
||||
case retryDecryption(sessionID: String)
|
||||
case report
|
||||
@@ -193,6 +194,10 @@ public struct TimelineItemMenu: View {
|
||||
Button { send(action) } label: {
|
||||
MenuLabel(title: L10n.actionReply, systemImageName: "arrowshape.turn.up.left")
|
||||
}
|
||||
case .forward:
|
||||
Button { send(action) } label: {
|
||||
MenuLabel(title: L10n.actionForward, systemImageName: "arrowshape.turn.up.right")
|
||||
}
|
||||
case .redact:
|
||||
Button(role: .destructive) { send(action) } label: {
|
||||
MenuLabel(title: L10n.actionRemove, systemImageName: "trash")
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ struct AnalyticsSettingsScreenStrings {
|
||||
let sectionFooter: AttributedString
|
||||
|
||||
init(termsURL: URL) {
|
||||
let content = AttributedString(L10n.screenAnalyticsSettingsHelpUsImprove(InfoPlistReader.main.bundleDisplayName))
|
||||
let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove)
|
||||
|
||||
// Create the 'read terms' with a placeholder.
|
||||
let linkPlaceholder = "{link}"
|
||||
|
||||
Reference in New Issue
Block a user