Store and restore drafts (#2898)

This commit is contained in:
Mauro 2024-06-13 14:19:38 +02:00 committed by GitHub
parent b8dea8ac4e
commit cefa38049f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1384 additions and 63 deletions

View File

@ -245,6 +245,7 @@
3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; }; 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */; };
3A64A93A651A3CB8774ADE8E /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; }; 3A64A93A651A3CB8774ADE8E /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; };
3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */; }; 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 */; }; 3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; };
3B28408450BCAED911283AA2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; }; 3B28408450BCAED911283AA2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; };
3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.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 */; }; CA12AE0DCD57D49CD96C699A /* WaveformCursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */; };
CB137BFB3E083C33E398A6CB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 020597E28A4BC8E1BE8EDF6E /* KeychainAccess */; }; CB137BFB3E083C33E398A6CB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 020597E28A4BC8E1BE8EDF6E /* KeychainAccess */; };
CB498F4E27AA0545DCEF0F6F /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 36B7FC232711031AA2B0D188 /* DTCoreText */; }; 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 */; }; CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */; };
CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; };
CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.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 = "<group>"; }; 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = "<group>"; };
1D652E78832289CD9EB64488 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 1D652E78832289CD9EB64488 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = "<group>"; };
1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDraftService.swift; sourceTree = "<group>"; };
1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModel.swift; sourceTree = "<group>"; }; 1D8C38663020DF2EB2D13F5E /* AppLockSetupSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupSettingsScreenViewModel.swift; sourceTree = "<group>"; };
1DB2FC2AA9A07EE792DF65CF /* NotificationPermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenModels.swift; sourceTree = "<group>"; }; 1DB2FC2AA9A07EE792DF65CF /* NotificationPermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenModels.swift; sourceTree = "<group>"; };
1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = "<group>"; }; 1DB34B0C74CD242FED9DD069 /* LoginScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenUITests.swift; sourceTree = "<group>"; };
@ -1787,6 +1790,7 @@
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
A7D452AF7B5F7E3A0A7DB54C /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = "<group>"; }; A7D452AF7B5F7E3A0A7DB54C /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDraftServiceProtocol.swift; sourceTree = "<group>"; };
A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenCoordinator.swift; sourceTree = "<group>"; }; A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewerScreenCoordinator.swift; sourceTree = "<group>"; };
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
A8DF55467ED4CE76B7AE9A33 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = "<group>"; }; A8DF55467ED4CE76B7AE9A33 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@ -2343,6 +2347,7 @@
AAFDD509929A0CCF8BCE51EB /* Authentication */, AAFDD509929A0CCF8BCE51EB /* Authentication */,
0ED3F5C21537519389C07644 /* BugReport */, 0ED3F5C21537519389C07644 /* BugReport */,
8039515BAA53B7C3275AC64A /* Client */, 8039515BAA53B7C3275AC64A /* Client */,
8B5E91450E85A9689931B221 /* ComposerDraft */,
8C3BAE06B336D97DABBE2509 /* CreateRoom */, 8C3BAE06B336D97DABBE2509 /* CreateRoom */,
92E99C57D7F92ED16F73282C /* ElementCall */, 92E99C57D7F92ED16F73282C /* ElementCall */,
39557ADF21345E18F3865B9E /* Emojis */, 39557ADF21345E18F3865B9E /* Emojis */,
@ -3988,6 +3993,15 @@
path = Sounds; path = Sounds;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8B5E91450E85A9689931B221 /* ComposerDraft */ = {
isa = PBXGroup;
children = (
1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */,
A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */,
);
path = ComposerDraft;
sourceTree = "<group>";
};
8C3BAE06B336D97DABBE2509 /* CreateRoom */ = { 8C3BAE06B336D97DABBE2509 /* CreateRoom */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -5975,6 +5989,8 @@
19DF5600A7F547B22DD7872A /* CompletionSuggestionService.swift in Sources */, 19DF5600A7F547B22DD7872A /* CompletionSuggestionService.swift in Sources */,
EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */, EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */,
16E4F1B8B9BFE1367F96DDA7 /* CompletionSuggestionView.swift in Sources */, 16E4F1B8B9BFE1367F96DDA7 /* CompletionSuggestionView.swift in Sources */,
3AA9E878FDCFF85664AC071F /* ComposerDraftService.swift in Sources */,
CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */,
937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */, 937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */,
94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */, 94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */,
71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */, 71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */,

View File

@ -44,6 +44,7 @@ final class AppSettings {
// Feature flags // Feature flags
case publicSearchEnabled case publicSearchEnabled
case draftRestoringEnabled
} }
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@ -268,6 +269,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.publicSearchEnabled, defaultValue: isDevelopmentBuild, storageType: .volatile) @UserPreference(key: UserDefaultsKeys.publicSearchEnabled, defaultValue: isDevelopmentBuild, storageType: .volatile)
var publicSearchEnabled var publicSearchEnabled
@UserPreference(key: UserDefaultsKeys.draftRestoringEnabled, defaultValue: false, storageType: .userDefaults(store))
var draftRestoringEnabled
#endif #endif

