Media uploading (#851)

* MediaUploadingPreprocessor - Prevent images without any GPS metadata from being changed

* Add support for sending images

* Add support for sending video, audio and file attachments

* Rename MediaPickerPreview to MediaUploadPreview

* Move media uploading to the MediaUploadPreviewScreen, add waiting indicators and error handling

* Add support for pasting and drag&dropping in media for upload

* Adopt new media picker source UI, remove developer flag

* Set minimum heights for timeline loadable images

* Fix invalid camera picker file names

* Fix flakey MediaUploadingPreprocessor image tests, improve gps metadata stripping

* UITests: Update existing screenshots and add new step for the room attachment picker

* Switch all github action runners to macos-13

* Cleanup enter key and paste message composer handlers
This commit is contained in:
Stefan Ceriu 2023-05-04 16:09:29 +03:00 committed by GitHub
parent e9c07c9f8f
commit 93e3a1a80b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 809 additions and 393 deletions

View File

@ -9,7 +9,7 @@ on:
jobs:
integration_tests:
name: Integration Tests
runs-on: macos-12
runs-on: macos-13
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.

View File

@ -11,7 +11,7 @@ jobs:
if: contains(github.event.pull_request.labels.*.name, 'Trigger-PR-Build')
name: Release
runs-on: macos-12
runs-on: macos-13
concurrency:
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.

View File

@ -7,7 +7,7 @@ on:
jobs:
open-translations-pr:
runs-on: macos-12
runs-on: macos-13
# Skip in forks
if: github.repository == 'vector-im/element-x-ios'
steps:

View File

@ -9,7 +9,7 @@ on:
jobs:
tests:
name: Tests
runs-on: macos-12
runs-on: macos-13
concurrency:
# When running on develop, use the sha to allow all runs of this workflow to run concurrently.

View File

@ -11,7 +11,7 @@ on:
jobs:
tests:
name: Tests
runs-on: macos-12
runs-on: macos-13
concurrency:
# When running on develop, use the sha to allow all runs of this workflow to run concurrently.

View File

@ -138,7 +138,6 @@
"notification_unread_notified_messages_in_room" = "%1$@ in %2$@";
"notification_unread_notified_messages_in_room_and_invitation" = "%1$@ in %2$@ and %3$@";
"preference_rageshake" = "Rageshake to report bug";
"rageshake_detection_dialog_content" = "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?";
"rageshake_dialog_content" = "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?";
"report_content_explanation" = "This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages.";
"report_content_hint" = "Reason for reporting this content";
@ -177,7 +176,6 @@
"screen_bug_report_include_logs" = "Send logs to help";
"screen_bug_report_include_screenshot" = "Send screenshot";
"screen_bug_report_logs_description" = "To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting.";
"screen_bug_report_rash_logs_alert_title" = "%1$@ crashed the last time it was used. Would you like to share a crash report with us?";
"screen_change_server_error_invalid_homeserver" = "We couldn't reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help.";
"screen_change_server_error_no_sliding_sync_message" = "This server currently doesnt support sliding sync.";
"screen_change_server_form_header" = "Homeserver URL";
@ -193,7 +191,6 @@
"screen_create_room_public_option_title" = "Public room (anyone)";
"screen_create_room_room_name_label" = "Room name";
"screen_create_room_room_name_placeholder" = "e.g. Product Sprint";
"screen_create_room_title" = "Create a room";
"screen_create_room_topic_label" = "Topic (optional)";
"screen_create_room_topic_placeholder" = "What is this room about?";
"screen_invites_decline_chat_message" = "Are you sure you want to decline joining %1$@?";
@ -214,9 +211,13 @@
"screen_onboarding_welcome_subtitle" = "Welcome to the %1$@ Beta. Supercharged, for speed and simplicity.";
"screen_onboarding_welcome_title" = "Be in your Element";
"screen_report_content_block_user_hint" = "Check if you want to hide all current and future messages from this user";
"screen_room_attachment_source_camera" = "Camera";
"screen_room_attachment_source_camera_photo" = "Take photo";
"screen_room_attachment_source_camera_video" = "Record a video";
"screen_room_attachment_source_files" = "Attachment";
"screen_room_attachment_source_gallery" = "Photo & Video Library";
"screen_room_details_encryption_enabled_subtitle" = "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them.";
"screen_room_details_encryption_enabled_title" = "Message encryption enabled";
"screen_room_details_invite_people_title" = "Invite people";
"screen_room_details_share_room_title" = "Share room";
"screen_room_member_details_block_alert_action" = "Block";
"screen_room_member_details_block_alert_description" = "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime.";
@ -241,10 +242,8 @@
"screen_session_verification_waiting_to_accept_subtitle" = "Accept the request to start the verification process in your other session to continue.";
"screen_session_verification_waiting_to_accept_title" = "Waiting to accept request";
"screen_signout_confirmation_dialog_content" = "Are you sure you want to sign out?";
"screen_signout_confirmation_dialog_submit" = "Sign out";
"screen_signout_confirmation_dialog_title" = "Sign out";
"screen_signout_in_progress_dialog_content" = "Signing out…";
"screen_signout_preference_item" = "Sign out";
"screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat";
"screen_start_chat_unknown_profile" = "We cant validate this users Matrix ID. The invite might not be received.";
"session_verification_banner_message" = "Looks like youre using a new device. Verify its you to access your encrypted messages.";
@ -312,11 +311,14 @@
"dialog_title_error" = "Error";
"dialog_title_success" = "Success";
"notification_room_action_quick_reply" = "Quick reply";
"rageshake_detection_dialog_content" = "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?";
"screen_analytics_settings_help_us_improve" = "Help us identify issues and improve %1$@ by sharing anonymous usage data.";
"screen_analytics_settings_read_terms" = "You can read all our terms %1$@.";
"screen_analytics_settings_read_terms_content_link" = "here";
"screen_bug_report_rash_logs_alert_title" = "%1$@ crashed the last time it was used. Would you like to share a crash report with us?";
"screen_change_server_submit" = "Continue";
"screen_change_server_title" = "Select your server";
"screen_create_room_title" = "Create a room";
"screen_dm_details_block_alert_action" = "Block";
"screen_dm_details_block_alert_description" = "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime.";
"screen_dm_details_block_user" = "Block user";
@ -328,9 +330,13 @@
"screen_login_submit" = "Continue";
"screen_login_username_hint" = "Username";
"screen_report_content_block_user" = "Block user";
"screen_room_details_invite_people_title" = "Invite people";
"screen_room_details_leave_room_title" = "Leave room";
"screen_room_details_people_title" = "People";
"screen_room_details_security_title" = "Security";
"screen_room_details_topic_title" = "Topic";
"screen_room_error_failed_processing_media" = "Failed processing media to upload, please try again.";
"screen_session_verification_cancelled_title" = "Verification cancelled";
"screen_session_verification_positive_button_ready" = "Start";
"screen_signout_confirmation_dialog_submit" = "Sign out";
"screen_signout_preference_item" = "Sign out";

View File

@ -15,11 +15,3 @@
"soft_logout_clear_data_submit" = "Clear all data";
"soft_logout_clear_data_dialog_title" = "Clear data";
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
// MARK: - Media upload
"media_upload_camera_picker" = "Camera";
"media_upload_photo_and_video_picker" = "Photo & Video Library";
"media_upload_document_picker" = "Document";

View File

@ -339,7 +339,7 @@ class AppCoordinator: AppCoordinatorProtocol {
// MARK: Toasts and loading indicators
static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
private static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
private func showLoadingIndicator() {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,

View File

@ -29,7 +29,6 @@ final class AppSettings {
case shouldCollapseRoomStateEvents
case startChatFlowEnabled
case startChatUserSuggestionsEnabled
case mediaUploadingFlowEnabled
case invitesFlowEnabled
}
@ -166,12 +165,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.startChatUserSuggestionsEnabled, defaultValue: false, storageType: .volatile)
var startChatUserSuggestionsEnabled
// MARK: Media Uploading
@UserPreference(key: UserDefaultsKeys.mediaUploadingFlowEnabled, defaultValue: false, storageType: .volatile)
var mediaUploadingFlowEnabled
// MARK: Invites
@UserPreference(key: UserDefaultsKeys.invitesFlowEnabled, defaultValue: false, storageType: .userDefaults(store))

View File

@ -10,12 +10,6 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
public enum UntranslatedL10n {
/// Camera
public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") }
/// Document
public static var mediaUploadDocumentPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_document_picker") }
/// Photo & Video Library
public static var mediaUploadPhotoAndVideoPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_photo_and_video_picker") }
/// Clear all data currently stored on this device?
/// Sign in again to access your account data and messages.
public static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }

View File

@ -556,6 +556,16 @@ public enum L10n {
public static var screenReportContentBlockUser: String { return L10n.tr("Localizable", "screen_report_content_block_user") }
/// Check if you want to hide all current and future messages from this user
public static var screenReportContentBlockUserHint: String { return L10n.tr("Localizable", "screen_report_content_block_user_hint") }
/// Camera
public static var screenRoomAttachmentSourceCamera: String { return L10n.tr("Localizable", "screen_room_attachment_source_camera") }
/// Take photo
public static var screenRoomAttachmentSourceCameraPhoto: String { return L10n.tr("Localizable", "screen_room_attachment_source_camera_photo") }
/// Record a video
public static var screenRoomAttachmentSourceCameraVideo: String { return L10n.tr("Localizable", "screen_room_attachment_source_camera_video") }
/// Attachment
public static var screenRoomAttachmentSourceFiles: String { return L10n.tr("Localizable", "screen_room_attachment_source_files") }
/// Photo & Video Library
public static var screenRoomAttachmentSourceGallery: String { return L10n.tr("Localizable", "screen_room_attachment_source_gallery") }
/// Messages are secured with locks. Only you and the recipients have the unique keys to unlock them.
public static var screenRoomDetailsEncryptionEnabledSubtitle: String { return L10n.tr("Localizable", "screen_room_details_encryption_enabled_subtitle") }
/// Message encryption enabled
@ -572,6 +582,8 @@ public enum L10n {
public static var screenRoomDetailsShareRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_share_room_title") }
/// Topic
public static var screenRoomDetailsTopicTitle: String { return L10n.tr("Localizable", "screen_room_details_topic_title") }
/// Failed processing media to upload, please try again.
public static var screenRoomErrorFailedProcessingMedia: String { return L10n.tr("Localizable", "screen_room_error_failed_processing_media") }
/// Block
public static var screenRoomMemberDetailsBlockAlertAction: String { return L10n.tr("Localizable", "screen_room_member_details_block_alert_action") }
/// Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime.

View File

@ -595,6 +595,69 @@ class RoomProxyMock: RoomProxyProtocol {
return sendImageUrlThumbnailURLImageInfoReturnValue
}
}
//MARK: - sendVideo
var sendVideoUrlThumbnailURLVideoInfoCallsCount = 0
var sendVideoUrlThumbnailURLVideoInfoCalled: Bool {
return sendVideoUrlThumbnailURLVideoInfoCallsCount > 0
}
var sendVideoUrlThumbnailURLVideoInfoReceivedArguments: (url: URL, thumbnailURL: URL, videoInfo: VideoInfo)?
var sendVideoUrlThumbnailURLVideoInfoReceivedInvocations: [(url: URL, thumbnailURL: URL, videoInfo: VideoInfo)] = []
var sendVideoUrlThumbnailURLVideoInfoReturnValue: Result<Void, RoomProxyError>!
var sendVideoUrlThumbnailURLVideoInfoClosure: ((URL, URL, VideoInfo) async -> Result<Void, RoomProxyError>)?
func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo) async -> Result<Void, RoomProxyError> {
sendVideoUrlThumbnailURLVideoInfoCallsCount += 1
sendVideoUrlThumbnailURLVideoInfoReceivedArguments = (url: url, thumbnailURL: thumbnailURL, videoInfo: videoInfo)
sendVideoUrlThumbnailURLVideoInfoReceivedInvocations.append((url: url, thumbnailURL: thumbnailURL, videoInfo: videoInfo))
if let sendVideoUrlThumbnailURLVideoInfoClosure = sendVideoUrlThumbnailURLVideoInfoClosure {
return await sendVideoUrlThumbnailURLVideoInfoClosure(url, thumbnailURL, videoInfo)
} else {
return sendVideoUrlThumbnailURLVideoInfoReturnValue
}
}
//MARK: - sendAudio
var sendAudioUrlAudioInfoCallsCount = 0
var sendAudioUrlAudioInfoCalled: Bool {
return sendAudioUrlAudioInfoCallsCount > 0
}
var sendAudioUrlAudioInfoReceivedArguments: (url: URL, audioInfo: AudioInfo)?
var sendAudioUrlAudioInfoReceivedInvocations: [(url: URL, audioInfo: AudioInfo)] = []
var sendAudioUrlAudioInfoReturnValue: Result<Void, RoomProxyError>!
var sendAudioUrlAudioInfoClosure: ((URL, AudioInfo) async -> Result<Void, RoomProxyError>)?
func sendAudio(url: URL, audioInfo: AudioInfo) async -> Result<Void, RoomProxyError> {
sendAudioUrlAudioInfoCallsCount += 1
sendAudioUrlAudioInfoReceivedArguments = (url: url, audioInfo: audioInfo)
sendAudioUrlAudioInfoReceivedInvocations.append((url: url, audioInfo: audioInfo))
if let sendAudioUrlAudioInfoClosure = sendAudioUrlAudioInfoClosure {
return await sendAudioUrlAudioInfoClosure(url, audioInfo)
} else {
return sendAudioUrlAudioInfoReturnValue
}
}
//MARK: - sendFile
var sendFileUrlFileInfoCallsCount = 0
var sendFileUrlFileInfoCalled: Bool {
return sendFileUrlFileInfoCallsCount > 0
}
var sendFileUrlFileInfoReceivedArguments: (url: URL, fileInfo: FileInfo)?
var sendFileUrlFileInfoReceivedInvocations: [(url: URL, fileInfo: FileInfo)] = []
var sendFileUrlFileInfoReturnValue: Result<Void, RoomProxyError>!
var sendFileUrlFileInfoClosure: ((URL, FileInfo) async -> Result<Void, RoomProxyError>)?
func sendFile(url: URL, fileInfo: FileInfo) async -> Result<Void, RoomProxyError> {
sendFileUrlFileInfoCallsCount += 1
sendFileUrlFileInfoReceivedArguments = (url: url, fileInfo: fileInfo)
sendFileUrlFileInfoReceivedInvocations.append((url: url, fileInfo: fileInfo))
if let sendFileUrlFileInfoClosure = sendFileUrlFileInfoClosure {
return await sendFileUrlFileInfoClosure(url, fileInfo)
} else {
return sendFileUrlFileInfoReturnValue
}
}
//MARK: - editMessage
var editMessageOriginalCallsCount = 0

View File

@ -89,6 +89,7 @@ struct A11yIdentifiers {
struct RoomScreen {
let name = "room-name"
let avatar = "room-avatar"
let attachmentPicker = "room-attachment_picker"
}
struct RoomDetailsScreen {

View File

@ -0,0 +1,26 @@
//
// Copyright 2023 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
extension NSItemProvider {
var isSupportedForPasteOrDrop: Bool {
!registeredContentTypes
.compactMap(\.preferredMIMEType)
.filter { $0.hasPrefix("image/") || $0.hasPrefix("video/") || $0.hasPrefix("application/") }
.isEmpty
}
}

View File

@ -127,7 +127,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
navigationStackCoordinator.push(coordinator)
}
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
private static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
private func startLoading() {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,

View File

@ -89,7 +89,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
// MARK: - Private
static let loadingIndicatorIdentifier = "LoginCoordinatorLoading"
private static let loadingIndicatorIdentifier = "LoginCoordinatorLoading"
private func startLoading(isInteractionBlocking: Bool) {
if isInteractionBlocking {

View File

@ -95,7 +95,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
// MARK: - Private
static let loadingIndicatorIdentifier = "SoftLogoutLoading"
private static let loadingIndicatorIdentifier = "SoftLogoutLoading"
/// Show an activity indicator whilst loading.
@MainActor private func startLoading() {

View File

@ -83,7 +83,7 @@ final class BugReportScreenCoordinator: CoordinatorProtocol {
// MARK: - Private
static let loadingIndicatorIdentifier = "BugReportLoading"
private static let loadingIndicatorIdentifier = "BugReportLoading"
private func startLoading(label: String = L10n.commonLoading, progressPublisher: ProgressPublisher) {
parameters.userIndicatorController?.submitIndicator(

View File

@ -26,7 +26,6 @@ struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
var startChatFlowEnabled: Bool
var startChatUserSuggestionsEnabled: Bool
var mediaUploadFlowEnabled: Bool
var invitesFlowEnabled: Bool
}
@ -34,6 +33,5 @@ enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
case changedStartChatFlowEnabled
case changedStartChatUserSuggestionsEnabled
case changedMediaUploadFlowEnabled
case changedInvitesFlowEnabled
}

View File

@ -25,7 +25,6 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents,
startChatFlowEnabled: ServiceLocator.shared.settings.startChatFlowEnabled,
startChatUserSuggestionsEnabled: ServiceLocator.shared.settings.startChatUserSuggestionsEnabled,
mediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled,
invitesFlowEnabled: ServiceLocator.shared.settings.invitesFlowEnabled)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
@ -44,8 +43,6 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
ServiceLocator.shared.settings.startChatFlowEnabled = state.bindings.startChatFlowEnabled
case .changedStartChatUserSuggestionsEnabled:
ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled
case .changedMediaUploadFlowEnabled:
ServiceLocator.shared.settings.mediaUploadingFlowEnabled = state.bindings.mediaUploadFlowEnabled
case .changedInvitesFlowEnabled:
ServiceLocator.shared.settings.invitesFlowEnabled = state.bindings.invitesFlowEnabled
}

View File

@ -43,14 +43,7 @@ struct DeveloperOptionsScreen: View {
.onChange(of: context.startChatUserSuggestionsEnabled) { _ in
context.send(viewAction: .changedStartChatUserSuggestionsEnabled)
}
Toggle(isOn: $context.mediaUploadFlowEnabled) {
Text("Show Media Uploading flow")
}
.onChange(of: context.mediaUploadFlowEnabled) { _ in
context.send(viewAction: .changedMediaUploadFlowEnabled)
}
Toggle(isOn: $context.invitesFlowEnabled) {
Text("Show Invites flow")
}

View File

@ -1,56 +0,0 @@
//
// 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 MediaPickerPreviewScreenCoordinatorParameters {
let url: URL
let title: String?
}
enum MediaPickerPreviewScreenCoordinatorAction {
case send
case cancel
}
final class MediaPickerPreviewScreenCoordinator: CoordinatorProtocol {
private let parameters: MediaPickerPreviewScreenCoordinatorParameters
private var viewModel: MediaPickerPreviewScreenViewModelProtocol
private let callback: (MediaPickerPreviewScreenCoordinatorAction) -> Void
init(parameters: MediaPickerPreviewScreenCoordinatorParameters, callback: @escaping (MediaPickerPreviewScreenCoordinatorAction) -> Void) {
self.parameters = parameters
self.callback = callback
viewModel = MediaPickerPreviewScreenViewModel(url: parameters.url, title: parameters.title)
}
func start() {
viewModel.callback = { [weak self] action in
switch action {
case .send:
self?.callback(.send)
case .cancel:
self?.callback(.cancel)
}
}
}
func toPresentable() -> AnyView {
AnyView(MediaPickerPreviewScreen(context: viewModel.context))
}
}

View File

@ -1,36 +0,0 @@
//
// 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
typealias MediaPickerPreviewScreenViewModelType = StateStoreViewModel<MediaPickerPreviewScreenViewState, MediaPickerPreviewScreenViewAction>
class MediaPickerPreviewScreenViewModel: MediaPickerPreviewScreenViewModelType, MediaPickerPreviewScreenViewModelProtocol {
var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)?
init(url: URL, title: String?) {
super.init(initialViewState: MediaPickerPreviewScreenViewState(url: url, title: title))
}
override func process(viewAction: MediaPickerPreviewScreenViewAction) {
switch viewAction {
case .send:
callback?(.send)
case .cancel:
callback?(.cancel)
}
}
}

View File

@ -70,7 +70,7 @@ struct CameraPicker: UIViewControllerRepresentable {
return
}
let fileName = "\(Date.now.formatted(date: .abbreviated, time: .shortened)).jpg"
let fileName = "\(Date.now.formatted(.iso8601.dateSeparator(.omitted).timeSeparator(.omitted))).jpg"
do {
let url = try FileManager.default.writeDataToTemporaryDirectory(data: jpegData, fileName: fileName)

View File

@ -25,14 +25,15 @@ enum MediaPickerScreenSource {
enum MediaPickerScreenCoordinatorAction {
case selectMediaAtURL(URL)
case cancel
case error(Error?)
}
class MediaPickerScreenCoordinator: CoordinatorProtocol {
private weak var userIndicatorController: UserIndicatorControllerProtocol?
private let source: MediaPickerScreenSource
private let callback: ((MediaPickerScreenCoordinatorAction) -> Void)?
init(source: MediaPickerScreenSource, callback: @escaping (MediaPickerScreenCoordinatorAction) -> Void) {
init(userIndicatorController: UserIndicatorControllerProtocol, source: MediaPickerScreenSource, callback: @escaping (MediaPickerScreenCoordinatorAction) -> Void) {
self.userIndicatorController = userIndicatorController
self.source = source
self.callback = callback
}
@ -52,7 +53,8 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
case .cancel:
self?.callback?(.cancel)
case .error(let error):
self?.callback?(.error(error))
MXLog.error("Failed selecting media from the photo library with error: \(error)")
self?.showError()
case .selectFile(let url):
self?.callback?(.selectMediaAtURL(url))
}
@ -65,7 +67,8 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
case .cancel:
self.callback?(.cancel)
case .error(let error):
self.callback?(.error(error))
MXLog.error("Failed selecting media from the document picker with error: \(error)")
self.showError()
case .selectFile(let url):
self.callback?(.selectMediaAtURL(url))
}
@ -79,11 +82,16 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
case .cancel:
self?.callback?(.cancel)
case .error(let error):
self?.callback?(.error(error))
MXLog.error("Failed selecting media from the camera picker with error: \(error)")
self?.showError()
case .selectFile(let url):
self?.callback?(.selectMediaAtURL(url))
}
}
.background(.black, ignoresSafeAreaEdges: .bottom)
}
private func showError() {
userIndicatorController?.submitIndicator(UserIndicator(title: L10n.screenMediaPickerErrorFailedSelection))
}
}

View File

@ -20,7 +20,12 @@ import SwiftUI
enum PhotoLibraryPickerAction {
case selectFile(URL)
case cancel
case error(Error?)
case error(PhotoLibraryPickerError)
}
enum PhotoLibraryPickerError: Error {
case failedLoadingFileRepresentation(Error?)
case failedCopyingFile
}
struct PhotoLibraryPicker: UIViewControllerRepresentable {
@ -63,7 +68,9 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable {
provider.loadFileRepresentation(forTypeIdentifier: "public.item") { [weak self] url, error in
guard let url else {
self?.photoLibraryPicker.callback(.error(error))
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error)))
}
return
}
@ -76,7 +83,9 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable {
self?.photoLibraryPicker.callback(.selectFile(newURL))
}
} catch {
self?.photoLibraryPicker.callback(.error(error))
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedCopyingFile))
}
}
}
}

