From cefa38049f8edd01b993e8057e0f99aeb6e204b1 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:19:38 +0200 Subject: [PATCH] Store and restore drafts (#2898) --- ElementX.xcodeproj/project.pbxproj | 16 + .../Sources/Application/AppSettings.swift | 4 + .../RoomFlowCoordinator.swift | 5 +- .../Mocks/Generated/GeneratedMocks.swift | 531 ++++++++++++++++++ ElementX/Sources/Mocks/RoomProxyMock.swift | 2 + .../ComposerToolbarViewModel.swift | 122 +++- .../View/ComposerToolbar.swift | 44 +- .../View/RoomAttachmentPicker.swift | 3 +- .../RoomScreen/RoomScreenCoordinator.swift | 39 +- .../RoomScreenInteractionHandler.swift | 12 +- .../Screens/RoomScreen/RoomScreenModels.swift | 27 +- .../RoomScreen/RoomScreenViewModel.swift | 11 + .../RoomScreenViewModelProtocol.swift | 2 + .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 6 + .../ComposerDraft/ComposerDraftService.swift | 74 +++ .../ComposerDraftServiceProtocol.swift | 80 +++ .../Sources/Services/Room/RoomProxy.swift | 31 + .../Services/Room/RoomProxyProtocol.swift | 8 +- .../TimelineItemReplyDetails.swift | 5 + .../RoomTimelineItemFactory.swift | 52 +- .../RoomTimelineItemFactoryProtocol.swift | 3 + .../Services/Timeline/TimelineProxy.swift | 9 + .../Timeline/TimelineProxyProtocol.swift | 2 + .../UITests/UITestsAppCoordinator.swift | 39 +- .../test_composerToolbar-iPad-en-GB.Reply.png | 3 + ...test_composerToolbar-iPad-pseudo.Reply.png | 3 + ..._composerToolbar-iPhone-15-en-GB.Reply.png | 3 + ...composerToolbar-iPhone-15-pseudo.Reply.png | 3 + .../ComposerToolbarViewModelTests.swift | 306 +++++++++- changelog.d/2849.feature | 1 + 31 files changed, 1384 insertions(+), 63 deletions(-) create mode 100644 ElementX/Sources/Services/ComposerDraft/ComposerDraftService.swift create mode 100644 ElementX/Sources/Services/ComposerDraft/ComposerDraftServiceProtocol.swift create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png create mode 100644 changelog.d/2849.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index be25dca9b..f8b11bddb 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -245,6 +245,7 @@ 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; }; 3A64A93A651A3CB8774ADE8E /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; }; 3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */; }; + 3AA9E878FDCFF85664AC071F /* ComposerDraftService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */; }; 3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; }; 3B28408450BCAED911283AA2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; }; 3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; }; @@ -849,6 +850,7 @@ CA12AE0DCD57D49CD96C699A /* WaveformCursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */; }; CB137BFB3E083C33E398A6CB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 020597E28A4BC8E1BE8EDF6E /* KeychainAccess */; }; CB498F4E27AA0545DCEF0F6F /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 36B7FC232711031AA2B0D188 /* DTCoreText */; }; + CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */; }; CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */; }; CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */; }; @@ -1253,6 +1255,7 @@ 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = ""; }; 1D652E78832289CD9EB64488 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = ""; }; 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDraftService.swift; sourceTree = ""; }; 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModel.swift; sourceTree = ""; }; 1DB2FC2AA9A07EE792DF65CF /* NotificationPermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenModels.swift; sourceTree = ""; }; 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = ""; }; @@ -1787,6 +1790,7 @@ A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A7D452AF7B5F7E3A0A7DB54C /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = ""; }; + A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDraftServiceProtocol.swift; sourceTree = ""; }; A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenCoordinator.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8DF55467ED4CE76B7AE9A33 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2343,6 +2347,7 @@ AAFDD509929A0CCF8BCE51EB /* Authentication */, 0ED3F5C21537519389C07644 /* BugReport */, 8039515BAA53B7C3275AC64A /* Client */, + 8B5E91450E85A9689931B221 /* ComposerDraft */, 8C3BAE06B336D97DABBE2509 /* CreateRoom */, 92E99C57D7F92ED16F73282C /* ElementCall */, 39557ADF21345E18F3865B9E /* Emojis */, @@ -3988,6 +3993,15 @@ path = Sounds; sourceTree = ""; }; + 8B5E91450E85A9689931B221 /* ComposerDraft */ = { + isa = PBXGroup; + children = ( + 1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */, + A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */, + ); + path = ComposerDraft; + sourceTree = ""; + }; 8C3BAE06B336D97DABBE2509 /* CreateRoom */ = { isa = PBXGroup; children = ( @@ -5975,6 +5989,8 @@ 19DF5600A7F547B22DD7872A /* CompletionSuggestionService.swift in Sources */, EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */, 16E4F1B8B9BFE1367F96DDA7 /* CompletionSuggestionView.swift in Sources */, + 3AA9E878FDCFF85664AC071F /* ComposerDraftService.swift in Sources */, + CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */, 937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */, 94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */, 71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 42fc5b2b2..33a970149 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -44,6 +44,7 @@ final class AppSettings { // Feature flags case publicSearchEnabled + case draftRestoringEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -268,6 +269,9 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.publicSearchEnabled, defaultValue: isDevelopmentBuild, storageType: .volatile) var publicSearchEnabled + + @UserPreference(key: UserDefaultsKeys.draftRestoringEnabled, defaultValue: false, storageType: .userDefaults(store)) + var draftRestoringEnabled #endif diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index d8ca88776..50a7bb880 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -549,6 +549,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy) + let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory) + let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, focussedEventID: focussedEventID, timelineController: timelineController, @@ -558,7 +560,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { emojiProvider: emojiProvider, completionSuggestionService: completionSuggestionService, appMediator: appMediator, - appSettings: appSettings) + appSettings: appSettings, + composerDraftService: composerDraftService) let coordinator = RoomScreenCoordinator(parameters: parameters) coordinator.actions diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 3d36aa88c..d5eb22755 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4415,6 +4415,273 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol { setSuggestionTriggerClosure?(suggestionTrigger) } } +class ComposerDraftServiceMock: ComposerDraftServiceProtocol { + + //MARK: - saveDraft + + var saveDraftUnderlyingCallsCount = 0 + var saveDraftCallsCount: Int { + get { + if Thread.isMainThread { + return saveDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = saveDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + saveDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + saveDraftUnderlyingCallsCount = newValue + } + } + } + } + var saveDraftCalled: Bool { + return saveDraftCallsCount > 0 + } + var saveDraftReceivedDraft: ComposerDraftProxy? + var saveDraftReceivedInvocations: [ComposerDraftProxy] = [] + + var saveDraftUnderlyingReturnValue: Result! + var saveDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return saveDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = saveDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + saveDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + saveDraftUnderlyingReturnValue = newValue + } + } + } + } + var saveDraftClosure: ((ComposerDraftProxy) async -> Result)? + + func saveDraft(_ draft: ComposerDraftProxy) async -> Result { + saveDraftCallsCount += 1 + saveDraftReceivedDraft = draft + saveDraftReceivedInvocations.append(draft) + if let saveDraftClosure = saveDraftClosure { + return await saveDraftClosure(draft) + } else { + return saveDraftReturnValue + } + } + //MARK: - loadDraft + + var loadDraftUnderlyingCallsCount = 0 + var loadDraftCallsCount: Int { + get { + if Thread.isMainThread { + return loadDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = loadDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + loadDraftUnderlyingCallsCount = newValue + } + } + } + } + var loadDraftCalled: Bool { + return loadDraftCallsCount > 0 + } + + var loadDraftUnderlyingReturnValue: Result! + var loadDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return loadDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = loadDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + loadDraftUnderlyingReturnValue = newValue + } + } + } + } + var loadDraftClosure: (() async -> Result)? + + func loadDraft() async -> Result { + loadDraftCallsCount += 1 + if let loadDraftClosure = loadDraftClosure { + return await loadDraftClosure() + } else { + return loadDraftReturnValue + } + } + //MARK: - clearDraft + + var clearDraftUnderlyingCallsCount = 0 + var clearDraftCallsCount: Int { + get { + if Thread.isMainThread { + return clearDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = clearDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + clearDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + clearDraftUnderlyingCallsCount = newValue + } + } + } + } + var clearDraftCalled: Bool { + return clearDraftCallsCount > 0 + } + + var clearDraftUnderlyingReturnValue: Result! + var clearDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return clearDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = clearDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + clearDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + clearDraftUnderlyingReturnValue = newValue + } + } + } + } + var clearDraftClosure: (() async -> Result)? + + func clearDraft() async -> Result { + clearDraftCallsCount += 1 + if let clearDraftClosure = clearDraftClosure { + return await clearDraftClosure() + } else { + return clearDraftReturnValue + } + } + //MARK: - getReply + + var getReplyEventIDUnderlyingCallsCount = 0 + var getReplyEventIDCallsCount: Int { + get { + if Thread.isMainThread { + return getReplyEventIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = getReplyEventIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + getReplyEventIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + getReplyEventIDUnderlyingCallsCount = newValue + } + } + } + } + var getReplyEventIDCalled: Bool { + return getReplyEventIDCallsCount > 0 + } + var getReplyEventIDReceivedEventID: String? + var getReplyEventIDReceivedInvocations: [String] = [] + + var getReplyEventIDUnderlyingReturnValue: Result! + var getReplyEventIDReturnValue: Result! { + get { + if Thread.isMainThread { + return getReplyEventIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = getReplyEventIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + getReplyEventIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + getReplyEventIDUnderlyingReturnValue = newValue + } + } + } + } + var getReplyEventIDClosure: ((String) async -> Result)? + + func getReply(eventID: String) async -> Result { + getReplyEventIDCallsCount += 1 + getReplyEventIDReceivedEventID = eventID + getReplyEventIDReceivedInvocations.append(eventID) + if let getReplyEventIDClosure = getReplyEventIDClosure { + return await getReplyEventIDClosure(eventID) + } else { + return getReplyEventIDReturnValue + } + } +} class ElementCallServiceMock: ElementCallServiceProtocol { var actions: AnyPublisher { get { return underlyingActions } @@ -10387,6 +10654,202 @@ class RoomProxyMock: RoomProxyProtocol { return matrixToEventPermalinkReturnValue } } + //MARK: - saveDraft + + var saveDraftUnderlyingCallsCount = 0 + var saveDraftCallsCount: Int { + get { + if Thread.isMainThread { + return saveDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = saveDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + saveDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + saveDraftUnderlyingCallsCount = newValue + } + } + } + } + var saveDraftCalled: Bool { + return saveDraftCallsCount > 0 + } + var saveDraftReceivedDraft: ComposerDraft? + var saveDraftReceivedInvocations: [ComposerDraft] = [] + + var saveDraftUnderlyingReturnValue: Result! + var saveDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return saveDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = saveDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + saveDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + saveDraftUnderlyingReturnValue = newValue + } + } + } + } + var saveDraftClosure: ((ComposerDraft) async -> Result)? + + func saveDraft(_ draft: ComposerDraft) async -> Result { + saveDraftCallsCount += 1 + saveDraftReceivedDraft = draft + saveDraftReceivedInvocations.append(draft) + if let saveDraftClosure = saveDraftClosure { + return await saveDraftClosure(draft) + } else { + return saveDraftReturnValue + } + } + //MARK: - loadDraft + + var loadDraftUnderlyingCallsCount = 0 + var loadDraftCallsCount: Int { + get { + if Thread.isMainThread { + return loadDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = loadDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + loadDraftUnderlyingCallsCount = newValue + } + } + } + } + var loadDraftCalled: Bool { + return loadDraftCallsCount > 0 + } + + var loadDraftUnderlyingReturnValue: Result! + var loadDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return loadDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = loadDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + loadDraftUnderlyingReturnValue = newValue + } + } + } + } + var loadDraftClosure: (() async -> Result)? + + func loadDraft() async -> Result { + loadDraftCallsCount += 1 + if let loadDraftClosure = loadDraftClosure { + return await loadDraftClosure() + } else { + return loadDraftReturnValue + } + } + //MARK: - clearDraft + + var clearDraftUnderlyingCallsCount = 0 + var clearDraftCallsCount: Int { + get { + if Thread.isMainThread { + return clearDraftUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = clearDraftUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + clearDraftUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + clearDraftUnderlyingCallsCount = newValue + } + } + } + } + var clearDraftCalled: Bool { + return clearDraftCallsCount > 0 + } + + var clearDraftUnderlyingReturnValue: Result! + var clearDraftReturnValue: Result! { + get { + if Thread.isMainThread { + return clearDraftUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = clearDraftUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + clearDraftUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + clearDraftUnderlyingReturnValue = newValue + } + } + } + } + var clearDraftClosure: (() async -> Result)? + + func clearDraft() async -> Result { + clearDraftCallsCount += 1 + if let clearDraftClosure = clearDraftClosure { + return await clearDraftClosure() + } else { + return clearDraftReturnValue + } + } } class RoomSummaryProviderMock: RoomSummaryProviderProtocol { var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> { @@ -12712,6 +13175,74 @@ class TimelineProxyMock: TimelineProxyProtocol { return sendPollResponsePollStartIDAnswersReturnValue } } + //MARK: - getLoadedReplyDetails + + var getLoadedReplyDetailsEventIDUnderlyingCallsCount = 0 + var getLoadedReplyDetailsEventIDCallsCount: Int { + get { + if Thread.isMainThread { + return getLoadedReplyDetailsEventIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = getLoadedReplyDetailsEventIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + getLoadedReplyDetailsEventIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + getLoadedReplyDetailsEventIDUnderlyingCallsCount = newValue + } + } + } + } + var getLoadedReplyDetailsEventIDCalled: Bool { + return getLoadedReplyDetailsEventIDCallsCount > 0 + } + var getLoadedReplyDetailsEventIDReceivedEventID: String? + var getLoadedReplyDetailsEventIDReceivedInvocations: [String] = [] + + var getLoadedReplyDetailsEventIDUnderlyingReturnValue: Result! + var getLoadedReplyDetailsEventIDReturnValue: Result! { + get { + if Thread.isMainThread { + return getLoadedReplyDetailsEventIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = getLoadedReplyDetailsEventIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + getLoadedReplyDetailsEventIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + getLoadedReplyDetailsEventIDUnderlyingReturnValue = newValue + } + } + } + } + var getLoadedReplyDetailsEventIDClosure: ((String) async -> Result)? + + func getLoadedReplyDetails(eventID: String) async -> Result { + getLoadedReplyDetailsEventIDCallsCount += 1 + getLoadedReplyDetailsEventIDReceivedEventID = eventID + getLoadedReplyDetailsEventIDReceivedInvocations.append(eventID) + if let getLoadedReplyDetailsEventIDClosure = getLoadedReplyDetailsEventIDClosure { + return await getLoadedReplyDetailsEventIDClosure(eventID) + } else { + return getLoadedReplyDetailsEventIDReturnValue + } + } } class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol { diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 8485983f2..89b0f95f0 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -149,5 +149,7 @@ extension RoomProxyMock { matrixToPermalinkReturnValue = .success(.homeDirectory) matrixToEventPermalinkReturnValue = .success(.homeDirectory) + loadDraftReturnValue = .success(nil) + clearDraftReturnValue = .success(()) } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index df91d50b4..28cdffbf9 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -27,6 +27,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private let wysiwygViewModel: WysiwygComposerViewModel private let completionSuggestionService: CompletionSuggestionServiceProtocol private let analyticsService: AnalyticsService + private let draftService: ComposerDraftServiceProtocol private let mentionBuilder: MentionBuilderProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol @@ -46,15 +47,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } private var currentLinkData: WysiwygLinkData? + + private var replyLoadingTask: Task? init(wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol, mentionDisplayHelper: MentionDisplayHelper, - analyticsService: AnalyticsService) { + analyticsService: AnalyticsService, + composerDraftService: ComposerDraftServiceProtocol) { self.wysiwygViewModel = wysiwygViewModel self.completionSuggestionService = completionSuggestionService self.analyticsService = analyticsService + draftService = composerDraftService mentionBuilder = MentionBuilder() attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder) @@ -177,6 +182,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string) case .didToggleFormattingOptions: if context.composerFormattingEnabled { + guard !context.plainComposerText.string.isEmpty else { + return + } DispatchQueue.main.async { self.wysiwygViewModel.textView.flushPills() self.wysiwygViewModel.setMarkdownContent(self.context.plainComposerText.string) @@ -191,13 +199,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool switch roomAction { case .setMode(mode: let mode): set(mode: mode) - case .setText(text: let text): - set(text: text) + case .setText(let plainText, let htmlText): + if let htmlText, context.composerFormattingEnabled { + set(text: htmlText) + } else { + set(text: plainText) + } case .removeFocus: state.bindings.composerFocused = false case .clear: set(mode: .default) set(text: "") + case .saveDraft: + handleSaveDraft() + case .loadDraft: + Task { + await handleLoadDraft() + } } } @@ -211,6 +229,99 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool // MARK: - Private + private func handleLoadDraft() async { + guard case let .success(draft) = await draftService.loadDraft(), + let draft else { + return + } + + if let html = draft.htmlText { + context.composerFormattingEnabled = true + DispatchQueue.main.async { + self.set(text: html) + } + } else { + context.composerFormattingEnabled = false + set(text: draft.plainText) + } + + switch draft.draftType { + case .newMessage: + set(mode: .default) + case .edit(let eventID): + set(mode: .edit(originalItemId: .init(timelineID: "", eventID: eventID))) + case .reply(let eventID): + set(mode: .reply(itemID: .init(timelineID: "", eventID: eventID), replyDetails: .loading(eventID: eventID), isThread: false)) + replyLoadingTask = Task { + let reply = switch await draftService.getReply(eventID: eventID) { + case .success(let reply): + reply + case .failure: + TimelineItemReply(details: .error(eventID: eventID, message: L10n.commonSomethingWentWrong), isThreaded: false) + } + + guard !Task.isCancelled else { + return + } + + set(mode: .reply(itemID: .init(timelineID: "", eventID: eventID), replyDetails: reply.details, isThread: reply.isThreaded)) + } + } + } + + private func handleSaveDraft() { + let plainText: String + let htmlText: String? + let type: ComposerDraftProxy.ComposerDraftType + + if context.composerFormattingEnabled { + if wysiwygViewModel.isContentEmpty, state.composerMode == .default { + Task { + await draftService.clearDraft() + } + return + } + plainText = wysiwygViewModel.content.markdown + htmlText = wysiwygViewModel.content.html + } else { + if context.plainComposerText.string.isEmpty, state.composerMode == .default { + Task { + await draftService.clearDraft() + } + return + } + plainText = context.plainComposerText.string + htmlText = nil + } + + switch state.composerMode { + case .default: + type = .newMessage + case .edit(let itemID): + guard let eventID = itemID.eventID else { + MXLog.error("The event id for this message is missing") + return + } + type = .edit(eventID: eventID) + case .reply(let itemID, _, _): + guard let eventID = itemID.eventID else { + MXLog.error("The event id for this message is missing") + return + } + type = .reply(eventID: eventID) + default: + // Do not save a draft for the other cases + Task { + await draftService.clearDraft() + } + return + } + + Task { + await draftService.saveDraft(.init(plainText: plainText, htmlText: htmlText, draftType: type)) + } + } + private func sendPlainComposerText() { let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText) @@ -338,6 +449,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } private func set(mode: RoomScreenComposerMode) { + if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID { + replyLoadingTask?.cancel() + } + guard mode != state.composerMode else { return } state.composerMode = mode @@ -357,7 +472,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private func set(text: String) { if context.composerFormattingEnabled { wysiwygViewModel.textView.flushPills() - wysiwygViewModel.setHtmlContent(text) } else { state.bindings.plainComposerText = .init(string: text) diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift index 354319c8d..d6942e73c 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift @@ -292,7 +292,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())), .user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))] @@ -315,6 +316,12 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview { ComposerToolbar.voiceMessagePreviewMock(uploading: false) } .previewDisplayName("Voice Message") + + VStack(spacing: 8) { + ComposerToolbar.replyLoadingPreviewMock(isLoading: true) + ComposerToolbar.replyLoadingPreviewMock(isLoading: false) + } + .previewDisplayName("Reply") } } @@ -328,7 +335,8 @@ extension ComposerToolbar { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) model.state.composerEmpty = focused return model } @@ -344,7 +352,8 @@ extension ComposerToolbar { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) model.state.composerEmpty = focused return model } @@ -360,7 +369,8 @@ extension ComposerToolbar { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState()) return model } @@ -377,7 +387,8 @@ extension ComposerToolbar { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: uploading) return model } @@ -385,4 +396,27 @@ extension ComposerToolbar { wysiwygViewModel: wysiwygViewModel, keyCommands: []) } + + static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar { + let wysiwygViewModel = WysiwygComposerViewModel() + var composerViewModel: ComposerToolbarViewModel { + let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + mediaProvider: MockMediaProvider(), + mentionDisplayHelper: ComposerMentionDisplayHelper.mock, + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) + model.state.composerMode = isLoading ? .reply(itemID: .init(timelineID: ""), + replyDetails: .loading(eventID: ""), + isThread: false) : + .reply(itemID: .init(timelineID: ""), + replyDetails: .loaded(sender: .init(id: "", + displayName: "Test"), + eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false) + return model + } + return ComposerToolbar(context: composerViewModel.context, + wysiwygViewModel: wysiwygViewModel, + keyCommands: []) + } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift index 1e87dcd5d..2615be8bc 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/RoomAttachmentPicker.swift @@ -100,7 +100,8 @@ struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview { completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: ComposerDraftServiceMock()) static var previews: some View { RoomAttachmentPicker(context: viewModel.context) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 767962026..1e4058e6e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -30,6 +30,7 @@ struct RoomScreenCoordinatorParameters { let completionSuggestionService: CompletionSuggestionServiceProtocol let appMediator: AppMediatorProtocol let appSettings: AppSettings + let composerDraftService: ComposerDraftServiceProtocol } enum RoomScreenCoordinatorAction { @@ -59,16 +60,17 @@ final class RoomScreenCoordinator: CoordinatorProtocol { } init(parameters: RoomScreenCoordinatorParameters) { - viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, - focussedEventID: parameters.focussedEventID, - timelineController: parameters.timelineController, - mediaProvider: parameters.mediaProvider, - mediaPlayerProvider: parameters.mediaPlayerProvider, - voiceMessageMediaManager: parameters.voiceMessageMediaManager, - userIndicatorController: ServiceLocator.shared.userIndicatorController, - appMediator: parameters.appMediator, - appSettings: parameters.appSettings, - analyticsService: ServiceLocator.shared.analytics) + let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, + focussedEventID: parameters.focussedEventID, + timelineController: parameters.timelineController, + mediaProvider: parameters.mediaProvider, + mediaPlayerProvider: parameters.mediaPlayerProvider, + voiceMessageMediaManager: parameters.voiceMessageMediaManager, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: parameters.appMediator, + appSettings: parameters.appSettings, + analyticsService: ServiceLocator.shared.analytics) + self.viewModel = viewModel wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, maxCompressedHeight: ComposerConstant.maxHeight, @@ -78,7 +80,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol { completionSuggestionService: parameters.completionSuggestionService, mediaProvider: parameters.mediaProvider, mentionDisplayHelper: ComposerMentionDisplayHelper(roomContext: viewModel.context), - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: parameters.composerDraftService) + + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).sink { _ in + viewModel.saveDraft() + } + .store(in: &cancellables) } // MARK: - Public @@ -128,6 +136,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol { viewModel.process(composerAction: action) } .store(in: &cancellables) + + // Loading the draft requires the subscriptions to be set up first otherwise the room won't be be able to propagate the information to the composer. + viewModel.loadDraft() } func focusOnEvent(eventID: String) { @@ -135,6 +146,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { } func stop() { + viewModel.saveDraft() viewModel.stop() } @@ -143,7 +155,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { wysiwygViewModel: wysiwygViewModel, keyCommands: composerViewModel.keyCommands) - return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar)) + return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar) + .onDisappear { [weak self] in + self?.viewModel.saveDraft() + }) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift index aefe7646f..486e4c1b7 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift @@ -265,16 +265,18 @@ class RoomScreenInteractionHandler { private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) { let text: String + var htmlText: String? switch messageTimelineItem.contentType { - case .text: - text = messageTimelineItem.body - case .emote: - text = "/me " + messageTimelineItem.body + case .text(let content): + text = content.body + htmlText = content.formattedBodyHTMLString + case .emote(let content): + text = "/me " + content.body default: text = messageTimelineItem.body } - actionsSubject.send(.composer(action: .setText(text: text))) + actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText))) actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id)))) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index adeacceb7..61b2f0ea3 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -60,6 +60,29 @@ enum RoomScreenComposerMode: Equatable { return false } } + + var isLoadingReply: Bool { + switch self { + case .reply(_, let replyDetails, _): + switch replyDetails { + case .loading: + return true + default: + return false + } + default: + return false + } + } + + var replyEventID: String? { + switch self { + case .reply(let itemID, _, _): + return itemID.eventID + default: + return nil + } + } } enum RoomScreenViewPollAction { @@ -110,9 +133,11 @@ enum RoomScreenViewAction { enum RoomScreenComposerAction { case setMode(mode: RoomScreenComposerMode) - case setText(text: String) + case setText(plainText: String, htmlText: String?) case removeFocus case clear + case saveDraft + case loadDraft } struct RoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 8fbc23bf4..815c8d8ad 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -133,11 +133,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Public + func loadDraft() { + guard appSettings.draftRestoringEnabled else { + return + } + actionsSubject.send(.composer(action: .loadDraft)) + } + func stop() { // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. state.bindings.mediaPreviewItem = nil } + func saveDraft() { + actionsSubject.send(.composer(action: .saveDraft)) + } + override func process(viewAction: RoomScreenViewAction) { switch viewAction { case .itemAppeared(let id): diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift index 30792cad1..757d36c41 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift @@ -26,4 +26,6 @@ protocol RoomScreenViewModelProtocol { /// Updates the timeline to show and highlight the item with the corresponding event ID. func focusOnEvent(eventID: String) async func stop() + func loadDraft() + func saveDraft() } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 1ad95e81f..197465f10 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -46,6 +46,7 @@ enum DeveloperOptionsScreenViewAction { protocol DeveloperOptionsProtocol: AnyObject { var logLevel: TracingConfiguration.LogLevel { get set } var hideUnreadMessagesBadge: Bool { get set } + var draftRestoringEnabled: Bool { get set } var elementCallBaseURL: URL { get set } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 299154b6e..b0f3ab8a3 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -32,6 +32,12 @@ struct DeveloperOptionsScreen: View { Text("Hide grey dots") } } + + Section("Room") { + Toggle(isOn: $context.draftRestoringEnabled) { + Text("Allow drafts to be restored") + } + } Section("Element Call") { TextField(context.elementCallBaseURL.absoluteString, text: $elementCallBaseURLString) diff --git a/ElementX/Sources/Services/ComposerDraft/ComposerDraftService.swift b/ElementX/Sources/Services/ComposerDraft/ComposerDraftService.swift new file mode 100644 index 000000000..9c2f996c7 --- /dev/null +++ b/ElementX/Sources/Services/ComposerDraft/ComposerDraftService.swift @@ -0,0 +1,74 @@ +// +// Copyright 2024 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 + +import MatrixRustSDK + +final class ComposerDraftService: ComposerDraftServiceProtocol { + private let roomProxy: RoomProxyProtocol + private let timelineItemfactory: RoomTimelineItemFactoryProtocol + + init(roomProxy: RoomProxyProtocol, timelineItemfactory: RoomTimelineItemFactoryProtocol) { + self.roomProxy = roomProxy + self.timelineItemfactory = timelineItemfactory + } + + func saveDraft(_ draft: ComposerDraftProxy) async -> Result { + switch await roomProxy.saveDraft(draft.toRust) { + case .success: + MXLog.info("Successfully saved draft") + return .success(()) + case .failure(let error): + MXLog.info("Failed to save draft: \(error)") + return .failure(.failedToSaveDraft) + } + } + + func loadDraft() async -> Result { + switch await roomProxy.loadDraft() { + case .success(let draft): + guard let draft else { + return .success(nil) + } + return .success(ComposerDraftProxy(from: draft)) + case .failure(let error): + MXLog.info("Failed to load draft: \(error)") + return .failure(.failedToLoadDraft) + } + } + + func getReply(eventID: String) async -> Result { + switch await roomProxy.timeline.getLoadedReplyDetails(eventID: eventID) { + case .success(let replyDetails): + return await .success(timelineItemfactory.buildReply(details: replyDetails)) + case .failure(let error): + MXLog.error("Could not load reply: \(error)") + return .failure(.failedToLoadReply) + } + } + + func clearDraft() async -> Result { + switch await roomProxy.clearDraft() { + case .success: + MXLog.info("Successfully cleared draft") + return .success(()) + case .failure(let error): + MXLog.info("Failed to clear draft: \(error)") + return .failure(.failedToClearDraft) + } + } +} diff --git a/ElementX/Sources/Services/ComposerDraft/ComposerDraftServiceProtocol.swift b/ElementX/Sources/Services/ComposerDraft/ComposerDraftServiceProtocol.swift new file mode 100644 index 000000000..6a30ef7b1 --- /dev/null +++ b/ElementX/Sources/Services/ComposerDraft/ComposerDraftServiceProtocol.swift @@ -0,0 +1,80 @@ +// +// Copyright 2024 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 + +import MatrixRustSDK + +struct ComposerDraftProxy: Equatable { + enum ComposerDraftType: Equatable { + case newMessage + case reply(eventID: String) + case edit(eventID: String) + + var toRust: MatrixRustSDK.ComposerDraftType { + switch self { + case .newMessage: + return .newMessage + case .edit(let eventID): + return .edit(eventId: eventID) + case .reply(let eventID): + return .reply(eventId: eventID) + } + } + + init(from rustDraftType: MatrixRustSDK.ComposerDraftType) { + switch rustDraftType { + case .newMessage: + self = .newMessage + case .edit(let eventID): + self = .edit(eventID: eventID) + case .reply(let eventID): + self = .reply(eventID: eventID) + } + } + } + + let plainText: String + let htmlText: String? + let draftType: ComposerDraftType + + var toRust: ComposerDraft { + ComposerDraft(plainText: plainText, htmlText: htmlText, draftType: draftType.toRust) + } +} + +extension ComposerDraftProxy { + init(from rustDraft: ComposerDraft) { + plainText = rustDraft.plainText + htmlText = rustDraft.htmlText + draftType = ComposerDraftType(from: rustDraft.draftType) + } +} + +enum ComposerDraftServiceError: Error { + case failedToLoadDraft + case failedToLoadReply + case failedToSaveDraft + case failedToClearDraft +} + +// sourcery: AutoMockable +protocol ComposerDraftServiceProtocol { + func saveDraft(_ draft: ComposerDraftProxy) async -> Result + func loadDraft() async -> Result + func clearDraft() async -> Result + func getReply(eventID: String) async -> Result +} diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 38807cd8c..8b05c5574 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -565,6 +565,37 @@ class RoomProxy: RoomProxyProtocol { return .failure(.sdkError(error)) } } + + // MARK: - Drafts + + func saveDraft(_ draft: ComposerDraft) async -> Result { + do { + try await room.saveComposerDraft(draft: draft) + return .success(()) + } catch { + MXLog.error("Failed saving draft with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func loadDraft() async -> Result { + do { + return try await .success(room.loadComposerDraft()) + } catch { + MXLog.error("Failed restoring draft with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func clearDraft() async -> Result { + do { + try await room.clearComposerDraft() + return .success(()) + } catch { + MXLog.error("Failed clearing draft with error: \(error)") + return .failure(.sdkError(error)) + } + } // MARK: - Private diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 94032b95e..e65b3457b 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -126,7 +126,6 @@ protocol RoomProxyProtocol { // MARK: - Element Call func canUserJoinCall(userID: String) async -> Result - func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol func sendCallNotificationIfNeeeded() async -> Result @@ -134,8 +133,13 @@ protocol RoomProxyProtocol { // MARK: - Permalinks func matrixToPermalink() async -> Result - func matrixToEventPermalink(_ eventID: String) async -> Result + + // MARK: - Drafts + + func saveDraft(_ draft: ComposerDraft) async -> Result + func loadDraft() async -> Result + func clearDraft() async -> Result } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift index 218afb271..de0b17173 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift @@ -16,6 +16,11 @@ import Foundation +struct TimelineItemReply { + let details: TimelineItemReplyDetails + let isThreaded: Bool +} + enum TimelineItemReplyDetails: Hashable { case notLoaded(eventID: String) case loading(eventID: String) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 466af51aa..b8137ba00 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -216,7 +216,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildTextTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -236,7 +236,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildImageTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -256,7 +256,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildVideoTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -276,7 +276,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildAudioTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -296,7 +296,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildAudioTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -316,7 +316,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildFileTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -336,7 +336,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildNoticeTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -356,7 +356,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -376,7 +376,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildLocationTimelineItemContent(messageContent), - replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, @@ -645,14 +645,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { // MARK: - Reply details - private func buildReplyToDetailsFrom(details: InReplyToDetails?) -> TimelineItemReplyDetails? { - guard let details else { return nil } - + func buildReply(details: InReplyToDetails) -> TimelineItemReply { + let isThreaded = details.event.isThreaded switch details.event { case .unavailable: - return .notLoaded(eventID: details.eventId) + return .init(details: .notLoaded(eventID: details.eventId), isThreaded: isThreaded) case .pending: - return .loading(eventID: details.eventId) + return .init(details: .loading(eventID: details.eventId), isThreaded: isThreaded) case let .ready(timelineItem, senderID, senderProfile): let sender: TimelineItemSender switch senderProfile { @@ -672,7 +671,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { switch timelineItem.kind() { case .message: - return timelineItemReplyDetails(sender: sender, eventID: details.eventId, messageType: timelineItem.asMessage()?.msgtype()) + return .init(details: timelineItemReplyDetails(sender: sender, eventID: details.eventId, messageType: timelineItem.asMessage()?.msgtype()), isThreaded: isThreaded) case .poll(let question, _, _, _, _, _, _): replyContent = .poll(question: question) case .sticker(let body, _, _): @@ -683,12 +682,20 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) } - return .loaded(sender: sender, eventID: details.eventId, eventContent: replyContent) + return .init(details: .loaded(sender: sender, eventID: details.eventId, eventContent: replyContent), isThreaded: isThreaded) case let .error(message): - return .error(eventID: details.eventId, message: message) + return .init(details: .error(eventID: details.eventId, message: message), isThreaded: isThreaded) } } + private func buildReplyToDetailsFromDetailsIfAvailable(details: InReplyToDetails?) -> TimelineItemReplyDetails? { + guard let details else { + return nil + } + + return buildReply(details: details).details + } + private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails { let replyContent: EventBasedMessageTimelineItemContentType @@ -733,3 +740,14 @@ extension Poll.Kind { } } } + +private extension RepliedToEventDetails { + var isThreaded: Bool { + switch self { + case .ready(let content, _, _): + return content.asMessage()?.isThreaded() ?? false + default: + return false + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index 1f4a453d5..78e61a4a4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -16,7 +16,10 @@ import Foundation +import MatrixRustSDK + @MainActor protocol RoomTimelineItemFactoryProtocol { func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol? + func buildReply(details: InReplyToDetails) -> TimelineItemReply } diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index 8f6dc570c..7b281b0e6 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -172,6 +172,15 @@ final class TimelineProxy: TimelineProxyProtocol { } } + func getLoadedReplyDetails(eventID: String) async -> Result { + do { + return try await .success(timeline.loadReplyDetails(eventIdStr: eventID)) + } catch { + MXLog.error("Failed getting reply details for event \(eventID) with error: \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Sending func sendAudio(url: URL, diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index 0e4323f5b..8199c67be 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -107,6 +107,8 @@ protocol TimelineProxyProtocol { func endPoll(pollStartID: String, text: String) async -> Result func sendPollResponse(pollStartID: String, answers: [String]) async -> Result + + func getLoadedReplyDetails(eventID: String) async -> Result } extension TimelineProxyProtocol { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b3e83f595..244cf9aa2 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -256,7 +256,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -272,7 +273,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -288,7 +290,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -304,7 +307,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -323,7 +327,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -342,7 +347,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -361,7 +367,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -381,7 +388,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -400,7 +408,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -418,7 +427,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -450,7 +460,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -469,7 +480,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -488,7 +500,8 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings) + appSettings: ServiceLocator.shared.settings, + composerDraftService: ComposerDraftServiceMock()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png new file mode 100644 index 000000000..adfee4f05 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd1275a60dbdb93672be62b7370c4ac5ddc087a368194506d9075c93105aaa5a +size 103042 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png new file mode 100644 index 000000000..fdb479a0b --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd14b1e5099f2b24e9f2d8fadcb122f378458748177b307de3f5d648e859573b +size 116409 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png new file mode 100644 index 000000000..eb2a04292 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f1dd6662904da01b7cd40c676a8bd1404165786c62498e803d38be521cc2f63 +size 59971 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png new file mode 100644 index 000000000..0c74928b2 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b172a9385aca198b30664d21b8276317be91e77b4036dfc94a6cbfe29f7f983 +size 70638 diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 8e3a27740..86834a97a 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -26,6 +26,7 @@ class ComposerToolbarViewModelTests: XCTestCase { private var wysiwygViewModel: WysiwygComposerViewModel! private var viewModel: ComposerToolbarViewModel! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! + private var draftServiceMock: ComposerDraftServiceMock! override func setUp() { AppSettings.resetAllSettings() @@ -33,11 +34,13 @@ class ComposerToolbarViewModelTests: XCTestCase { ServiceLocator.shared.register(appSettings: appSettings) wysiwygViewModel = WysiwygComposerViewModel() completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) + draftServiceMock = ComposerDraftServiceMock() viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, completionSuggestionService: completionSuggestionServiceMock, mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: draftServiceMock) viewModel.context.composerFormattingEnabled = true } @@ -113,7 +116,8 @@ class ComposerToolbarViewModelTests: XCTestCase { completionSuggestionService: mockCompletionSuggestionService, mediaProvider: MockMediaProvider(), mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: draftServiceMock) XCTAssertEqual(viewModel.state.suggestions, suggestions) } @@ -186,6 +190,304 @@ class ComposerToolbarViewModelTests: XCTestCase { try await deferred.fulfill() } + + // MARK: - Draft + + func testSaveDraftPlainText() async { + let expectation = expectation(description: "Wait for draft to be saved") + draftServiceMock.saveDraftClosure = { draft in + XCTAssertEqual(draft.plainText, "Hello world!") + XCTAssertNil(draft.htmlText) + XCTAssertEqual(draft.draftType, .newMessage) + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + viewModel.context.plainComposerText = .init(string: "Hello world!") + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.clearDraftCalled) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testSaveDraftFormattedText() async { + let expectation = expectation(description: "Wait for draft to be saved") + draftServiceMock.saveDraftClosure = { draft in + XCTAssertEqual(draft.plainText, "__Hello__ world!") + XCTAssertEqual(draft.htmlText, "Hello world!") + XCTAssertEqual(draft.draftType, .newMessage) + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = true + wysiwygViewModel.setHtmlContent("Hello world!") + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.clearDraftCalled) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testSaveDraftEdit() async { + let expectation = expectation(description: "Wait for draft to be saved") + draftServiceMock.saveDraftClosure = { draft in + XCTAssertEqual(draft.plainText, "Hello world!") + XCTAssertNil(draft.htmlText) + XCTAssertEqual(draft.draftType, .edit(eventID: "testID")) + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: .init(timelineID: "", eventID: "testID")))) + viewModel.context.plainComposerText = .init(string: "Hello world!") + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.clearDraftCalled) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testSaveDraftReply() async { + let expectation = expectation(description: "Wait for draft to be saved") + draftServiceMock.saveDraftClosure = { draft in + XCTAssertEqual(draft.plainText, "Hello world!") + XCTAssertNil(draft.htmlText) + XCTAssertEqual(draft.draftType, .reply(eventID: "testID")) + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + viewModel.process(roomAction: .setMode(mode: .reply(itemID: .init(timelineID: "", + eventID: "testID"), + replyDetails: .loaded(sender: .init(id: ""), + eventID: "testID", + eventContent: .message(.text(.init(body: "reply text")))), + isThread: false))) + viewModel.context.plainComposerText = .init(string: "Hello world!") + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.clearDraftCalled) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testSaveDraftWhenEmptyReply() async { + let expectation = expectation(description: "Wait for draft to be saved") + draftServiceMock.saveDraftClosure = { draft in + XCTAssertEqual(draft.plainText, "") + XCTAssertNil(draft.htmlText) + XCTAssertEqual(draft.draftType, .reply(eventID: "testID")) + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + viewModel.process(roomAction: .setMode(mode: .reply(itemID: .init(timelineID: "", + eventID: "testID"), + replyDetails: .loaded(sender: .init(id: ""), + eventID: "testID", + eventContent: .message(.text(.init(body: "reply text")))), + isThread: false))) + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.clearDraftCalled) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testClearDraftWhenEmptyNormalMessage() async { + let expectation = expectation(description: "Wait for draft to be cleared") + draftServiceMock.clearDraftClosure = { + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertFalse(draftServiceMock.saveDraftCalled) + XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testClearDraftForNonTextMode() async { + let expectation = expectation(description: "Wait for draft to be cleared") + draftServiceMock.clearDraftClosure = { + defer { expectation.fulfill() } + return .success(()) + } + + viewModel.context.composerFormattingEnabled = false + let waveformData: [Float] = Array(repeating: 1.0, count: 1000) + viewModel.context.plainComposerText = .init(string: "Hello world!") + viewModel.process(roomAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: false))) + viewModel.process(roomAction: .saveDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertFalse(draftServiceMock.saveDraftCalled) + XCTAssertEqual(draftServiceMock.clearDraftCallsCount, 1) + XCTAssertFalse(draftServiceMock.loadDraftCalled) + } + + func testNothingToRestore() async { + viewModel.context.composerFormattingEnabled = false + let expectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { expectation.fulfill() } + return .success(nil) + } + + viewModel.process(roomAction: .loadDraft) + await fulfillment(of: [expectation], timeout: 10) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + XCTAssertTrue(viewModel.state.composerEmpty) + XCTAssertEqual(viewModel.state.composerMode, .default) + } + + func testRestoreNormalPlainTextMessage() async { + viewModel.context.composerFormattingEnabled = false + let expectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { expectation.fulfill() } + return .success(.init(plainText: "Hello world!", + htmlText: nil, + draftType: .newMessage)) + } + viewModel.process(roomAction: .loadDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + XCTAssertEqual(viewModel.state.composerMode, .default) + XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!")) + } + + func testRestoreNormalFormattedTextMessage() async { + viewModel.context.composerFormattingEnabled = false + let expectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { expectation.fulfill() } + return .success(.init(plainText: "__Hello__ world!", + htmlText: "Hello world!", + draftType: .newMessage)) + } + viewModel.process(roomAction: .loadDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertTrue(viewModel.context.composerFormattingEnabled) + XCTAssertEqual(viewModel.state.composerMode, .default) + XCTAssertEqual(wysiwygViewModel.content.html, "Hello world!") + XCTAssertEqual(wysiwygViewModel.content.markdown, "__Hello__ world!") + } + + func testRestoreEdit() async { + viewModel.context.composerFormattingEnabled = false + let expectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { expectation.fulfill() } + return .success(.init(plainText: "Hello world!", + htmlText: nil, + draftType: .edit(eventID: "testID"))) + } + viewModel.process(roomAction: .loadDraft) + + await fulfillment(of: [expectation], timeout: 10) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + XCTAssertEqual(viewModel.state.composerMode, .edit(originalItemId: .init(timelineID: "", eventID: "testID"))) + XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!")) + } + + func testRestoreReply() async { + let testEventID = "testID" + let text = "Hello world!" + let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", + displayName: "Username"), + eventID: testEventID, + eventContent: .message(.text(.init(body: "Reply text")))) + + viewModel.context.composerFormattingEnabled = false + let draftExpectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { draftExpectation.fulfill() } + return .success(.init(plainText: text, + htmlText: nil, + draftType: .reply(eventID: testEventID))) + } + + let loadReplyExpectation = expectation(description: "Wait for reply to be loaded") + draftServiceMock.getReplyEventIDClosure = { eventID in + defer { loadReplyExpectation.fulfill() } + XCTAssertEqual(eventID, testEventID) + try? await Task.sleep(for: .seconds(1)) + return .success(.init(details: loadedReply, + isThreaded: true)) + } + viewModel.process(roomAction: .loadDraft) + + await fulfillment(of: [draftExpectation], timeout: 10) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + // Testing the loading state first + XCTAssertEqual(viewModel.state.composerMode, .reply(itemID: .init(timelineID: "", eventID: testEventID), + replyDetails: .loading(eventID: testEventID), + isThread: false)) + XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text)) + + await fulfillment(of: [loadReplyExpectation], timeout: 10) + XCTAssertEqual(viewModel.state.composerMode, .reply(itemID: .init(timelineID: "", eventID: testEventID), + replyDetails: loadedReply, + isThread: true)) + } + + func testRestoreReplyAndCancelReplyMode() async { + let testEventID = "testID" + let text = "Hello world!" + let loadedReply = TimelineItemReplyDetails.loaded(sender: .init(id: "userID", + displayName: "Username"), + eventID: testEventID, + eventContent: .message(.text(.init(body: "Reply text")))) + + viewModel.context.composerFormattingEnabled = false + let draftExpectation = expectation(description: "Wait for draft to be restored") + draftServiceMock.loadDraftClosure = { + defer { draftExpectation.fulfill() } + return .success(.init(plainText: text, + htmlText: nil, + draftType: .reply(eventID: testEventID))) + } + + let loadReplyExpectation = expectation(description: "Wait for reply to be loaded") + draftServiceMock.getReplyEventIDClosure = { eventID in + defer { loadReplyExpectation.fulfill() } + XCTAssertEqual(eventID, testEventID) + try? await Task.sleep(for: .seconds(1)) + return .success(.init(details: loadedReply, + isThreaded: true)) + } + viewModel.process(roomAction: .loadDraft) + + await fulfillment(of: [draftExpectation], timeout: 10) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + // Testing the loading state first + XCTAssertEqual(viewModel.state.composerMode, .reply(itemID: .init(timelineID: "", eventID: testEventID), + replyDetails: .loading(eventID: testEventID), + isThread: false)) + XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: text)) + + // Now we change the state to cancel the reply mode update + viewModel.process(viewAction: .cancelReply) + await fulfillment(of: [loadReplyExpectation], timeout: 10) + XCTAssertEqual(viewModel.state.composerMode, .default) + } } private extension MentionSuggestionItem { diff --git a/changelog.d/2849.feature b/changelog.d/2849.feature new file mode 100644 index 000000000..b459747d9 --- /dev/null +++ b/changelog.d/2849.feature @@ -0,0 +1 @@ +Composer drafts will now be saved upon leaving the screen, and get restored later when displayed again. \ No newline at end of file