View File

@ -549,6 +549,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy) let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy)
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
focussedEventID: focussedEventID, focussedEventID: focussedEventID,
timelineController: timelineController, timelineController: timelineController,
@ -558,7 +560,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
completionSuggestionService: completionSuggestionService, completionSuggestionService: completionSuggestionService,
appMediator: appMediator, appMediator: appMediator,
appSettings: appSettings) appSettings: appSettings,
composerDraftService: composerDraftService)
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.actions coordinator.actions

View File

@ -4415,6 +4415,273 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
setSuggestionTriggerClosure?(suggestionTrigger) 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<Void, ComposerDraftServiceError>!
var saveDraftReturnValue: Result<Void, ComposerDraftServiceError>! {
get {
if Thread.isMainThread {
return saveDraftUnderlyingReturnValue
} else {
var returnValue: Result<Void, ComposerDraftServiceError>? = 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<Void, ComposerDraftServiceError>)?
func saveDraft(_ draft: ComposerDraftProxy) async -> Result<Void, ComposerDraftServiceError> {
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<ComposerDraftProxy?, ComposerDraftServiceError>!
var loadDraftReturnValue: Result<ComposerDraftProxy?, ComposerDraftServiceError>! {
get {
if Thread.isMainThread {
return loadDraftUnderlyingReturnValue
} else {
var returnValue: Result<ComposerDraftProxy?, ComposerDraftServiceError>? = nil
DispatchQueue.main.sync {
returnValue = loadDraftUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
loadDraftUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
loadDraftUnderlyingReturnValue = newValue
}
}
}
}
var loadDraftClosure: (() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>)?
func loadDraft() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError> {
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<Void, ComposerDraftServiceError>!
var clearDraftReturnValue: Result<Void, ComposerDraftServiceError>! {
get {
if Thread.isMainThread {
return clearDraftUnderlyingReturnValue
} else {
var returnValue: Result<Void, ComposerDraftServiceError>? = nil
DispatchQueue.main.sync {
returnValue = clearDraftUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
clearDraftUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
clearDraftUnderlyingReturnValue = newValue
}
}
}
}
var clearDraftClosure: (() async -> Result<Void, ComposerDraftServiceError>)?
func clearDraft() async -> Result<Void, ComposerDraftServiceError> {
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<TimelineItemReply, ComposerDraftServiceError>!
var getReplyEventIDReturnValue: Result<TimelineItemReply, ComposerDraftServiceError>! {
get {
if Thread.isMainThread {
return getReplyEventIDUnderlyingReturnValue
} else {
var returnValue: Result<TimelineItemReply, ComposerDraftServiceError>? = 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<TimelineItemReply, ComposerDraftServiceError>)?
func getReply(eventID: String) async -> Result<TimelineItemReply, ComposerDraftServiceError> {
getReplyEventIDCallsCount += 1
getReplyEventIDReceivedEventID = eventID
getReplyEventIDReceivedInvocations.append(eventID)
if let getReplyEventIDClosure = getReplyEventIDClosure {
return await getReplyEventIDClosure(eventID)
} else {
return getReplyEventIDReturnValue
}
}
}
class ElementCallServiceMock: ElementCallServiceProtocol { class ElementCallServiceMock: ElementCallServiceProtocol {
var actions: AnyPublisher<ElementCallServiceAction, Never> { var actions: AnyPublisher<ElementCallServiceAction, Never> {
get { return underlyingActions } get { return underlyingActions }
@ -10387,6 +10654,202 @@ class RoomProxyMock: RoomProxyProtocol {
return matrixToEventPermalinkReturnValue 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<Void, RoomProxyError>!
var saveDraftReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return saveDraftUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = 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<Void, RoomProxyError>)?
func saveDraft(_ draft: ComposerDraft) async -> Result<Void, RoomProxyError> {
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<ComposerDraft?, RoomProxyError>!
var loadDraftReturnValue: Result<ComposerDraft?, RoomProxyError>! {
get {
if Thread.isMainThread {
return loadDraftUnderlyingReturnValue
} else {
var returnValue: Result<ComposerDraft?, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = loadDraftUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
loadDraftUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
loadDraftUnderlyingReturnValue = newValue
}
}
}
}
var loadDraftClosure: (() async -> Result<ComposerDraft?, RoomProxyError>)?
func loadDraft() async -> Result<ComposerDraft?, RoomProxyError> {
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<Void, RoomProxyError>!
var clearDraftReturnValue: Result<Void, RoomProxyError>! {
get {
if Thread.isMainThread {
return clearDraftUnderlyingReturnValue
} else {
var returnValue: Result<Void, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = clearDraftUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
clearDraftUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
clearDraftUnderlyingReturnValue = newValue
}
}
}
}
var clearDraftClosure: (() async -> Result<Void, RoomProxyError>)?
func clearDraft() async -> Result<Void, RoomProxyError> {
clearDraftCallsCount += 1
if let clearDraftClosure = clearDraftClosure {
return await clearDraftClosure()
} else {
return clearDraftReturnValue
}
}
} }
class RoomSummaryProviderMock: RoomSummaryProviderProtocol { class RoomSummaryProviderMock: RoomSummaryProviderProtocol {
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> { var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
@ -12712,6 +13175,74 @@ class TimelineProxyMock: TimelineProxyProtocol {
return sendPollResponsePollStartIDAnswersReturnValue 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<InReplyToDetails, TimelineProxyError>!
var getLoadedReplyDetailsEventIDReturnValue: Result<InReplyToDetails, TimelineProxyError>! {
get {
if Thread.isMainThread {
return getLoadedReplyDetailsEventIDUnderlyingReturnValue
} else {
var returnValue: Result<InReplyToDetails, TimelineProxyError>? = 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<InReplyToDetails, TimelineProxyError>)?
func getLoadedReplyDetails(eventID: String) async -> Result<InReplyToDetails, TimelineProxyError> {
getLoadedReplyDetailsEventIDCallsCount += 1
getLoadedReplyDetailsEventIDReceivedEventID = eventID
getLoadedReplyDetailsEventIDReceivedInvocations.append(eventID)
if let getLoadedReplyDetailsEventIDClosure = getLoadedReplyDetailsEventIDClosure {
return await getLoadedReplyDetailsEventIDClosure(eventID)
} else {
return getLoadedReplyDetailsEventIDReturnValue
}
}
} }
class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol { class UserDiscoveryServiceMock: UserDiscoveryServiceProtocol {

View File

@ -149,5 +149,7 @@ extension RoomProxyMock {
matrixToPermalinkReturnValue = .success(.homeDirectory) matrixToPermalinkReturnValue = .success(.homeDirectory)
matrixToEventPermalinkReturnValue = .success(.homeDirectory) matrixToEventPermalinkReturnValue = .success(.homeDirectory)
loadDraftReturnValue = .success(nil)
clearDraftReturnValue = .success(())
} }
} }

View File

@ -27,6 +27,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private let wysiwygViewModel: WysiwygComposerViewModel private let wysiwygViewModel: WysiwygComposerViewModel
private let completionSuggestionService: CompletionSuggestionServiceProtocol private let completionSuggestionService: CompletionSuggestionServiceProtocol
private let analyticsService: AnalyticsService private let analyticsService: AnalyticsService
private let draftService: ComposerDraftServiceProtocol
private let mentionBuilder: MentionBuilderProtocol private let mentionBuilder: MentionBuilderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol private let attributedStringBuilder: AttributedStringBuilderProtocol
@ -46,15 +47,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
private var currentLinkData: WysiwygLinkData? private var currentLinkData: WysiwygLinkData?
private var replyLoadingTask: Task<Void, Never>?
init(wysiwygViewModel: WysiwygComposerViewModel, init(wysiwygViewModel: WysiwygComposerViewModel,
completionSuggestionService: CompletionSuggestionServiceProtocol, completionSuggestionService: CompletionSuggestionServiceProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
mentionDisplayHelper: MentionDisplayHelper, mentionDisplayHelper: MentionDisplayHelper,
analyticsService: AnalyticsService) { analyticsService: AnalyticsService,
composerDraftService: ComposerDraftServiceProtocol) {
self.wysiwygViewModel = wysiwygViewModel self.wysiwygViewModel = wysiwygViewModel
self.completionSuggestionService = completionSuggestionService self.completionSuggestionService = completionSuggestionService
self.analyticsService = analyticsService self.analyticsService = analyticsService
draftService = composerDraftService
mentionBuilder = MentionBuilder() mentionBuilder = MentionBuilder()
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder) attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder)
@ -177,6 +182,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string) completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string)
case .didToggleFormattingOptions: case .didToggleFormattingOptions:
if context.composerFormattingEnabled { if context.composerFormattingEnabled {
guard !context.plainComposerText.string.isEmpty else {
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.wysiwygViewModel.textView.flushPills() self.wysiwygViewModel.textView.flushPills()
self.wysiwygViewModel.setMarkdownContent(self.context.plainComposerText.string) self.wysiwygViewModel.setMarkdownContent(self.context.plainComposerText.string)
@ -191,13 +199,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
switch roomAction { switch roomAction {
case .setMode(mode: let mode): case .setMode(mode: let mode):
set(mode: mode) set(mode: mode)
case .setText(text: let text): case .setText(let plainText, let htmlText):
set(text: text) if let htmlText, context.composerFormattingEnabled {
set(text: htmlText)
} else {
set(text: plainText)
}
case .removeFocus: case .removeFocus:
state.bindings.composerFocused = false state.bindings.composerFocused = false
case .clear: case .clear:
set(mode: .default) set(mode: .default)
set(text: "") set(text: "")
case .saveDraft:
handleSaveDraft()
case .loadDraft:
Task {
await handleLoadDraft()
}
} }
} }
@ -211,6 +229,99 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
// MARK: - Private // 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() { private func sendPlainComposerText() {
let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText) let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText)
@ -338,6 +449,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
private func set(mode: RoomScreenComposerMode) { private func set(mode: RoomScreenComposerMode) {
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
replyLoadingTask?.cancel()
}
guard mode != state.composerMode else { return } guard mode != state.composerMode else { return }
state.composerMode = mode state.composerMode = mode
@ -357,7 +472,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private func set(text: String) { private func set(text: String) {
if context.composerFormattingEnabled { if context.composerFormattingEnabled {
wysiwygViewModel.textView.flushPills() wysiwygViewModel.textView.flushPills()
wysiwygViewModel.setHtmlContent(text) wysiwygViewModel.setHtmlContent(text)
} else { } else {
state.bindings.plainComposerText = .init(string: text) state.bindings.plainComposerText = .init(string: text)

View File

@ -292,7 +292,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, 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())), 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()))] .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) ComposerToolbar.voiceMessagePreviewMock(uploading: false)
} }
.previewDisplayName("Voice Message") .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()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock())
model.state.composerEmpty = focused model.state.composerEmpty = focused
return model return model
} }
@ -344,7 +352,8 @@ extension ComposerToolbar {
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock())
model.state.composerEmpty = focused model.state.composerEmpty = focused
return model return model
} }
@ -360,7 +369,8 @@ extension ComposerToolbar {
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock())
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState()) model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
return model return model
} }
@ -377,7 +387,8 @@ extension ComposerToolbar {
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, 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) model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: uploading)
return model return model
} }
@ -385,4 +396,27 @@ extension ComposerToolbar {
wysiwygViewModel: wysiwygViewModel, wysiwygViewModel: wysiwygViewModel,
keyCommands: []) 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: [])
}
} }