View File

@ -0,0 +1,57 @@
//
// 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 MediaUploadPreviewScreenCoordinatorParameters {
weak var userIndicatorController: UserIndicatorControllerProtocol?
let roomProxy: RoomProxyProtocol
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
let title: String?
let url: URL
}
enum MediaUploadPreviewScreenCoordinatorAction {
case dismiss
}
final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
private var viewModel: MediaUploadPreviewScreenViewModelProtocol
private let callback: (MediaUploadPreviewScreenCoordinatorAction) -> Void
init(parameters: MediaUploadPreviewScreenCoordinatorParameters, callback: @escaping (MediaUploadPreviewScreenCoordinatorAction) -> Void) {
self.callback = callback
viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: parameters.userIndicatorController,
roomProxy: parameters.roomProxy,
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
title: parameters.title,
url: parameters.url)
}
func start() {
viewModel.callback = { [weak self] action in
switch action {
case .dismiss:
self?.callback(.dismiss)
}
}
}
func toPresentable() -> AnyView {
AnyView(MediaUploadPreviewScreen(context: viewModel.context))
}
}

View File

@ -16,17 +16,16 @@
import Foundation
enum MediaPickerPreviewScreenViewModelAction {
case send
case cancel
enum MediaUploadPreviewScreenViewModelAction {
case dismiss
}
struct MediaPickerPreviewScreenViewState: BindableState {
struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL
let title: String?
}
enum MediaPickerPreviewScreenViewAction {
enum MediaUploadPreviewScreenViewAction {
case send
case cancel
}

View File

@ -0,0 +1,105 @@
//
// 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
typealias MediaUploadPreviewScreenViewModelType = StateStoreViewModel<MediaUploadPreviewScreenViewState, MediaUploadPreviewScreenViewAction>
class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, MediaUploadPreviewScreenViewModelProtocol {
private weak var userIndicatorController: UserIndicatorControllerProtocol?
private let roomProxy: RoomProxyProtocol
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
private let url: URL
var callback: ((MediaUploadPreviewScreenViewModelAction) -> Void)?
init(userIndicatorController: UserIndicatorControllerProtocol?,
roomProxy: RoomProxyProtocol,
mediaUploadingPreprocessor: MediaUploadingPreprocessor,
title: String?,
url: URL) {
self.userIndicatorController = userIndicatorController
self.roomProxy = roomProxy
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
self.url = url
super.init(initialViewState: MediaUploadPreviewScreenViewState(url: url, title: title))
}
override func process(viewAction: MediaUploadPreviewScreenViewAction) {
switch viewAction {
case .send:
Task {
startLoading()
defer {
stopLoading()
}
switch await mediaUploadingPreprocessor.processMedia(at: url) {
case .success(let mediaInfo):
switch await sendAttachment(mediaInfo: mediaInfo) {
case .success:
callback?(.dismiss)
case .failure(let error):
MXLog.error("Failed sending attachment with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
}
case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
}
}
case .cancel:
callback?(.dismiss)
}
}
// MARK: - Private
private func sendAttachment(mediaInfo: MediaInfo) async -> Result<Void, RoomProxyError> {
switch mediaInfo {
case let .image(imageURL, thumbnailURL, imageInfo):
return await roomProxy.sendImage(url: imageURL, thumbnailURL: thumbnailURL, imageInfo: imageInfo)
case let .video(videoURL, thumbnailURL, videoInfo):
return await roomProxy.sendVideo(url: videoURL, thumbnailURL: thumbnailURL, videoInfo: videoInfo)
case let .audio(audioURL, audioInfo):
return await roomProxy.sendAudio(url: audioURL, audioInfo: audioInfo)
case let .file(fileURL, fileInfo):
return await roomProxy.sendFile(url: fileURL, fileInfo: fileInfo)
}
}
private static let loadingIndicatorIdentifier = "MediaUploadPreviewLoading"
private func startLoading() {
userIndicatorController?.submitIndicator(
UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true)
)
}
private func stopLoading() {
userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
private func showError(label: String) {
userIndicatorController?.submitIndicator(UserIndicator(title: label))
}
}

View File

@ -17,7 +17,7 @@
import Foundation
@MainActor
protocol MediaPickerPreviewScreenViewModelProtocol {
var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)? { get set }
var context: MediaPickerPreviewScreenViewModelType.Context { get }
protocol MediaUploadPreviewScreenViewModelProtocol {
var callback: ((MediaUploadPreviewScreenViewModelAction) -> Void)? { get set }
var context: MediaUploadPreviewScreenViewModelType.Context { get }
}

View File

@ -17,18 +17,17 @@
import QuickLook
import SwiftUI
struct MediaPickerPreviewScreen: View {
@ObservedObject var context: MediaPickerPreviewScreenViewModel.Context
struct MediaUploadPreviewScreen: View {
@ObservedObject var context: MediaUploadPreviewScreenViewModel.Context
var body: some View {
NavigationStack {
PreviewView(context: context,
fileURL: context.viewState.url,
title: context.viewState.title)
.id(UUID())
.ignoresSafeArea(edges: .bottom)
.toolbar { toolbar }
}
PreviewView(context: context,
fileURL: context.viewState.url,
title: context.viewState.title)
.id(UUID())
.ignoresSafeArea(edges: .bottom)
.toolbar { toolbar }
.interactiveDismissDisabled()
}
@ToolbarContentBuilder
@ -47,7 +46,7 @@ struct MediaPickerPreviewScreen: View {
}
private struct PreviewView: UIViewControllerRepresentable {
let context: MediaPickerPreviewScreenViewModel.Context
let context: MediaUploadPreviewScreenViewModel.Context
let fileURL: URL
let title: String?
@ -102,9 +101,13 @@ private class PreviewItem: NSObject, QLPreviewItem {
// MARK: - Previews
struct MediaPickerPreviewScreen_Previews: PreviewProvider {
static let viewModel = MediaPickerPreviewScreenViewModel(url: URL.picturesDirectory, title: nil)
struct MediaUploadPreviewScreen_Previews: PreviewProvider {
static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: MockUserIndicatorController(),
roomProxy: RoomProxyMock(),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
title: nil,
url: URL.picturesDirectory)
static var previews: some View {
MediaPickerPreviewScreen(context: viewModel.context)
MediaUploadPreviewScreen(context: viewModel.context)
}
}

View File

@ -68,6 +68,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.displayMediaPickerWithSource(.photoLibrary)
case .displayDocumentPicker:
self.displayMediaPickerWithSource(.documents)
case .displayMediaUploadPreviewScreen(let url):
self.displayMediaUploadPreviewScreenForFile(at: url)
}
}
}
@ -84,44 +86,43 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
// MARK: - Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
let mediaPickerCoordinator = MediaPickerScreenCoordinator(source: source) { [weak self] action in
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
case .error:
break
case .selectMediaAtURL(let url):
let mediaPickerPreviewScreenCoordinator = MediaPickerPreviewScreenCoordinator(parameters: .init(url: url, title: url.lastPathComponent)) { action in
switch action {
case .send:
Task {
let preprocessor = MediaUploadingPreprocessor()
switch await preprocessor.processMedia(at: url) {
case .success(let mediaInfo):
MXLog.info(mediaInfo)
switch mediaInfo {
case let .image(imageURL, thumbnailURL, imageInfo):
let _ = await self?.parameters.roomProxy.sendImage(url: imageURL, thumbnailURL: thumbnailURL, imageInfo: imageInfo)
default:
break
}
case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)")
}
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
self?.navigationStackCoordinator.setSheetCoordinator(mediaPickerPreviewScreenCoordinator)
self?.displayMediaUploadPreviewScreenForFile(at: url)
}
}
navigationStackCoordinator.setSheetCoordinator(mediaPickerCoordinator)
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func displayMediaUploadPreviewScreenForFile(at url: URL) {
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
roomProxy: parameters.roomProxy,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
title: url.lastPathComponent,
url: url)
let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in
switch action {
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) {

View File

@ -26,6 +26,7 @@ enum RoomScreenViewModelAction {
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayMediaUploadPreviewScreen(url: URL)
}
enum RoomScreenComposerMode: Equatable {
@ -62,6 +63,8 @@ enum RoomScreenViewAction {
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case handlePasteOrDrop(provider: NSItemProvider)
}
struct RoomScreenViewState: BindableState {
@ -73,7 +76,6 @@ struct RoomScreenViewState: BindableState {
var isBackPaginating = false
var showLoading = false
var timelineStyle: TimelineStyle
var mediaUploadingFlowEnabled: Bool
var bindings: RoomScreenViewStateBindings

View File

@ -39,7 +39,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomTitle: roomName ?? "Unknown room 💥",
roomAvatarURL: roomAvatarUrl,
timelineStyle: ServiceLocator.shared.settings.timelineStyle,
mediaUploadingFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled,
bindings: .init(composerText: "", composerFocused: false)),
imageProvider: mediaProvider)
@ -74,11 +73,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
ServiceLocator.shared.settings.$timelineStyle
.weakAssign(to: \.state.timelineStyle, on: self)
.store(in: &cancellables)
ServiceLocator.shared.settings.$mediaUploadingFlowEnabled
.weakAssign(to: \.state.mediaUploadingFlowEnabled, on: self)
.store(in: &cancellables)
buildTimelineViews()
}
@ -122,6 +117,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
callback?(.displayMediaPicker)
case .displayDocumentPicker:
callback?(.displayDocumentPicker)
case .handlePasteOrDrop(let provider):
handlePasteOrDrop(provider)
}
}
@ -334,6 +331,60 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.composerMode = .default
}
}
// Pasting and dropping
private func handlePasteOrDrop(_ provider: NSItemProvider) {
guard let type = provider.registeredContentTypes.first,
let preferredExtension = type.preferredFilenameExtension else {
MXLog.error("Invalid NSItemProvider: \(provider)")
displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
return
}
let providerSuggestedName = provider.suggestedName
let providerDescription = provider.description
_ = provider.loadDataRepresentation(for: type) { data, error in
Task { @MainActor in
let loadingIndicatorIdentifier = UUID().uuidString
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
defer {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
if let error {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)")
return
}
guard let data else {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Invalid NSItemProvider data: \(providerDescription)")
return
}
do {
let url = try await Task.detached {
if let filename = providerSuggestedName {
let hasExtension = !(filename as NSString).pathExtension.isEmpty
let filename = hasExtension ? filename : "\(filename).\(preferredExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
} else {
let filename = "\(UUID().uuidString).\(preferredExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
}
}.value
self.callback?(.displayMediaUploadPreviewScreen(url: url))
} catch {
self.displayError(.toast(L10n.screenRoomErrorFailedProcessingMedia))
MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)")
}
}
}
}
}
// MARK: - Mocks

