Support adding a caption to media uploads. (#3531)

* Add a composer to the MediaUploadPreviewScreen.

And send it's content to the media upload's caption.

* Use the new compound SendButton (updating relative padding in the toolbar).

* Update snapshots.

* Add unit tests for sending a caption.
This commit is contained in:
Doug 2024-11-19 16:35:01 +00:00 committed by GitHub
parent 352bb577ad
commit b75ad6a5aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 530 additions and 284 deletions

View File

@ -689,6 +689,7 @@
90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; };
914BDF61447C723F104BCE33 /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; }; 914BDF61447C723F104BCE33 /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; };
915B4CDAF220D9AEB4047D45 /* PollInteractionHandlerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259E5B05BDE6E20C26CF11B4 /* PollInteractionHandlerProtocol.swift */; }; 915B4CDAF220D9AEB4047D45 /* PollInteractionHandlerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259E5B05BDE6E20C26CF11B4 /* PollInteractionHandlerProtocol.swift */; };
919BAE492CECA981009F6A5B /* TimelineProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919BAE482CECA981009F6A5B /* TimelineProxyMock.swift */; };
91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */; }; 91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */; };
91C6AC0E9D2B9C0C76CC6AD4 /* RoomDirectorySearchScreenScreenModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */; }; 91C6AC0E9D2B9C0C76CC6AD4 /* RoomDirectorySearchScreenScreenModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */; };
91D1A46A733EC24C081DD353 /* SessionVerificationRequestDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1265FAF2C0AF1C30605BE7 /* SessionVerificationRequestDetailsView.swift */; }; 91D1A46A733EC24C081DD353 /* SessionVerificationRequestDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1265FAF2C0AF1C30605BE7 /* SessionVerificationRequestDetailsView.swift */; };
@ -1900,6 +1901,7 @@
90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = "<group>"; }; 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = "<group>"; };
913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillTextAttachmentData.swift; sourceTree = "<group>"; }; 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillTextAttachmentData.swift; sourceTree = "<group>"; };
91868EB98818044E6FEBE532 /* NotificationPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenCoordinator.swift; sourceTree = "<group>"; }; 91868EB98818044E6FEBE532 /* NotificationPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenCoordinator.swift; sourceTree = "<group>"; };
919BAE482CECA981009F6A5B /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = "<group>"; };
91C8BD78F7B9247AC57FA1A3 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = "<group>"; }; 91C8BD78F7B9247AC57FA1A3 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = "<group>"; };
91CF6F7D08228D16BA69B63B /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = "<group>"; }; 91CF6F7D08228D16BA69B63B /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = "<group>"; };
91FFE1F410969ECB23FE9BB2 /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = "<group>"; }; 91FFE1F410969ECB23FE9BB2 /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = "<group>"; };
@ -3032,6 +3034,7 @@
9EB9BA2F30EB8C33226D8FF1 /* UserSessionStoreMock.swift */, 9EB9BA2F30EB8C33226D8FF1 /* UserSessionStoreMock.swift */,
B23135B06B044CB811139D2F /* Generated */, B23135B06B044CB811139D2F /* Generated */,
E5E545F92D01588360A9BAC5 /* SDK */, E5E545F92D01588360A9BAC5 /* SDK */,
919BAE482CECA981009F6A5B /* TimelineProxyMock.swift */,
); );
path = Mocks; path = Mocks;
sourceTree = "<group>"; sourceTree = "<group>";
@ -7096,6 +7099,7 @@
50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */, 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */,
D43F0503EF2CBC55272538FE /* SDKGeneratedMocks.swift in Sources */, D43F0503EF2CBC55272538FE /* SDKGeneratedMocks.swift in Sources */,
88CBF1595E39CE697928DE48 /* SFNumberedListView.swift in Sources */, 88CBF1595E39CE697928DE48 /* SFNumberedListView.swift in Sources */,
919BAE492CECA981009F6A5B /* TimelineProxyMock.swift in Sources */,
FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */, FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */,
0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */, 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */,
0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */, 0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */,

View File

