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:
Stefan Ceriu
2023-06-21 12:10:14 +03:00
committed by GitHub
parent f624add018
commit 2ae8f471c6
36 changed files with 800 additions and 140 deletions
@@ -29,6 +29,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let emojiProvider: EmojiProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let stateMachine: StateMachine<State, Event> = .init(state: .initial)
@@ -46,12 +47,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
navigationSplitCoordinator: NavigationSplitCoordinator,
emojiProvider: EmojiProviderProtocol) {
emojiProvider: EmojiProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.userSession = userSession
self.roomTimelineControllerFactory = roomTimelineControllerFactory
self.navigationStackCoordinator = navigationStackCoordinator
self.navigationSplitCoordinator = navigationSplitCoordinator
self.emojiProvider = emojiProvider
self.userIndicatorController = userIndicatorController
setupStateMachine()
}
@@ -137,6 +140,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (.dismissEmojiPicker, .emojiPicker(let roomID, _)):
return .room(roomID: roomID)
case (.presentMessageForwarding(let itemID), .room(let roomID)):
return .messageForwarding(roomID: roomID, itemID: itemID)
case (.dismissMessageForwarding, .messageForwarding(let roomID, _)):
return .room(roomID: roomID)
default:
return nil
}
@@ -152,7 +160,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
dismissRoom(animated: animated)
presentRoom(roomID, animated: animated)
case (_, .presentRoom(let roomID), .room):
presentRoom(roomID, animated: animated)
let destinationRoomProxy = (context.userInfo as? EventUserInfo)?.destinationRoomProxy
presentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy)
case (.room, .dismissRoom, .initial):
dismissRoom(animated: animated)
@@ -199,6 +208,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (.roomMemberDetails, .dismissRoomMemberDetails, .room):
break
case (.room, .presentMessageForwarding(let eventID), .messageForwarding):
presentMessageForwarding(for: eventID)
case (.messageForwarding, .dismissMessageForwarding, .room):
break
default:
fatalError("Unknown transition: \(context)")
}
@@ -217,23 +231,39 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentRoom(_ roomID: String, animated: Bool) {
/// Updates the navigation stack so it displays the timeline for the given room
/// - Parameters:
/// - roomID: the identifier of the room that is to be presented
/// - animated: whether it should animate the transition
/// - destinationRoomProxy: an optional already build roomProxy for the target room. It is currently used when
/// forwarding messages so that we can take advantage of the local echo
/// and have the message already there when presenting the room
private func presentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) {
Task {
await asyncPresentRoom(roomID, animated: animated)
await asyncPresentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy)
}
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func asyncPresentRoom(_ roomID: String, animated: Bool) async {
private func asyncPresentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) async {
if let roomProxy, roomProxy.id == roomID {
navigationStackCoordinator.popToRoot()
return
}
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room identifier: \(roomID)")
stateMachine.tryEvent(.dismissRoom)
return
let roomProxy: RoomProxyProtocol
if let destinationRoomProxy {
roomProxy = destinationRoomProxy
} else {
guard let proxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room identifier: \(roomID)")
stateMachine.tryEvent(.dismissRoom)
return
}
roomProxy = proxy
}
actionsSubject.send(.presentedRoom(roomID))
@@ -277,6 +307,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID))
case .presentRoomMemberDetails(member: let member):
stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member)))
case .presentMessageForwarding(let itemID):
stateMachine.tryEvent(.presentMessageForwarding(itemID: itemID))
}
}
.store(in: &cancellables)
@@ -398,7 +430,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .cancel:
break
case .finish:
self?.showSuccess(label: L10n.commonReportSubmitted)
userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonReportSubmitted, iconName: "checkmark"))
}
}
navigationCoordinator.setRootCoordinator(coordinator)
@@ -492,9 +524,65 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
self?.stateMachine.tryEvent(.dismissRoomMemberDetails)
}
}
private func presentMessageForwarding(for eventID: String) {
guard let roomProxy, let roomSummaryProvider = userSession.clientProxy.roomSummaryProvider else {
fatalError()
}
let messageForwardingNavigationStackCoordinator = NavigationStackCoordinator()
let parameters = MessageForwardingScreenCoordinatorParameters(roomSummaryProvider: roomSummaryProvider,
sourceRoomID: roomProxy.id)
let coordinator = MessageForwardingScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .send(let roomID):
navigationStackCoordinator.setSheetCoordinator(nil)
Task {
await self.forward(eventID: eventID, toRoomID: roomID)
}
}
}.store(in: &cancellables)
messageForwardingNavigationStackCoordinator.setRootCoordinator(coordinator)
private func showSuccess(label: String) {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
navigationStackCoordinator.setSheetCoordinator(messageForwardingNavigationStackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissMessageForwarding)
}
}
private func forward(eventID: String, toRoomID roomID: String) async {
guard let roomProxy else {
MXLog.error("Failed retrieving current room with id: \(roomID)")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
guard let messageEventContent = roomProxy.messageEventContent(for: eventID) else {
MXLog.error("Failed retrieving forwarded message event content for eventID: \(eventID)")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
guard let targetRoomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Failed retrieving room to forward to with id: \(roomID)")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
if case .failure(let error) = await targetRoomProxy.sendMessageEventContent(messageEventContent) {
MXLog.error("Failed forwarding message with error: \(error)")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: true, destinationRoomProxy: targetRoomProxy))
}
}
@@ -521,10 +609,12 @@ private extension RoomFlowCoordinator {
case mediaUploadPreview(roomID: String, fileURL: URL)
case emojiPicker(roomID: String, itemID: String)
case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper)
case messageForwarding(roomID: String, itemID: String)
}
struct EventUserInfo {
let animated: Bool
var destinationRoomProxy: RoomProxyProtocol?
}
enum Event: EventType {
@@ -551,5 +641,8 @@ private extension RoomFlowCoordinator {
case presentRoomMemberDetails(member: HashableRoomMemberWrapper)
case dismissRoomMemberDetails
case presentMessageForwarding(itemID: String)
case dismissMessageForwarding
}
}
@@ -59,7 +59,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
roomTimelineControllerFactory: roomTimelineControllerFactory,
navigationStackCoordinator: detailNavigationStackCoordinator,
navigationSplitCoordinator: navigationSplitCoordinator,
emojiProvider: EmojiProvider())
emojiProvider: EmojiProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
setupStateMachine()
+15 -11
View File
@@ -466,6 +466,8 @@ public enum L10n {
public static var screenAccountProviderFormSubtitle: String { return L10n.tr("Localizable", "screen_account_provider_form_subtitle") }
/// Find an account provider
public static var screenAccountProviderFormTitle: String { return L10n.tr("Localizable", "screen_account_provider_form_title") }
/// This is where you conversations will live — just like you would use an email provider to keep your emails.
public static var screenAccountProviderSigninSubtitle: String { return L10n.tr("Localizable", "screen_account_provider_signin_subtitle") }
/// You’re about to sign in to %@
public static func screenAccountProviderSigninTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_account_provider_signin_title", String(describing: p1))
@@ -476,30 +478,26 @@ public enum L10n {
public static func screenAccountProviderSignupTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_account_provider_signup_title", String(describing: p1))
}
/// We <b>don't</b> record or profile any account data
/// We won't record or profile any personal data
public static var screenAnalyticsPromptDataUsage: String { return L10n.tr("Localizable", "screen_analytics_prompt_data_usage") }
/// Help us identify issues and improve %1$@ by sharing anonymous usage data.
public static func screenAnalyticsPromptHelpUsImprove(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_analytics_prompt_help_us_improve", String(describing: p1))
}
/// Share anonymous usage data to help us identify issues.
public static var screenAnalyticsPromptHelpUsImprove: String { return L10n.tr("Localizable", "screen_analytics_prompt_help_us_improve") }
/// You can read all our terms %1$@.
public static func screenAnalyticsPromptReadTerms(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_analytics_prompt_read_terms", String(describing: p1))
}
/// here
public static var screenAnalyticsPromptReadTermsContentLink: String { return L10n.tr("Localizable", "screen_analytics_prompt_read_terms_content_link") }
/// You can turn this off anytime in settings
/// You can turn this off anytime
public static var screenAnalyticsPromptSettings: String { return L10n.tr("Localizable", "screen_analytics_prompt_settings") }
/// We <b>don't</b> share information with third parties
/// We won't share your data with third parties
public static var screenAnalyticsPromptThirdPartySharing: String { return L10n.tr("Localizable", "screen_analytics_prompt_third_party_sharing") }
/// Help improve %1$@
public static func screenAnalyticsPromptTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_analytics_prompt_title", String(describing: p1))
}
/// Help us identify issues and improve %1$@ by sharing anonymous usage data.
public static func screenAnalyticsSettingsHelpUsImprove(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_analytics_settings_help_us_improve", String(describing: p1))
}
/// Share anonymous usage data to help us identify issues.
public static var screenAnalyticsSettingsHelpUsImprove: String { return L10n.tr("Localizable", "screen_analytics_settings_help_us_improve") }
/// You can read all our terms %1$@.
public static func screenAnalyticsSettingsReadTerms(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_analytics_settings_read_terms", String(describing: p1))
@@ -558,6 +556,8 @@ public enum L10n {
public static var screenChangeServerTitle: String { return L10n.tr("Localizable", "screen_change_server_title") }
/// New room
public static var screenCreateRoomActionCreateRoom: String { return L10n.tr("Localizable", "screen_create_room_action_create_room") }
/// Invite friends to Element
public static var screenCreateRoomActionInvitePeople: String { return L10n.tr("Localizable", "screen_create_room_action_invite_people") }
/// Invite people
public static var screenCreateRoomAddPeopleTitle: String { return L10n.tr("Localizable", "screen_create_room_add_people_title") }
/// An error occurred when creating the room
@@ -684,6 +684,8 @@ public enum L10n {
public static var screenRoomDetailsInvitePeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_invite_people_title") }
/// Leave room
public static var screenRoomDetailsLeaveRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_leave_room_title") }
/// Notification
public static var screenRoomDetailsNotificationTitle: String { return L10n.tr("Localizable", "screen_room_details_notification_title") }
/// People
public static var screenRoomDetailsPeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_people_title") }
/// Room name
@@ -724,6 +726,8 @@ public enum L10n {
public static var screenRoomMemberListPendingHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_pending_header_title") }
/// Room members
public static var screenRoomMemberListRoomMembersHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_room_members_header_title") }
/// You do not have permission to post to this room
public static var screenRoomNoPermissionToPost: String { return L10n.tr("Localizable", "screen_room_no_permission_to_post") }
/// Remove
public static var screenRoomRetrySendMenuRemoveAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_remove_action") }
/// Send again
@@ -457,6 +457,11 @@ class RoomProxyMock: RoomProxyProtocol {
set(value) { underlyingUpdatesPublisher = value }
}
var underlyingUpdatesPublisher: AnyPublisher<TimelineDiff, Never>!
var timelineProvider: RoomTimelineProviderProtocol {
get { return underlyingTimelineProvider }
set(value) { underlyingTimelineProvider = value }
}
var underlyingTimelineProvider: RoomTimelineProviderProtocol!
//MARK: - loadAvatarURLForUserId
@@ -500,23 +505,6 @@ class RoomProxyMock: RoomProxyProtocol {
return loadDisplayNameForUserIdReturnValue
}
}
//MARK: - registerTimelineListenerIfNeeded
var registerTimelineListenerIfNeededCallsCount = 0
var registerTimelineListenerIfNeededCalled: Bool {
return registerTimelineListenerIfNeededCallsCount > 0
}
var registerTimelineListenerIfNeededReturnValue: Result<[TimelineItem], RoomProxyError>!
var registerTimelineListenerIfNeededClosure: (() -> Result<[TimelineItem], RoomProxyError>)?
func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError> {
registerTimelineListenerIfNeededCallsCount += 1
if let registerTimelineListenerIfNeededClosure = registerTimelineListenerIfNeededClosure {
return registerTimelineListenerIfNeededClosure()
} else {
return registerTimelineListenerIfNeededReturnValue
}
}
//MARK: - paginateBackwards
var paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount = 0
@@ -559,6 +547,48 @@ class RoomProxyMock: RoomProxyProtocol {
return sendReadReceiptForReturnValue
}
}
//MARK: - messageEventContent
var messageEventContentForCallsCount = 0
var messageEventContentForCalled: Bool {
return messageEventContentForCallsCount > 0
}
var messageEventContentForReceivedEventID: String?
var messageEventContentForReceivedInvocations: [String] = []
var messageEventContentForReturnValue: RoomMessageEventContent?
var messageEventContentForClosure: ((String) -> RoomMessageEventContent?)?
func messageEventContent(for eventID: String) -> RoomMessageEventContent? {
messageEventContentForCallsCount += 1
messageEventContentForReceivedEventID = eventID
messageEventContentForReceivedInvocations.append(eventID)
if let messageEventContentForClosure = messageEventContentForClosure {
return messageEventContentForClosure(eventID)
} else {
return messageEventContentForReturnValue
}
}
//MARK: - sendMessageEventContent
var sendMessageEventContentCallsCount = 0
var sendMessageEventContentCalled: Bool {
return sendMessageEventContentCallsCount > 0
}
var sendMessageEventContentReceivedMessageContent: RoomMessageEventContent?
var sendMessageEventContentReceivedInvocations: [RoomMessageEventContent] = []
var sendMessageEventContentReturnValue: Result<Void, RoomProxyError>!
var sendMessageEventContentClosure: ((RoomMessageEventContent) async -> Result<Void, RoomProxyError>)?
func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result<Void, RoomProxyError> {
sendMessageEventContentCallsCount += 1
sendMessageEventContentReceivedMessageContent = messageContent
sendMessageEventContentReceivedInvocations.append(messageContent)
if let sendMessageEventContentClosure = sendMessageEventContentClosure {
return await sendMessageEventContentClosure(messageContent)
} else {
return sendMessageEventContentReturnValue
}
}
//MARK: - sendMessage
var sendMessageInReplyToCallsCount = 0
@@ -74,7 +74,6 @@ extension RoomProxyMock {
updateMembersClosure = { }
acceptInvitationClosure = { .success(()) }
registerTimelineListenerIfNeededClosure = { .success([]) }
underlyingUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher()
setNameClosure = { _ in .success(()) }
setTopicClosure = { _ in .success(()) }
+3
View File
@@ -76,12 +76,15 @@ enum UserAvatarSizeOnScreen {
enum RoomAvatarSizeOnScreen {
case timeline
case home
case messageForwarding
case details
var value: CGFloat {
switch self {
case .timeline:
return 32
case .messageForwarding:
return 36
case .home:
return 52
case .details:
@@ -23,7 +23,8 @@ struct FormRowAccessory: View {
enum Kind {
case navigationLink
case progressView
case selection(isSelected: Bool)
case singleSelection(isSelected: Bool)
case multipleSelection(isSelected: Bool)
}
let kind: Kind
@@ -36,8 +37,12 @@ struct FormRowAccessory: View {
.init(kind: .progressView)
}
static func selection(isSelected: Bool) -> Self {
.init(kind: .selection(isSelected: isSelected))
static func singleSelection(isSelected: Bool) -> Self {
.init(kind: .singleSelection(isSelected: isSelected))
}
static func multipleSelection(isSelected: Bool) -> Self {
.init(kind: .multipleSelection(isSelected: isSelected))
}
var body: some View {
@@ -48,7 +53,13 @@ struct FormRowAccessory: View {
.foregroundColor(.compound.iconQuaternary)
case .progressView:
ProgressView()
case .selection(let isSelected):
case .singleSelection(let isSelected):
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.compound.bodyLG)
.foregroundColor(isSelected && isEnabled ? .compound.iconPrimary : .compound.iconTertiary)
}
case .multipleSelection(let isSelected):
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.compound.bodyLG)
.foregroundColor(isSelected && isEnabled ? .compound.iconPrimary : .compound.iconTertiary)
@@ -94,7 +105,16 @@ struct FormButtonStyle: PrimitiveButtonStyle {
}
.contentShape(Rectangle())
.padding(FormRow.insets) // Re-apply the normal insets using padding.
.background(configuration.isPressed ? Color.compound.bgSubtlePrimary : .clear)
.background(backgroundColor(for: configuration))
}
private func backgroundColor(for configuration: Configuration) -> Color {
switch accessory?.kind {
case .none, .navigationLink, .progressView:
return configuration.isPressed ? .compound.bgSubtlePrimary : .element.formRowBackground
default:
return .element.formRowBackground
}
}
}
}
@@ -147,7 +167,7 @@ struct FormButtonStyles_Previews: PreviewProvider {
}
.formSectionStyle()
Section {
Section("Single selection") {
Button { } label: {
Text("Hello world")
}
@@ -156,18 +176,42 @@ struct FormButtonStyles_Previews: PreviewProvider {
Button { } label: {
Text("Selected")
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true)))
.buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: true)))
Button { } label: {
Text("Selected (disabled)")
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true)))
.buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: true)))
.disabled(true)
Button { } label: {
Text("Unselected")
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false)))
.buttonStyle(FormButtonStyle(accessory: .singleSelection(isSelected: false)))
}
.formSectionStyle()
Section("Multiple selection") {
Button { } label: {
Text("Hello world")
}
.buttonStyle(FormButtonStyle())
Button { } label: {
Text("Selected")
}
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true)))
Button { } label: {
Text("Selected (disabled)")
}
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true)))
.disabled(true)
Button { } label: {
Text("Unselected")
}
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false)))
}
.formSectionStyle()
@@ -97,33 +97,33 @@ struct UserProfileCell_Previews: PreviewProvider {
Button(action: action) {
UserProfileCell(user: .mockAlice, membership: nil, imageProvider: MockMediaProvider())
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true)))
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true)))
.previewDisplayName("Selected user")
Button(action: action) {
UserProfileCell(user: .mockBob, membership: nil, imageProvider: MockMediaProvider())
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false)))
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false)))
.previewDisplayName("Unselected user")
Button(action: action) {
UserProfileCell(user: .mockCharlie, membership: .join, imageProvider: MockMediaProvider())
}
.disabled(true)
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true)))
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true)))
.previewDisplayName("Selected disabled user")
Button(action: action) {
UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: .join, imageProvider: MockMediaProvider())
}
.disabled(true)
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: true)))
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: true)))
.previewDisplayName("Unverified joined user")
Button(action: action) {
UserProfileCell(user: .init(userID: "@someone:matrix.org"), membership: nil, imageProvider: MockMediaProvider())
}
.buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false)))
.buttonStyle(FormButtonStyle(accessory: .multipleSelection(isSelected: false)))
.previewDisplayName("Unverified user")
}
}
@@ -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 {
@@ -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?
}
@@ -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")
}
}
@@ -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")
@@ -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}"
+47 -30
View File
@@ -46,6 +46,11 @@ class RoomProxy: RoomProxyProtocol {
updatesSubject.eraseToAnyPublisher()
}
var innerTimelineProvider: RoomTimelineProviderProtocol!
var timelineProvider: RoomTimelineProviderProtocol {
innerTimelineProvider
}
deinit {
Task { @MainActor [roomTimelineObservationToken, roomListItem] in
roomTimelineObservationToken?.cancel()
@@ -59,6 +64,30 @@ class RoomProxy: RoomProxyProtocol {
self.roomListItem = roomListItem
self.room = room
self.backgroundTaskService = backgroundTaskService
let settings = RoomSubscription(requiredState: [RequiredState(key: "m.room.name", value: ""),
RequiredState(key: "m.room.topic", value: ""),
RequiredState(key: "m.room.avatar", value: ""),
RequiredState(key: "m.room.canonical_alias", value: ""),
RequiredState(key: "m.room.join_rules", value: "")],
timelineLimit: UInt32(SlidingSyncConstants.defaultTimelineLimit))
roomListItem.subscribe(settings: settings)
let timelineListener = RoomTimelineListener { [weak self] timelineDiff in
self?.updatesSubject.send(timelineDiff)
}
self.timelineListener = timelineListener
let result = room.addTimelineListener(listener: timelineListener)
roomTimelineObservationToken = result.itemsStream
innerTimelineProvider = RoomTimelineProvider(currentItems: result.items, updatePublisher: updatesPublisher)
Task {
await fetchMembers()
await updateMembers()
}
}
lazy var id: String = room.id()
@@ -162,36 +191,6 @@ class RoomProxy: RoomProxyProtocol {
}
}
func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError> {
guard timelineListener == nil else {
return .failure(.roomListenerAlreadyRegistered)
}
let settings = RoomSubscription(requiredState: [RequiredState(key: "m.room.name", value: ""),
RequiredState(key: "m.room.topic", value: ""),
RequiredState(key: "m.room.avatar", value: ""),
RequiredState(key: "m.room.canonical_alias", value: ""),
RequiredState(key: "m.room.join_rules", value: "")],
timelineLimit: UInt32(SlidingSyncConstants.defaultTimelineLimit))
roomListItem.subscribe(settings: settings)
let timelineListener = RoomTimelineListener { [weak self] timelineDiff in
self?.updatesSubject.send(timelineDiff)
}
self.timelineListener = timelineListener
let result = room.addTimelineListener(listener: timelineListener)
roomTimelineObservationToken = result.itemsStream
Task {
await fetchMembers()
await updateMembers()
}
return .success(result.items)
}
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomProxyError> {
do {
try await Task.dispatch(on: .global()) {
@@ -219,6 +218,24 @@ class RoomProxy: RoomProxyProtocol {
}
}
}
func messageEventContent(for eventID: String) -> RoomMessageEventContent? {
try? room.getTimelineEventContentByEventId(eventId: eventID)
}
func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
let transactionId = genTransactionId()
return await Task.dispatch(on: userInitiatedDispatchQueue) {
self.room.send(msg: messageContent, txnId: transactionId)
return .success(())
}
}
func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
@@ -20,7 +20,6 @@ import MatrixRustSDK
enum RoomProxyError: Error {
case noMoreMessagesToBackPaginate
case roomListenerAlreadyRegistered
case failedPaginatingBackwards
case failedRetrievingMemberAvatarURL
case failedRetrievingMemberDisplayName
@@ -73,22 +72,23 @@ protocol RoomProxyProtocol {
var activeMembersCount: Int { get }
/// Publishes the room's updates.
/// The publisher starts publishing after the first call to `registerTimelineListenerIfNeeded()`
/// The thread on which this publisher sends the output isn't defined.
var updatesPublisher: AnyPublisher<TimelineDiff, Never> { get }
var timelineProvider: RoomTimelineProviderProtocol { get }
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
/// Registers a timeline listener if not registered already.
/// Updates for this object will be published on the `updatesPublisher` publisher.
func registerTimelineListenerIfNeeded() -> Result<[TimelineItem], RoomProxyError>
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomProxyError>
func sendReadReceipt(for eventID: String) async -> Result<Void, RoomProxyError>
func messageEventContent(for eventID: String) -> RoomMessageEventContent?
func sendMessageEventContent(_ messageContent: RoomMessageEventContent) async -> Result<Void, RoomProxyError>
func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError>
func sendReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError>
@@ -19,7 +19,6 @@ import Foundation
import MatrixRustSDK
class RoomTimelineProvider: RoomTimelineProviderProtocol {
private let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
private let serialDispatchQueue: DispatchQueue
@@ -35,27 +34,15 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}
}
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
init(currentItems: [TimelineItem], updatePublisher: AnyPublisher<TimelineDiff, Never>) {
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
itemProxies = []
itemProxies = currentItems.map(TimelineItemProxy.init)
roomProxy
.updatesPublisher
updatePublisher
.collect(.byTime(serialDispatchQueue, 0.1))
.sink { [weak self] in self?.updateItemsWithDiffs($0) }
.store(in: &cancellables)
switch roomProxy.registerTimelineListenerIfNeeded() {
case let .success(items):
itemProxies = items.map(TimelineItemProxy.init)
MXLog.verbose("Added timeline listener, current items (\(items.count)) : \(items.map(\.debugIdentifier))")
case .failure(.roomListenerAlreadyRegistered):
MXLog.verbose("Listener already registered for the room: \(roomProxy.id)")
case .failure:
let roomID = roomProxy.id
MXLog.error("Failed adding timeline listener on room with identifier: \(roomID)")
}
}
// MARK: - Private
@@ -68,7 +68,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func debugInfo(for itemID: String) -> TimelineItemDebugInfo {
.init(model: "Mock debug description", originalJSON: nil, latestEditJSON: nil)
}
func retryDecryption(for sessionID: String) async { }
// MARK: - UI Test signalling
@@ -43,17 +43,16 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
init(userId: String,
roomProxy: RoomProxyProtocol,
timelineProvider: RoomTimelineProviderProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) {
self.userId = userId
self.timelineProvider = timelineProvider
self.roomProxy = roomProxy
timelineProvider = roomProxy.timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
self.roomProxy = roomProxy
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
self.timelineProvider
timelineProvider
.itemsPublisher
.receive(on: serialDispatchQueue)
.sink { [weak self] _ in
@@ -23,7 +23,6 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
RoomTimelineController(userId: userId,
roomProxy: roomProxy,
timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider)
}