View File

@ -100,7 +100,8 @@ struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock())
static var previews: some View { static var previews: some View {
RoomAttachmentPicker(context: viewModel.context) RoomAttachmentPicker(context: viewModel.context)

View File

@ -30,6 +30,7 @@ struct RoomScreenCoordinatorParameters {
let completionSuggestionService: CompletionSuggestionServiceProtocol let completionSuggestionService: CompletionSuggestionServiceProtocol
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
let appSettings: AppSettings let appSettings: AppSettings
let composerDraftService: ComposerDraftServiceProtocol
} }
enum RoomScreenCoordinatorAction { enum RoomScreenCoordinatorAction {
@ -59,16 +60,17 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: RoomScreenCoordinatorParameters) { init(parameters: RoomScreenCoordinatorParameters) {
viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID, focussedEventID: parameters.focussedEventID,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider, mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager, voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController, userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator, appMediator: parameters.appMediator,
appSettings: parameters.appSettings, appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics)
self.viewModel = viewModel
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight, wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight, maxCompressedHeight: ComposerConstant.maxHeight,
@ -78,7 +80,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
completionSuggestionService: parameters.completionSuggestionService, completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
mentionDisplayHelper: ComposerMentionDisplayHelper(roomContext: viewModel.context), 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 // MARK: - Public
@ -128,6 +136,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
viewModel.process(composerAction: action) viewModel.process(composerAction: action)
} }
.store(in: &cancellables) .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) { func focusOnEvent(eventID: String) {
@ -135,6 +146,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
} }
func stop() { func stop() {
viewModel.saveDraft()
viewModel.stop() viewModel.stop()
} }
@ -143,7 +155,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
wysiwygViewModel: wysiwygViewModel, wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands) 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()
})
} }
} }

