mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Store and restore drafts (#2898)
This commit is contained in:
parent
b8dea8ac4e
commit
cefa38049f
@ -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 */,
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
@ -149,5 +149,7 @@ extension RoomProxyMock {
|
|||||||
|
|
||||||
matrixToPermalinkReturnValue = .success(.homeDirectory)
|
matrixToPermalinkReturnValue = .success(.homeDirectory)
|
||||||
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
||||||
|
loadDraftReturnValue = .success(nil)
|
||||||
|
clearDraftReturnValue = .success(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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: [])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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):
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
}
|
@ -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
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-en-GB.Reply.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPad-pseudo.Reply.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-en-GB.Reply.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_composerToolbar-iPhone-15-pseudo.Reply.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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
1
changelog.d/2849.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Composer drafts will now be saved upon leaving the screen, and get restored later when displayed again.
|
Loading…
x
Reference in New Issue
Block a user