View File

@ -22,7 +22,8 @@ struct MessageComposer: View {
let sendingDisabled: Bool
let type: RoomScreenComposerMode
let sendAction: () -> Void
let sendAction: EnterKeyHandler
let pasteAction: PasteHandler
let replyCancellationAction: () -> Void
let editCancellationAction: () -> Void
@ -39,7 +40,8 @@ struct MessageComposer: View {
focused: $focused,
isMultiline: $isMultiline,
maxHeight: 300,
onEnterKeyHandler: sendAction)
enterKeyHandler: sendAction,
pasteHandler: pasteAction)
.tint(.element.brand)
.padding(.vertical, 10)
@ -173,6 +175,7 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: true,
type: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
@ -181,6 +184,7 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: false,
type: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
@ -189,6 +193,7 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: false,
type: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
@ -197,6 +202,7 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: false,
type: .default,
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
@ -206,6 +212,7 @@ struct MessageComposer_Previews: PreviewProvider {
type: .reply(id: UUID().uuidString,
displayName: "John Doe"),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
@ -214,6 +221,7 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: false,
type: .edit(originalItemId: UUID().uuidString),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
editCancellationAction: { })
}

View File

@ -16,7 +16,8 @@
import SwiftUI
typealias OnEnterKeyHandler = () -> Void
typealias EnterKeyHandler = () -> Void
typealias PasteHandler = (NSItemProvider) -> Void
struct MessageComposerTextField: View {
let placeholder: String
@ -25,7 +26,8 @@ struct MessageComposerTextField: View {
@Binding var isMultiline: Bool
let maxHeight: CGFloat
let onEnterKeyHandler: OnEnterKeyHandler
let enterKeyHandler: EnterKeyHandler
let pasteHandler: PasteHandler
private var showingPlaceholder: Bool {
text.isEmpty
@ -40,7 +42,8 @@ struct MessageComposerTextField: View {
focused: $focused,
isMultiline: $isMultiline,
maxHeight: maxHeight,
onEnterKeyHandler: onEnterKeyHandler)
enterKeyHandler: enterKeyHandler,
pasteHandler: pasteHandler)
.accessibilityLabel(placeholder)
.background(placeholderView, alignment: .topLeading)
}
@ -64,15 +67,16 @@ private struct UITextViewWrapper: UIViewRepresentable {
let maxHeight: CGFloat
let onEnterKeyHandler: OnEnterKeyHandler
let enterKeyHandler: EnterKeyHandler
let pasteHandler: PasteHandler
private let font = UIFont.preferredFont(forTextStyle: .body)
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textView = TextViewWithKeyDetection()
let textView = ElementTextView()
textView.isMultiline = $isMultiline
textView.delegate = context.coordinator
textView.keyDelegate = context.coordinator
textView.elementDelegate = context.coordinator
textView.textColor = .element.primaryContent
textView.isEditable = true
textView.font = font
@ -126,25 +130,29 @@ private struct UITextViewWrapper: UIViewRepresentable {
Coordinator(text: $text,
focused: $focused,
maxHeight: maxHeight,
onEnterKeyHandler: onEnterKeyHandler)
enterKeyHandler: enterKeyHandler,
pasteHandler: pasteHandler)
}
final class Coordinator: NSObject, UITextViewDelegate, TextViewWithKeyDetectionDelegate {
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
private var text: Binding<String>
private var focused: Binding<Bool>
private let maxHeight: CGFloat
private let onEnterKeyHandler: OnEnterKeyHandler
private let enterKeyHandler: EnterKeyHandler
private let pasteHandler: PasteHandler
init(text: Binding<String>,
focused: Binding<Bool>,
maxHeight: CGFloat,
onEnterKeyHandler: @escaping OnEnterKeyHandler) {
enterKeyHandler: @escaping EnterKeyHandler,
pasteHandler: @escaping PasteHandler) {
self.text = text
self.focused = focused
self.maxHeight = maxHeight
self.onEnterKeyHandler = onEnterKeyHandler
self.enterKeyHandler = enterKeyHandler
self.pasteHandler = pasteHandler
}
func textViewDidChange(_ textView: UITextView) {
@ -159,23 +167,28 @@ private struct UITextViewWrapper: UIViewRepresentable {
focused.wrappedValue = false
}
func enterKeyWasPressed(textView: UITextView) {
onEnterKeyHandler()
func textViewDidReceiveEnterKeyPress(_ textView: UITextView) {
enterKeyHandler()
}
func shiftEnterKeyPressed(textView: UITextView) {
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) {
textView.insertText("\n")
}
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
pasteHandler(provider)
}
}
}
private protocol TextViewWithKeyDetectionDelegate: AnyObject {
func enterKeyWasPressed(textView: UITextView)
func shiftEnterKeyPressed(textView: UITextView)
private protocol ElementTextViewDelegate: AnyObject {
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView)
func textViewDidReceiveEnterKeyPress(_ textView: UITextView)
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider)
}
private class TextViewWithKeyDetection: UITextView {
weak var keyDelegate: TextViewWithKeyDetectionDelegate?
private class ElementTextView: UITextView {
weak var elementDelegate: ElementTextViewDelegate?
var isMultiline: Binding<Bool>?
@ -185,11 +198,11 @@ private class TextViewWithKeyDetection: UITextView {
}
@objc func shiftEnterKeyPressed(sender: UIKeyCommand) {
keyDelegate?.shiftEnterKeyPressed(textView: self)
elementDelegate?.textViewDidReceiveShiftEnterKeyPress(self)
}
@objc func enterKeyPressed(sender: UIKeyCommand) {
keyDelegate?.enterKeyWasPressed(textView: self)
elementDelegate?.textViewDidReceiveEnterKeyPress(self)
}
override func layoutSubviews() {
@ -208,6 +221,31 @@ private class TextViewWithKeyDetection: UITextView {
}
}
}
// Pasting support
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if super.canPerformAction(action, withSender: sender) {
return true
}
guard action == #selector(paste(_:)) else {
return false
}
return UIPasteboard.general.itemProviders.first?.isSupportedForPasteOrDrop ?? false
}
override func paste(_ sender: Any?) {
super.paste(sender)
guard let provider = UIPasteboard.general.itemProviders.first,
provider.isSupportedForPasteOrDrop else {
return
}
elementDelegate?.textView(self, didReceivePasteWith: provider)
}
}
struct MessageComposerTextField_Previews: PreviewProvider {
@ -236,7 +274,8 @@ struct MessageComposerTextField_Previews: PreviewProvider {
focused: $focused,
isMultiline: $isMultiline,
maxHeight: 300,
onEnterKeyHandler: { })
enterKeyHandler: { },
pasteHandler: { _ in })
}
}
}