View File

@ -265,16 +265,18 @@ class RoomScreenInteractionHandler {
private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) { private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) {
let text: String let text: String
var htmlText: String?
switch messageTimelineItem.contentType { switch messageTimelineItem.contentType {
case .text: case .text(let content):
text = messageTimelineItem.body text = content.body
case .emote: htmlText = content.formattedBodyHTMLString
text = "/me " + messageTimelineItem.body case .emote(let content):
text = "/me " + content.body
default: default:
text = messageTimelineItem.body 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)))) actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
} }

View File

@ -60,6 +60,29 @@ enum RoomScreenComposerMode: Equatable {
return false 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 { enum RoomScreenViewPollAction {
@ -110,9 +133,11 @@ enum RoomScreenViewAction {
enum RoomScreenComposerAction { enum RoomScreenComposerAction {
case setMode(mode: RoomScreenComposerMode) case setMode(mode: RoomScreenComposerMode)
case setText(text: String) case setText(plainText: String, htmlText: String?)
case removeFocus case removeFocus
case clear case clear
case saveDraft
case loadDraft
} }
struct RoomScreenViewState: BindableState { struct RoomScreenViewState: BindableState {

View File

@ -133,11 +133,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Public // MARK: - Public
func loadDraft() {
guard appSettings.draftRestoringEnabled else {
return
}
actionsSubject.send(.composer(action: .loadDraft))
}
func stop() { func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil state.bindings.mediaPreviewItem = nil
} }
func saveDraft() {
actionsSubject.send(.composer(action: .saveDraft))
}
override func process(viewAction: RoomScreenViewAction) { override func process(viewAction: RoomScreenViewAction) {
switch viewAction { switch viewAction {
case .itemAppeared(let id): case .itemAppeared(let id):

View File

@ -26,4 +26,6 @@ protocol RoomScreenViewModelProtocol {
/// Updates the timeline to show and highlight the item with the corresponding event ID. /// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async func focusOnEvent(eventID: String) async
func stop() func stop()
func loadDraft()
func saveDraft()
} }

View File

@ -46,6 +46,7 @@ enum DeveloperOptionsScreenViewAction {
protocol DeveloperOptionsProtocol: AnyObject { protocol DeveloperOptionsProtocol: AnyObject {
var logLevel: TracingConfiguration.LogLevel { get set } var logLevel: TracingConfiguration.LogLevel { get set }
var hideUnreadMessagesBadge: Bool { get set } var hideUnreadMessagesBadge: Bool { get set }
var draftRestoringEnabled: Bool { get set }
var elementCallBaseURL: URL { get set } var elementCallBaseURL: URL { get set }
} }

View File

@ -32,6 +32,12 @@ struct DeveloperOptionsScreen: View {
Text("Hide grey dots") Text("Hide grey dots")
} }
} }
Section("Room") {
Toggle(isOn: $context.draftRestoringEnabled) {
Text("Allow drafts to be restored")
}
}
Section("Element Call") { Section("Element Call") {
TextField(context.elementCallBaseURL.absoluteString, text: $elementCallBaseURLString) TextField(context.elementCallBaseURL.absoluteString, text: $elementCallBaseURLString)

View File

@ -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<Void, ComposerDraftServiceError> {
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<ComposerDraftProxy?, ComposerDraftServiceError> {
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<TimelineItemReply, ComposerDraftServiceError> {
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<Void, ComposerDraftServiceError> {
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)
}
}
}

View File

@ -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<Void, ComposerDraftServiceError>
func loadDraft() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>
func clearDraft() async -> Result<Void, ComposerDraftServiceError>
func getReply(eventID: String) async -> Result<TimelineItemReply, ComposerDraftServiceError>
}

View File

@ -565,6 +565,37 @@ class RoomProxy: RoomProxyProtocol {
return .failure(.sdkError(error)) return .failure(.sdkError(error))
} }
} }
// MARK: - Drafts
func saveDraft(_ draft: ComposerDraft) async -> Result<Void, RoomProxyError> {
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<ComposerDraft?, RoomProxyError> {
do {
return try await .success(room.loadComposerDraft())
} catch {
MXLog.error("Failed restoring draft with error: \(error)")
return .failure(.sdkError(error))
}
}
func clearDraft() async -> Result<Void, RoomProxyError> {
do {
try await room.clearComposerDraft()
return .success(())
} catch {
MXLog.error("Failed clearing draft with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private // MARK: - Private

View File

@ -126,7 +126,6 @@ protocol RoomProxyProtocol {
// MARK: - Element Call // MARK: - Element Call
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError> func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError>
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
@ -134,8 +133,13 @@ protocol RoomProxyProtocol {
// MARK: - Permalinks // MARK: - Permalinks
func matrixToPermalink() async -> Result<URL, RoomProxyError> func matrixToPermalink() async -> Result<URL, RoomProxyError>
func matrixToEventPermalink(_ eventID: String) async -> Result<URL, RoomProxyError> func matrixToEventPermalink(_ eventID: String) async -> Result<URL, RoomProxyError>
// MARK: - Drafts
func saveDraft(_ draft: ComposerDraft) async -> Result<Void, RoomProxyError>
func loadDraft() async -> Result<ComposerDraft?, RoomProxyError>
func clearDraft() async -> Result<Void, RoomProxyError>
} }
extension RoomProxyProtocol { extension RoomProxyProtocol {

View File

@ -16,6 +16,11 @@
import Foundation import Foundation
struct TimelineItemReply {
let details: TimelineItemReplyDetails
let isThreaded: Bool
}
enum TimelineItemReplyDetails: Hashable { enum TimelineItemReplyDetails: Hashable {
case notLoaded(eventID: String) case notLoaded(eventID: String)
case loading(eventID: String) case loading(eventID: String)

View File

@ -216,7 +216,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildTextTimelineItemContent(messageContent), content: buildTextTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -236,7 +236,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildImageTimelineItemContent(messageContent), content: buildImageTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -256,7 +256,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildVideoTimelineItemContent(messageContent), content: buildVideoTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -276,7 +276,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(messageContent), content: buildAudioTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -296,7 +296,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(messageContent), content: buildAudioTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -316,7 +316,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildFileTimelineItemContent(messageContent), content: buildFileTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -336,7 +336,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildNoticeTimelineItemContent(messageContent), content: buildNoticeTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -356,7 +356,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent), 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(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -376,7 +376,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isThreaded: isThreaded, isThreaded: isThreaded,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: buildLocationTimelineItemContent(messageContent), content: buildLocationTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions), reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
@ -645,14 +645,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
// MARK: - Reply details // MARK: - Reply details
private func buildReplyToDetailsFrom(details: InReplyToDetails?) -> TimelineItemReplyDetails? { func buildReply(details: InReplyToDetails) -> TimelineItemReply {
guard let details else { return nil } let isThreaded = details.event.isThreaded
switch details.event { switch details.event {
case .unavailable: case .unavailable:
return .notLoaded(eventID: details.eventId) return .init(details: .notLoaded(eventID: details.eventId), isThreaded: isThreaded)
case .pending: case .pending:
return .loading(eventID: details.eventId) return .init(details: .loading(eventID: details.eventId), isThreaded: isThreaded)
case let .ready(timelineItem, senderID, senderProfile): case let .ready(timelineItem, senderID, senderProfile):
let sender: TimelineItemSender let sender: TimelineItemSender
switch senderProfile { switch senderProfile {
@ -672,7 +671,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
switch timelineItem.kind() { switch timelineItem.kind() {
case .message: 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, _, _, _, _, _, _): case .poll(let question, _, _, _, _, _, _):
replyContent = .poll(question: question) replyContent = .poll(question: question)
case .sticker(let body, _, _): case .sticker(let body, _, _):
@ -683,12 +682,20 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) 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): 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 { private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails {
let replyContent: EventBasedMessageTimelineItemContentType 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
}
}
}

View File

@ -16,7 +16,10 @@
import Foundation import Foundation
import MatrixRustSDK
@MainActor @MainActor
protocol RoomTimelineItemFactoryProtocol { protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol? func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol?
func buildReply(details: InReplyToDetails) -> TimelineItemReply
} }

View File

@ -172,6 +172,15 @@ final class TimelineProxy: TimelineProxyProtocol {
} }
} }
func getLoadedReplyDetails(eventID: String) async -> Result<InReplyToDetails, TimelineProxyError> {
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 // MARK: - Sending
func sendAudio(url: URL, func sendAudio(url: URL,

View File

@ -107,6 +107,8 @@ protocol TimelineProxyProtocol {
func endPoll(pollStartID: String, text: String) async -> Result<Void, TimelineProxyError> func endPoll(pollStartID: String, text: String) async -> Result<Void, TimelineProxyError>
func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, TimelineProxyError> func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, TimelineProxyError>
func getLoadedReplyDetails(eventID: String) async -> Result<InReplyToDetails, TimelineProxyError>
} }
extension TimelineProxyProtocol { extension TimelineProxyProtocol {

View File

@ -256,7 +256,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
@ -272,7 +273,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
@ -288,7 +290,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
@ -304,7 +307,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
@ -323,7 +327,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -342,7 +347,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -361,7 +367,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -381,7 +388,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -400,7 +408,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -418,7 +427,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -450,7 +460,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -469,7 +480,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
@ -488,7 +500,8 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider(), emojiProvider: EmojiProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings,
composerDraftService: ComposerDraftServiceMock())
let coordinator = RoomScreenCoordinator(parameters: parameters) let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -26,6 +26,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
private var wysiwygViewModel: WysiwygComposerViewModel! private var wysiwygViewModel: WysiwygComposerViewModel!
private var viewModel: ComposerToolbarViewModel! private var viewModel: ComposerToolbarViewModel!
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock! private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
private var draftServiceMock: ComposerDraftServiceMock!
override func setUp() { override func setUp() {
AppSettings.resetAllSettings() AppSettings.resetAllSettings()
@ -33,11 +34,13 @@ class ComposerToolbarViewModelTests: XCTestCase {
ServiceLocator.shared.register(appSettings: appSettings) ServiceLocator.shared.register(appSettings: appSettings)
wysiwygViewModel = WysiwygComposerViewModel() wysiwygViewModel = WysiwygComposerViewModel()
completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())
draftServiceMock = ComposerDraftServiceMock()
viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: completionSuggestionServiceMock, completionSuggestionService: completionSuggestionServiceMock,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
viewModel.context.composerFormattingEnabled = true viewModel.context.composerFormattingEnabled = true
} }
@ -113,7 +116,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
completionSuggestionService: mockCompletionSuggestionService, completionSuggestionService: mockCompletionSuggestionService,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock, mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics,
composerDraftService: draftServiceMock)
XCTAssertEqual(viewModel.state.suggestions, suggestions) XCTAssertEqual(viewModel.state.suggestions, suggestions)
} }
@ -186,6 +190,304 @@ class ComposerToolbarViewModelTests: XCTestCase {
try await deferred.fulfill() 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, "<strong>Hello</strong> world!")
XCTAssertEqual(draft.draftType, .newMessage)
defer { expectation.fulfill() }
return .success(())
}
viewModel.context.composerFormattingEnabled = true
wysiwygViewModel.setHtmlContent("<strong>Hello</strong> 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: "<strong>Hello</strong> 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, "<strong>Hello</strong> 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 { private extension MentionSuggestionItem {

1
changelog.d/2849.feature Normal file
View File

@ -0,0 +1 @@
Composer drafts will now be saved upon leaving the screen, and get restored later when displayed again.