@ -248,8 +248,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing", "location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : { "state" : {
"revision" : "7b0bbbae90c41f848f90ac7b4df6c4f50068256d", "revision" : "42a086182681cf661f5c47c9b7dc3931de18c6d7",
"version" : "1.17.5" "version" : "1.17.6"
} }
}, },
{ {

View File

@ -14449,15 +14449,15 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
//MARK: - sendAudio //MARK: - sendAudio
var sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount = 0 var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = 0
var sendAudioUrlAudioInfoProgressSubjectRequestHandleCallsCount: Int { var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount return sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount returnValue = sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -14465,27 +14465,27 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendAudioUrlAudioInfoProgressSubjectRequestHandleCalled: Bool { var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleCalled: Bool {
return sendAudioUrlAudioInfoProgressSubjectRequestHandleCallsCount > 0 return sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleCallsCount > 0
} }
var sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>! var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendAudioUrlAudioInfoProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! { var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingReturnValue return sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, TimelineProxyError>? = nil var returnValue: Result<Void, TimelineProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingReturnValue returnValue = sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -14493,35 +14493,35 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendAudioUrlAudioInfoProgressSubjectRequestHandleClosure: ((URL, AudioInfo, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)? var sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleClosure: ((URL, AudioInfo, String?, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)?
func sendAudio(url: URL, audioInfo: AudioInfo, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { func sendAudio(url: URL, audioInfo: AudioInfo, caption: String?, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
sendAudioUrlAudioInfoProgressSubjectRequestHandleCallsCount += 1 sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleCallsCount += 1
if let sendAudioUrlAudioInfoProgressSubjectRequestHandleClosure = sendAudioUrlAudioInfoProgressSubjectRequestHandleClosure { if let sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleClosure = sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleClosure {
return await sendAudioUrlAudioInfoProgressSubjectRequestHandleClosure(url, audioInfo, progressSubject, requestHandle) return await sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleClosure(url, audioInfo, caption, progressSubject, requestHandle)
} else { } else {
return sendAudioUrlAudioInfoProgressSubjectRequestHandleReturnValue return sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleReturnValue
} }
} }
//MARK: - sendFile //MARK: - sendFile
var sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingCallsCount = 0 var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = 0
var sendFileUrlFileInfoProgressSubjectRequestHandleCallsCount: Int { var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingCallsCount return sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingCallsCount returnValue = sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -14529,27 +14529,27 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendFileUrlFileInfoProgressSubjectRequestHandleCalled: Bool { var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleCalled: Bool {
return sendFileUrlFileInfoProgressSubjectRequestHandleCallsCount > 0 return sendFileUrlFileInfoCaptionProgressSubjectRequestHandleCallsCount > 0
} }
var sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>! var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendFileUrlFileInfoProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! { var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingReturnValue return sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, TimelineProxyError>? = nil var returnValue: Result<Void, TimelineProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingReturnValue returnValue = sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -14557,35 +14557,35 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendFileUrlFileInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendFileUrlFileInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendFileUrlFileInfoProgressSubjectRequestHandleClosure: ((URL, FileInfo, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)? var sendFileUrlFileInfoCaptionProgressSubjectRequestHandleClosure: ((URL, FileInfo, String?, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)?
func sendFile(url: URL, fileInfo: FileInfo, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { func sendFile(url: URL, fileInfo: FileInfo, caption: String?, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
sendFileUrlFileInfoProgressSubjectRequestHandleCallsCount += 1 sendFileUrlFileInfoCaptionProgressSubjectRequestHandleCallsCount += 1
if let sendFileUrlFileInfoProgressSubjectRequestHandleClosure = sendFileUrlFileInfoProgressSubjectRequestHandleClosure { if let sendFileUrlFileInfoCaptionProgressSubjectRequestHandleClosure = sendFileUrlFileInfoCaptionProgressSubjectRequestHandleClosure {
return await sendFileUrlFileInfoProgressSubjectRequestHandleClosure(url, fileInfo, progressSubject, requestHandle) return await sendFileUrlFileInfoCaptionProgressSubjectRequestHandleClosure(url, fileInfo, caption, progressSubject, requestHandle)
} else { } else {
return sendFileUrlFileInfoProgressSubjectRequestHandleReturnValue return sendFileUrlFileInfoCaptionProgressSubjectRequestHandleReturnValue
} }
} }
//MARK: - sendImage //MARK: - sendImage
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingCallsCount = 0 var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = 0
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleCallsCount: Int { var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingCallsCount return sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingCallsCount returnValue = sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -14593,27 +14593,27 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleCalled: Bool { var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleCalled: Bool {
return sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleCallsCount > 0 return sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleCallsCount > 0
} }
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>! var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! { var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingReturnValue return sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, TimelineProxyError>? = nil var returnValue: Result<Void, TimelineProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingReturnValue returnValue = sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -14621,22 +14621,22 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleClosure: ((URL, URL, ImageInfo, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)? var sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleClosure: ((URL, URL, ImageInfo, String?, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)?
func sendImage(url: URL, thumbnailURL: URL, imageInfo: ImageInfo, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { func sendImage(url: URL, thumbnailURL: URL, imageInfo: ImageInfo, caption: String?, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleCallsCount += 1 sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleCallsCount += 1
if let sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleClosure = sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleClosure { if let sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleClosure = sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleClosure {
return await sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleClosure(url, thumbnailURL, imageInfo, progressSubject, requestHandle) return await sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleClosure(url, thumbnailURL, imageInfo, caption, progressSubject, requestHandle)
} else { } else {
return sendImageUrlThumbnailURLImageInfoProgressSubjectRequestHandleReturnValue return sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleReturnValue
} }
} }
//MARK: - sendLocation //MARK: - sendLocation
@ -14711,15 +14711,15 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
//MARK: - sendVideo //MARK: - sendVideo
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingCallsCount = 0 var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = 0
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleCallsCount: Int { var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingCallsCount return sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingCallsCount returnValue = sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -14727,27 +14727,27 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingCallsCount = newValue sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleCalled: Bool { var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleCalled: Bool {
return sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleCallsCount > 0 return sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleCallsCount > 0
} }
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>! var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! { var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleReturnValue: Result<Void, TimelineProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingReturnValue return sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, TimelineProxyError>? = nil var returnValue: Result<Void, TimelineProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingReturnValue returnValue = sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -14755,22 +14755,22 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleUnderlyingReturnValue = newValue sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleClosure: ((URL, URL, VideoInfo, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)? var sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleClosure: ((URL, URL, VideoInfo, String?, CurrentValueSubject<Double, Never>?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>)?
func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { func sendVideo(url: URL, thumbnailURL: URL, videoInfo: VideoInfo, caption: String?, progressSubject: CurrentValueSubject<Double, Never>?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleCallsCount += 1 sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleCallsCount += 1
if let sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleClosure = sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleClosure { if let sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleClosure = sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleClosure {
return await sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleClosure(url, thumbnailURL, videoInfo, progressSubject, requestHandle) return await sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleClosure(url, thumbnailURL, videoInfo, caption, progressSubject, requestHandle)
} else { } else {
return sendVideoUrlThumbnailURLVideoInfoProgressSubjectRequestHandleReturnValue return sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleReturnValue
} }
} }
//MARK: - sendVoiceMessage //MARK: - sendVoiceMessage

View File

@ -49,22 +49,8 @@ extension JoinedRoomProxyMock {
id = configuration.id id = configuration.id
isEncrypted = configuration.isEncrypted isEncrypted = configuration.isEncrypted
let timeline = TimelineProxyMock() timeline = TimelineProxyMock(.init(isAutoUpdating: configuration.shouldUseAutoUpdatingTimeline,
timeline.sendMessageEventContentReturnValue = .success(()) timelineStartReached: configuration.timelineStartReached))
timeline.paginateBackwardsRequestSizeReturnValue = .success(())
timeline.paginateForwardsRequestSizeReturnValue = .success(())
timeline.sendReadReceiptForTypeReturnValue = .success(())
if configuration.shouldUseAutoUpdatingTimeline {
timeline.underlyingTimelineProvider = AutoUpdatingRoomTimelineProviderMock()
} else {
let timelineProvider = RoomTimelineProviderMock()
timelineProvider.paginationState = .init(backward: configuration.timelineStartReached ? .timelineEndReached : .idle, forward: .timelineEndReached)
timelineProvider.underlyingMembershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
timeline.underlyingTimelineProvider = timelineProvider
}
self.timeline = timeline
ownUserID = configuration.ownUserID ownUserID = configuration.ownUserID

View File

@ -0,0 +1,35 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Foundation
extension TimelineProxyMock {
struct Configuration {
var isAutoUpdating = false
var timelineStartReached = false
}
@MainActor
convenience init(_ configuration: Configuration) {
self.init()
sendMessageEventContentReturnValue = .success(())
paginateBackwardsRequestSizeReturnValue = .success(())
paginateForwardsRequestSizeReturnValue = .success(())
sendReadReceiptForTypeReturnValue = .success(())
if configuration.isAutoUpdating {
underlyingTimelineProvider = AutoUpdatingRoomTimelineProviderMock()
} else {
let timelineProvider = RoomTimelineProviderMock()
timelineProvider.paginationState = .init(backward: configuration.timelineStartReached ? .timelineEndReached : .idle, forward: .timelineEndReached)
timelineProvider.underlyingMembershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
underlyingTimelineProvider = timelineProvider
}
}
}

View File

@ -50,7 +50,7 @@ struct BigIcon: View {
var style: Style = .defaultSolid var style: Style = .defaultSolid
var body: some View { var body: some View {
CompoundIcon(icon, size: .custom(32), relativeTo: .title) CompoundIcon(icon, size: .custom(32), relativeTo: .compound.headingLG)
.modifier(BigIconModifier(style: style)) .modifier(BigIconModifier(style: style))
} }
} }
@ -62,7 +62,7 @@ extension Image {
resizable() resizable()
.renderingMode(.template) .renderingMode(.template)
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.scaledPadding(insets, relativeTo: .title) .scaledPadding(insets, relativeTo: .compound.headingLG)
.modifier(BigIconModifier(style: style)) .modifier(BigIconModifier(style: style))
} }
} }
@ -72,7 +72,7 @@ private struct BigIconModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.scaledFrame(size: 64, relativeTo: .title) .scaledFrame(size: 64, relativeTo: .compound.headingLG)
.foregroundColor(style.foregroundColor) .foregroundColor(style.foregroundColor)
.background { .background {
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 14)

View File

@ -15,6 +15,13 @@ struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL let url: URL
let title: String? let title: String?
var shouldDisableInteraction = false var shouldDisableInteraction = false
var bindings = MediaUploadPreviewScreenBindings()
}
struct MediaUploadPreviewScreenBindings: BindableState {
var caption = NSAttributedString()
var presendCallback: (() -> Void)?
} }
enum MediaUploadPreviewScreenViewAction { enum MediaUploadPreviewScreenViewAction {

View File

@ -42,6 +42,9 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
} }
override func process(viewAction: MediaUploadPreviewScreenViewAction) { override func process(viewAction: MediaUploadPreviewScreenViewAction) {
// Get the current caption before all the processing starts.
let caption = state.bindings.caption.nonBlankString
switch viewAction { switch viewAction {
case .send: case .send:
Task { Task {
@ -51,7 +54,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
switch await mediaUploadingPreprocessor.processMedia(at: url) { switch await mediaUploadingPreprocessor.processMedia(at: url) {
case .success(let mediaInfo): case .success(let mediaInfo):
switch await sendAttachment(mediaInfo: mediaInfo, progressSubject: progressSubject) { switch await sendAttachment(mediaInfo: mediaInfo, caption: caption, progressSubject: progressSubject) {
case .success: case .success:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
case .failure(let error): case .failure(let error):
@ -75,20 +78,38 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
// MARK: - Private // MARK: - Private
private func sendAttachment(mediaInfo: MediaInfo, progressSubject: CurrentValueSubject<Double, Never>?) async -> Result<Void, TimelineProxyError> { private func sendAttachment(mediaInfo: MediaInfo, caption: String?, progressSubject: CurrentValueSubject<Double, Never>?) async -> Result<Void, TimelineProxyError> {
let requestHandle: ((SendAttachmentJoinHandleProtocol) -> Void) = { [weak self] handle in let requestHandle: ((SendAttachmentJoinHandleProtocol) -> Void) = { [weak self] handle in
self?.requestHandle = handle self?.requestHandle = handle
} }
switch mediaInfo { switch mediaInfo {
case let .image(imageURL, thumbnailURL, imageInfo): case let .image(imageURL, thumbnailURL, imageInfo):
return await roomProxy.timeline.sendImage(url: imageURL, thumbnailURL: thumbnailURL, imageInfo: imageInfo, progressSubject: progressSubject, requestHandle: requestHandle) return await roomProxy.timeline.sendImage(url: imageURL,
thumbnailURL: thumbnailURL,
imageInfo: imageInfo,
caption: caption,
progressSubject: progressSubject,
requestHandle: requestHandle)
case let .video(videoURL, thumbnailURL, videoInfo): case let .video(videoURL, thumbnailURL, videoInfo):
return await roomProxy.timeline.sendVideo(url: videoURL, thumbnailURL: thumbnailURL, videoInfo: videoInfo, progressSubject: progressSubject, requestHandle: requestHandle) return await roomProxy.timeline.sendVideo(url: videoURL,
thumbnailURL: thumbnailURL,
videoInfo: videoInfo,
caption: caption,
progressSubject: progressSubject,
requestHandle: requestHandle)
case let .audio(audioURL, audioInfo): case let .audio(audioURL, audioInfo):
return await roomProxy.timeline.sendAudio(url: audioURL, audioInfo: audioInfo, progressSubject: progressSubject, requestHandle: requestHandle) return await roomProxy.timeline.sendAudio(url: audioURL,
audioInfo: audioInfo,
caption: caption,
progressSubject: progressSubject,
requestHandle: requestHandle)
case let .file(fileURL, fileInfo): case let .file(fileURL, fileInfo):
return await roomProxy.timeline.sendFile(url: fileURL, fileInfo: fileInfo, progressSubject: progressSubject, requestHandle: requestHandle) return await roomProxy.timeline.sendFile(url: fileURL,
fileInfo: fileInfo,
caption: caption,
progressSubject: progressSubject,
requestHandle: requestHandle)
} }
} }
@ -118,3 +139,10 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
userIndicatorController.submitIndicator(UserIndicator(title: label)) userIndicatorController.submitIndicator(UserIndicator(title: label))
} }
} }
extension NSAttributedString {
var nonBlankString: String? {
guard !string.isBlank else { return nil }
return string
}
}

View File

@ -5,6 +5,7 @@
// Please see LICENSE in the repository root for full details. // Please see LICENSE in the repository root for full details.
// //
import Compound
import QuickLook import QuickLook
import SwiftUI import SwiftUI
@ -17,13 +18,20 @@ struct MediaUploadPreviewScreen: View {
var body: some View { var body: some View {
mainContent mainContent
.id(UUID()) .id(context.viewState.url)
.ignoresSafeArea(edges: [.horizontal])
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.padding(.horizontal, 12)
.padding(.vertical, 16)
.background() // Don't use compound so we match the QLPreviewController.
}
.navigationTitle(title) .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.disabled(context.viewState.shouldDisableInteraction)
.ignoresSafeArea(edges: [.horizontal, .bottom])
.toolbar { toolbar } .toolbar { toolbar }
.disabled(context.viewState.shouldDisableInteraction)
.interactiveDismissDisabled() .interactiveDismissDisabled()
.preferredColorScheme(.dark)
} }
@ViewBuilder @ViewBuilder
@ -38,18 +46,31 @@ struct MediaUploadPreviewScreen: View {
} }
} }
private var composer: some View {
HStack(spacing: 12) {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerCaptionPlaceholder,
text: $context.caption,
presendCallback: $context.presendCallback,
maxHeight: ComposerConstant.maxHeight,
keyHandler: { _ in },
pasteHandler: { _ in })
.messageComposerStyle()
SendButton {
context.send(viewAction: .send)
}
}
}
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .cancel) } label: { Button { context.send(viewAction: .cancel) } label: {
Text(L10n.actionCancel) Text(L10n.actionCancel)
} }
} // Fix a bug with the preferredColorScheme on iOS 18 where the button doesn't
ToolbarItem(placement: .confirmationAction) { // follow the dark colour scheme on devices running with dark mode disabled.
Button { context.send(viewAction: .send) } label: { .tint(.compound.textActionPrimary)
Text(L10n.actionSend)
}
.disabled(context.viewState.shouldDisableInteraction)
} }
} }
} }
@ -111,21 +132,6 @@ private class PreviewItem: NSObject, QLPreviewItem {
} }
} }
// MARK: - Previews
struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock.default,
roomProxy: JoinedRoomProxyMock(),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
title: "some random file name",
url: URL.picturesDirectory)
static var previews: some View {
NavigationStack {
MediaUploadPreviewScreen(context: viewModel.context)
}
}
}
private class PreviewViewController: QLPreviewController { private class PreviewViewController: QLPreviewController {
override func viewWillLayoutSubviews() { override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews() super.viewWillLayoutSubviews()
@ -137,3 +143,21 @@ private class PreviewViewController: QLPreviewController {
toolbarItems?.first?.isHidden = true toolbarItems?.first?.isHidden = true
} }
} }
// MARK: - Previews
struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let snapshotURL = URL.picturesDirectory
static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png")
static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock.default,
roomProxy: JoinedRoomProxyMock(),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
title: "App Icon.png",
url: snapshotURL)
static var previews: some View {
NavigationStack {
MediaUploadPreviewScreen(context: viewModel.context)
}
}
}

View File

@ -62,7 +62,7 @@ struct ComposerToolbar: View {
if !context.composerFormattingEnabled { if !context.composerFormattingEnabled {
if context.viewState.isUploading { if context.viewState.isUploading {
ProgressView() ProgressView()
.scaledFrame(size: 44, relativeTo: .title) .scaledFrame(size: 44, relativeTo: .compound.headingLG)
.padding(.leading, 3) .padding(.leading, 3)
} else if context.viewState.showSendButton { } else if context.viewState.showSendButton {
sendButton sendButton
@ -119,27 +119,29 @@ struct ComposerToolbar: View {
Image(Asset.Images.closeRte.name) Image(Asset.Images.closeRte.name)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.scaledFrame(size: 30, relativeTo: .title) .scaledFrame(size: 30, relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .title) .scaledPadding(7, relativeTo: .compound.headingLG)
} }
.accessibilityLabel(L10n.actionClose) .accessibilityLabel(L10n.actionClose)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions) .accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
} }
private var sendButton: some View { private var sendButton: some View {
Button { Group {
sendMessage() if context.viewState.composerMode.isEdit {
} label: { Button(action: sendMessage) {
CompoundIcon(context.viewState.composerMode.isEdit ? \.check : \.sendSolid) CompoundIcon(\.check, size: .medium, relativeTo: .compound.headingLG)
.scaledPadding(6, relativeTo: .title) .foregroundColor(.white)
.accessibilityLabel(context.viewState.composerMode.isEdit ? L10n.actionConfirm : L10n.actionSend) .scaledPadding(6, relativeTo: .compound.headingLG)
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .white) .background(.compound.iconAccentTertiary, in: Circle())
.background { .accessibilityLabel(L10n.actionConfirm)
Circle()
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
} }
.scaledPadding(4, relativeTo: .title) } else {
SendButton(action: sendMessage)
.accessibilityLabel(L10n.actionSend)
} }
}
.scaledPadding(4, relativeTo: .compound.headingLG)
.disabled(context.viewState.sendButtonDisabled) .disabled(context.viewState.sendButtonDisabled)
.animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled) .animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled)
.keyboardShortcut(.return, modifiers: [.command]) .keyboardShortcut(.return, modifiers: [.command])
@ -271,8 +273,8 @@ struct ComposerToolbar: View {
} label: { } label: {
CompoundIcon(\.delete) CompoundIcon(\.delete)
.scaledToFit() .scaledToFit()
.scaledFrame(size: 30, relativeTo: .title) .scaledFrame(size: 30, relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .title) .scaledPadding(7, relativeTo: .compound.headingLG)
} }
.buttonStyle(.compound(.plain)) .buttonStyle(.compound(.plain))
.accessibilityLabel(L10n.a11yDelete) .accessibilityLabel(L10n.a11yDelete)
@ -291,6 +293,8 @@ struct ComposerToolbar: View {
} }
} }
// MARK: - Previews
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
static let wysiwygViewModel = WysiwygComposerViewModel() static let wysiwygViewModel = WysiwygComposerViewModel()
static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
@ -330,8 +334,6 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
} }
} }
// MARK: - Mock
extension ComposerToolbar { extension ComposerToolbar {
static func mock(focused: Bool = true) -> ComposerToolbar { static func mock(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel() let wysiwygViewModel = WysiwygComposerViewModel()

View File

@ -35,17 +35,8 @@ struct MessageComposer: View {
resizeGrabber resizeGrabber
} }
mainContent composerTextField
.padding(.horizontal, 12.0) .messageComposerStyle(header: header)
.clipShape(composerShape)
.background {
ZStack {
composerShape
.fill(Color.compound.bgSubtleSecondary)
composerShape
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
}
}
// Explicitly disable all animations to fix weirdness with the header immediately // Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it. // appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: mode) .animation(.noAnimation, value: mode)
@ -57,10 +48,8 @@ struct MessageComposer: View {
@State private var composerFrame = CGRect.zero @State private var composerFrame = CGRect.zero
private var mainContent: some View { @ViewBuilder
VStack(alignment: .leading, spacing: -6) { private var composerTextField: some View {
header
if composerFormattingEnabled { if composerFormattingEnabled {
Color.clear Color.clear
.overlay(alignment: .top) { .overlay(alignment: .top) {
@ -70,8 +59,6 @@ struct MessageComposer: View {
} }
.frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height), .frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height),
alignment: .top) alignment: .top)
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
.onAppear { .onAppear {
onAppearAction() onAppearAction()
} }
@ -82,9 +69,6 @@ struct MessageComposer: View {
maxHeight: ComposerConstant.maxHeight, maxHeight: ComposerConstant.maxHeight,
keyHandler: { handleKeyPress($0) }, keyHandler: { handleKeyPress($0) },
pasteHandler: pasteAction) pasteHandler: pasteAction)
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
}
} }
} }
@ -200,6 +184,42 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
} }
} }
// MARK: - Style
extension View {
func messageComposerStyle(header: some View = EmptyView()) -> some View {
modifier(MessageComposerStyleModifier(header: header))
}
}
private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
let header: Header
func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) {
header
content
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
}
.padding(.horizontal, 12.0)
.clipShape(composerShape)
.background {
ZStack {
composerShape
.fill(Color.compound.bgSubtleSecondary)
composerShape
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
}
}
}
}
// MARK: - Previews
struct MessageComposer_Previews: PreviewProvider, TestablePreview { struct MessageComposer_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock static let viewModel = TimelineViewModel.mock

View File

@ -18,8 +18,8 @@ struct RoomAttachmentPicker: View {
Menu { Menu {
menuContent menuContent
} label: { } label: {
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .title) CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .title) .scaledPadding(7, relativeTo: .compound.headingLG)
} }
.buttonStyle(RoomAttachmentPickerButtonStyle()) .buttonStyle(RoomAttachmentPickerButtonStyle())
.accessibilityLabel(L10n.actionAddToTimeline) .accessibilityLabel(L10n.actionAddToTimeline)