View File

@ -0,0 +1,98 @@
//
// Copyright 2023 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 RoomAttachmentPicker: View {
@ObservedObject var context: RoomScreenViewModel.Context
@State private var showAttachmentPopover = false
@State private var sheetContentHeight = CGFloat(0)
var body: some View {
Button {
showAttachmentPopover = true
} label: {
Image(systemName: "plus.circle.fill")
.font(.compound.headingLG)
.foregroundColor(.element.accent)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPicker)
.popover(isPresented: $showAttachmentPopover) {
VStack(alignment: .leading, spacing: 0.0) {
Button {
showAttachmentPopover = false
context.send(viewAction: .displayMediaPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceGallery, systemImageName: "photo.fill")
}
Button {
showAttachmentPopover = false
context.send(viewAction: .displayDocumentPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceFiles, systemImageName: "paperclip")
}
Button {
showAttachmentPopover = false
context.send(viewAction: .displayCameraPicker)
} label: {
PickerLabel(title: L10n.screenRoomAttachmentSourceCamera, systemImageName: "camera.fill")
}
}
.background {
// This is done in the background otherwise GeometryReader tends to expand to
// all the space given to it like color or shape.
GeometryReader { proxy in
Color.clear
.onAppear {
sheetContentHeight = proxy.size.height
}
}
}
.presentationDetents([.height(sheetContentHeight)])
.presentationDragIndicator(.visible)
.tint(.element.accent)
}
}
private struct PickerLabel: View {
let title: String
let systemImageName: String
var body: some View {
Label(title, systemImage: systemImageName)
.labelStyle(EqualIconWidthLabelStyle())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
private struct EqualIconWidthLabelStyle: LabelStyle {
@ScaledMetric private var menuIconSize = 24.0
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration
.icon
.frame(width: menuIconSize, height: menuIconSize)
configuration.title
}
}
}

View File

@ -19,16 +19,14 @@ import SwiftUI
struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context
@State private var showReactionsMenuForItemId = ""
@State private var dragOver = false
var body: some View {
timeline
.background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency.
.safeAreaInset(edge: .bottom, spacing: 0) {
HStack(spacing: 4.0) {
if context.viewState.mediaUploadingFlowEnabled {
sendAttachmentButton
}
RoomAttachmentPicker(context: context)
messageComposer
}
.padding()
@ -48,6 +46,15 @@ struct RoomScreen: View {
guard !Task.isCancelled else { return }
context.send(viewAction: .markRoomAsRead)
}
.onDrop(of: ["public.item"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
provider.isSupportedForPasteOrDrop else {
return false
}
context.send(viewAction: .handlePasteOrDrop(provider: provider))
return true
}
}
private var timeline: some View {
@ -64,6 +71,8 @@ struct RoomScreen: View {
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} pasteAction: { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider))
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
@ -113,30 +122,6 @@ struct RoomScreen: View {
}
}
private var sendAttachmentButton: some View {
Menu {
Button {
context.send(viewAction: .displayDocumentPicker)
} label: {
Label(UntranslatedL10n.mediaUploadDocumentPicker, systemImage: "doc")
}
Button {
context.send(viewAction: .displayMediaPicker)
} label: {
Label(UntranslatedL10n.mediaUploadPhotoAndVideoPicker, systemImage: "photo")
}
Button {
context.send(viewAction: .displayCameraPicker)
} label: {
Label(UntranslatedL10n.mediaUploadCameraPicker, systemImage: "camera")
}
} label: {
Image(systemName: "plus.circle")
.font(.compound.headingLG)
.foregroundColor(.element.brand)
}
}
private func sendMessage() {
guard !context.viewState.sendButtonDisabled else { return }
context.send(viewAction: .sendMessage)

View File

@ -28,7 +28,7 @@ struct ImageRoomTimelineView: View {
imageProvider: context.imageProvider) {
placeholder
}
.frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}

View File

@ -28,7 +28,7 @@ struct StickerRoomTimelineView: View {
imageProvider: context.imageProvider) {
placeholder
}
.frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.accessibilityLabel(timelineItem.body)

View File

@ -24,7 +24,7 @@ struct VideoRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
thumbnail
.frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}