View File

@ -32,18 +32,15 @@ struct VoiceMessageRecordingButton: View {
} label: { } label: {
switch mode { switch mode {
case .idle: case .idle:
CompoundIcon(\.micOn, size: .medium, relativeTo: .title) CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG)
.foregroundColor(.compound.iconSecondary) .foregroundColor(.compound.iconSecondary)
.scaledPadding(10, relativeTo: .title) .scaledPadding(10, relativeTo: .compound.headingLG)
case .recording: case .recording:
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .title) CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG)
.foregroundColor(.compound.iconOnSolidPrimary) .foregroundColor(.compound.iconOnSolidPrimary)
.scaledPadding(6, relativeTo: .title) .scaledPadding(6, relativeTo: .compound.headingLG)
.background( .background(.compound.bgActionPrimaryRest, in: Circle())
Circle() .scaledPadding(4, relativeTo: .compound.headingLG)
.foregroundColor(.compound.bgActionPrimaryRest)
)
.scaledPadding(4, relativeTo: .title)
} }
} }
.buttonStyle(VoiceMessageRecordingButtonStyle()) .buttonStyle(VoiceMessageRecordingButtonStyle())

View File

@ -223,13 +223,14 @@ final class TimelineProxy: TimelineProxyProtocol {
func sendAudio(url: URL, func sendAudio(url: URL,
audioInfo: AudioInfo, audioInfo: AudioInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending audio") MXLog.info("Sending audio")
let handle = timeline.sendAudio(url: url.path(percentEncoded: false), let handle = timeline.sendAudio(url: url.path(percentEncoded: false),
audioInfo: audioInfo, audioInfo: audioInfo,
caption: nil, caption: caption,
formattedCaption: nil, formattedCaption: nil,
progressWatcher: UploadProgressListener { progress in progressWatcher: UploadProgressListener { progress in
progressSubject?.send(progress) progressSubject?.send(progress)
@ -251,13 +252,14 @@ final class TimelineProxy: TimelineProxyProtocol {
func sendFile(url: URL, func sendFile(url: URL,
fileInfo: FileInfo, fileInfo: FileInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending file") MXLog.info("Sending file")
let handle = timeline.sendFile(url: url.path(percentEncoded: false), let handle = timeline.sendFile(url: url.path(percentEncoded: false),
fileInfo: fileInfo, fileInfo: fileInfo,
caption: nil, caption: caption,
formattedCaption: nil, formattedCaption: nil,
progressWatcher: UploadProgressListener { progress in progressWatcher: UploadProgressListener { progress in
progressSubject?.send(progress) progressSubject?.send(progress)
@ -277,9 +279,11 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(()) return .success(())
} }
// swiftlint:disable:next function_parameter_count
func sendImage(url: URL, func sendImage(url: URL,
thumbnailURL: URL, thumbnailURL: URL,
imageInfo: ImageInfo, imageInfo: ImageInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending image") MXLog.info("Sending image")
@ -287,7 +291,7 @@ final class TimelineProxy: TimelineProxyProtocol {
let handle = timeline.sendImage(url: url.path(percentEncoded: false), let handle = timeline.sendImage(url: url.path(percentEncoded: false),
thumbnailUrl: thumbnailURL.path(percentEncoded: false), thumbnailUrl: thumbnailURL.path(percentEncoded: false),
imageInfo: imageInfo, imageInfo: imageInfo,
caption: nil, caption: caption,
formattedCaption: nil, formattedCaption: nil,
progressWatcher: UploadProgressListener { progress in progressWatcher: UploadProgressListener { progress in
progressSubject?.send(progress) progressSubject?.send(progress)
@ -325,9 +329,11 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(()) return .success(())
} }
// swiftlint:disable:next function_parameter_count
func sendVideo(url: URL, func sendVideo(url: URL,
thumbnailURL: URL, thumbnailURL: URL,
videoInfo: VideoInfo, videoInfo: VideoInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> { requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> {
MXLog.info("Sending video") MXLog.info("Sending video")
@ -335,7 +341,7 @@ final class TimelineProxy: TimelineProxyProtocol {
let handle = timeline.sendVideo(url: url.path(percentEncoded: false), let handle = timeline.sendVideo(url: url.path(percentEncoded: false),
thumbnailUrl: thumbnailURL.path(percentEncoded: false), thumbnailUrl: thumbnailURL.path(percentEncoded: false),
videoInfo: videoInfo, videoInfo: videoInfo,
caption: nil, caption: caption,
formattedCaption: nil, formattedCaption: nil,
progressWatcher: UploadProgressListener { progress in progressWatcher: UploadProgressListener { progress in
progressSubject?.send(progress) progressSubject?.send(progress)

View File

@ -51,17 +51,21 @@ protocol TimelineProxyProtocol {
func sendAudio(url: URL, func sendAudio(url: URL,
audioInfo: AudioInfo, audioInfo: AudioInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>
func sendFile(url: URL, func sendFile(url: URL,
fileInfo: FileInfo, fileInfo: FileInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>
// swiftlint:disable:next function_parameter_count
func sendImage(url: URL, func sendImage(url: URL,
thumbnailURL: URL, thumbnailURL: URL,
imageInfo: ImageInfo, imageInfo: ImageInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>
@ -71,9 +75,11 @@ protocol TimelineProxyProtocol {
zoomLevel: UInt8?, zoomLevel: UInt8?,
assetType: AssetType?) async -> Result<Void, TimelineProxyError> assetType: AssetType?) async -> Result<Void, TimelineProxyError>
// swiftlint:disable:next function_parameter_count
func sendVideo(url: URL, func sendVideo(url: URL,
thumbnailURL: URL, thumbnailURL: URL,
videoInfo: VideoInfo, videoInfo: VideoInfo,
caption: String?,
progressSubject: CurrentValueSubject<Double, Never>?, progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError> requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, TimelineProxyError>

View File

@ -10,4 +10,135 @@ import XCTest
@testable import ElementX @testable import ElementX
@MainActor @MainActor
class MediaUploadPreviewScreenViewModelTests: XCTestCase { } class MediaUploadPreviewScreenViewModelTests: XCTestCase {
var timelineProxy: TimelineProxyMock!
var viewModel: MediaUploadPreviewScreenViewModel!
var context: MediaUploadPreviewScreenViewModel.Context { viewModel.context }
enum TestError: Swift.Error {
case unexpectedParameter
case unknown
}
override func setUp() {
AppSettings.resetAllSettings()
let appSettings = AppSettings()
appSettings.optimizeMediaUploads = false
ServiceLocator.shared.register(appSettings: appSettings)
}
deinit {
AppSettings.resetAllSettings()
}
func testImageUploadWithoutCaption() async throws {
setUpViewModel(url: imageURL, expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testImageUploadWithBlankCaption() async throws {
setUpViewModel(url: imageURL, expectedCaption: nil)
context.caption = .init(" ")
try await send()
}
func testImageUploadWithCaption() async throws {
let caption = "This is a really great image!"
setUpViewModel(url: imageURL, expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testVideoUploadWithoutCaption() async throws {
setUpViewModel(url: videoURL, expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testVideoUploadWithCaption() async throws {
let caption = "Check out this video!"
setUpViewModel(url: videoURL, expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testAudioUploadWithoutCaption() async throws {
setUpViewModel(url: audioURL, expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testAudioUploadWithCaption() async throws {
let caption = "Listen to this!"
setUpViewModel(url: audioURL, expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
func testFileUploadWithoutCaption() async throws {
setUpViewModel(url: fileURL, expectedCaption: nil)
context.caption = .init("")
try await send()
}
func testFileUploadWithCaption() async throws {
let caption = "Please will you check my article."
setUpViewModel(url: fileURL, expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
// MARK: - Helpers
private var audioURL: URL { assertResourceURL(filename: "test_audio.mp3") }
private var fileURL: URL { assertResourceURL(filename: "test_pdf.pdf") }
private var imageURL: URL { assertResourceURL(filename: "test_animated_image.gif") }
private var videoURL: URL { assertResourceURL(filename: "landscape_test_video.mov") }
private func assertResourceURL(filename: String) -> URL {
guard let url = Bundle(for: Self.self).url(forResource: filename, withExtension: nil) else {
XCTFail("Failed retrieving test asset")
return .picturesDirectory
}
return url
}
private func setUpViewModel(url: URL, expectedCaption: String?) {
timelineProxy = TimelineProxyMock(.init())
timelineProxy.sendAudioUrlAudioInfoCaptionProgressSubjectRequestHandleClosure = { [weak self] _, _, caption, _, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendFileUrlFileInfoCaptionProgressSubjectRequestHandleClosure = { [weak self] _, _, caption, _, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionProgressSubjectRequestHandleClosure = { [weak self] _, _, _, caption, _, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionProgressSubjectRequestHandleClosure = { [weak self] _, _, _, caption, _, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
let roomProxy = JoinedRoomProxyMock(.init())
roomProxy.timeline = timelineProxy
viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock(),
roomProxy: roomProxy,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
title: "Some File",
url: url)
}
private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> {
guard caption == expectedCaption else {
XCTFail("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'").self
return .failure(.sdkError(TestError.unexpectedParameter))
}
return .success(())
}
private func send() async throws {
let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
try await deferred.fulfill()
}
}