View File

@ -150,7 +150,7 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
// MARK: Loading indicator
static let loadingIndicatorIdentifier = "StartChatLoading"
private static let loadingIndicatorIdentifier = "StartChatLoading"
private func showLoadingIndicator() {
userIndicatorController?.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,

View File

@ -40,7 +40,7 @@ enum MediaInfo {
case image(imageURL: URL, thumbnailURL: URL, imageInfo: ImageInfo)
case video(videoURL: URL, thumbnailURL: URL, videoInfo: VideoInfo)
case audio(audioURL: URL, audioInfo: AudioInfo)
case file(FileInfo)
case file(fileURL: URL, fileInfo: FileInfo)
}
private struct ImageProcessingInfo {
@ -205,7 +205,7 @@ struct MediaUploadingPreprocessor {
let fileSize = try? UInt64(FileManager.default.sizeForItem(at: url))
let fileInfo = FileInfo(mimetype: mimeType, size: fileSize, thumbnailInfo: nil, thumbnailSource: nil)
return .success(.file(fileInfo))
return .success(.file(fileURL: url, fileInfo: fileInfo))
}
// MARK: Images
@ -217,32 +217,30 @@ struct MediaUploadingPreprocessor {
/// - Returns: the URL for the modified image and its size as an `ImageProcessingResult`
private func stripLocationFromImage(at url: URL, type: UTType, mimeType: String) async -> Result<ImageProcessingInfo, MediaUploadingPreprocessorError> {
guard let originalData = NSData(contentsOf: url),
let originalCGImage = UIImage(data: originalData as Data)?.cgImage,
let originalImage = UIImage(data: originalData as Data),
let imageSource = CGImageSourceCreateWithData(originalData, nil) else {
return .failure(.failedStrippingLocationData)
}
guard let originalMetadata = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) else {
MXLog.info("No metadata found. Returning original image")
return .success(.init(url: url, height: Double(originalCGImage.height), width: Double(originalCGImage.width), mimeType: mimeType, blurhash: nil))
guard let originalMetadata = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil),
(originalMetadata as NSDictionary).value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") != nil else {
MXLog.info("No GPS metadata found. Returning original image")
return .success(.init(url: url, height: Double(originalImage.size.height), width: Double(originalImage.size.width), mimeType: mimeType, blurhash: nil))
}
guard let adjustedMetadata = (originalMetadata as NSDictionary).mutableCopy() as? NSMutableDictionary else {
return .failure(.failedStrippingLocationData)
}
adjustedMetadata.setValue(nil, forKeyPath: "\(kCGImagePropertyGPSDictionary)")
let count = CGImageSourceGetCount(imageSource)
let metadataKeysToRemove = [kCGImagePropertyGPSDictionary: kCFNull]
let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(data as CFMutableData, type.identifier as CFString, 1, nil) else {
guard let destination = CGImageDestinationCreateWithData(data as CFMutableData, type.identifier as CFString, count, nil) else {
return .failure(.failedStrippingLocationData)
}
CGImageDestinationAddImage(destination, originalCGImage, adjustedMetadata)
CGImageDestinationAddImageFromSource(destination, imageSource, 0, metadataKeysToRemove as NSDictionary)
CGImageDestinationFinalize(destination)
do {
try data.write(to: url)
return .success(.init(url: url, height: Double(originalCGImage.height), width: Double(originalCGImage.width), mimeType: mimeType, blurhash: nil))
return .success(.init(url: url, height: Double(originalImage.size.height), width: Double(originalImage.size.width), mimeType: mimeType, blurhash: nil))
} catch {
return .failure(.failedStrippingLocationData)
}

View File

@ -227,20 +227,67 @@ class RoomProxy: RoomProxyProtocol {
}
func sendImage(url: URL, thumbnailURL: URL, imageInfo: ImageInfo) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMedia)
// sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
// defer {
// sendMessageBackgroundTask?.stop()
// }
//
// return await Task.dispatch(on: userInitiatedDispatchQueue) {
// do {
// try self.room.sendImage(url: url.path(), thumbnailUrl: thumbnailURL.path(), imageInfo: imageInfo)
// return .success(())
// } catch {
// return .failure(.failedEditingMessage)
// }
// }
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.room.sendImage(url: url.path(), thumbnailUrl: thumbnailURL.path(), imageInfo: imageInfo)
return .success(())
} catch {
return .failure(.failedSendingMedia)
}
}
}
func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.room.sendVideo(url: url.path(), thumbnailUrl: thumbnailURL.path(), videoInfo: videoInfo)
return .success(())
} catch {
return .failure(.failedSendingMedia)
}
}
}
func sendAudio(url: URL, audioInfo: AudioInfo) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.room.sendAudio(url: url.path(), audioInfo: audioInfo)
return .success(())
} catch {
return .failure(.failedSendingMedia)
}
}
}
func sendFile(url: URL, fileInfo: FileInfo) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.room.sendFile(url: url.path(), fileInfo: fileInfo)
return .success(())
} catch {
return .failure(.failedSendingMedia)
}
}
}
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> {

View File

@ -76,6 +76,12 @@ protocol RoomProxyProtocol {
func sendReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError>
func sendImage(url: URL, thumbnailURL: URL, imageInfo: ImageInfo) async -> Result<Void, RoomProxyError>
func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo) async -> Result<Void, RoomProxyError>
func sendAudio(url: URL, audioInfo: AudioInfo) async -> Result<Void, RoomProxyError>
func sendFile(url: URL, fileInfo: FileInfo) async -> Result<Void, RoomProxyError>
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError>

View File

@ -17,4 +17,4 @@
import ElementX
import XCTest
class MediaPickerPreviewScreenUITests: XCTestCase { }
class MediaUploadPreviewScreenUITests: XCTestCase { }

View File

@ -33,5 +33,9 @@ class UserSessionScreenTests: XCTestCase {
try await Task.sleep(for: .seconds(1))
app.assertScreenshot(.userSessionScreen, step: 2)
app.buttons[A11yIdentifiers.roomScreen.attachmentPicker].tap()
app.assertScreenshot(.userSessionScreen, step: 3)
}
}

Binary file not shown.

Binary file not shown.

View File

@ -19,4 +19,4 @@ import XCTest
@testable import ElementX
@MainActor
class MediaPickerPreviewScreenViewModelTests: XCTestCase { }
class MediaUploadPreviewScreenViewModelTests: XCTestCase { }