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 */; };
|
||||
3A64A93A651A3CB8774ADE8E /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = BA93CD75CCE486660C9040BD /* Collections */; };
|
||||
3A7DD0D13B0FB8876D69D829 /* TextBasedRoomTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */; };
|
||||
3AA9E878FDCFF85664AC071F /* ComposerDraftService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */; };
|
||||
3B0F9B57D25B07E66F15762A /* MediaUploadPreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */; };
|
||||
3B28408450BCAED911283AA2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; };
|
||||
3C31E1A65EEB61E72E1113B4 /* AudioRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */; };
|
||||
@ -849,6 +850,7 @@
|
||||
CA12AE0DCD57D49CD96C699A /* WaveformCursorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */; };
|
||||
CB137BFB3E083C33E398A6CB /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 020597E28A4BC8E1BE8EDF6E /* KeychainAccess */; };
|
||||
CB498F4E27AA0545DCEF0F6F /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 36B7FC232711031AA2B0D188 /* DTCoreText */; };
|
||||
CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */; };
|
||||
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */; };
|
||||
CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; };
|
||||
CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */; };
|
||||
@ -1253,6 +1255,7 @@
|
||||
1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -1787,6 +1790,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -2343,6 +2347,7 @@
|
||||
AAFDD509929A0CCF8BCE51EB /* Authentication */,
|
||||
0ED3F5C21537519389C07644 /* BugReport */,
|
||||
8039515BAA53B7C3275AC64A /* Client */,
|
||||
8B5E91450E85A9689931B221 /* ComposerDraft */,
|
||||
8C3BAE06B336D97DABBE2509 /* CreateRoom */,
|
||||
92E99C57D7F92ED16F73282C /* ElementCall */,
|
||||
39557ADF21345E18F3865B9E /* Emojis */,
|
||||
@ -3988,6 +3993,15 @@
|
||||
path = Sounds;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8B5E91450E85A9689931B221 /* ComposerDraft */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D70253004A5AEC9C73D6A4F /* ComposerDraftService.swift */,
|
||||
A7E37072597F67C4DD8CC2DB /* ComposerDraftServiceProtocol.swift */,
|
||||
);
|
||||
path = ComposerDraft;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C3BAE06B336D97DABBE2509 /* CreateRoom */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -5975,6 +5989,8 @@
|
||||
19DF5600A7F547B22DD7872A /* CompletionSuggestionService.swift in Sources */,
|
||||
EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */,
|
||||
16E4F1B8B9BFE1367F96DDA7 /* CompletionSuggestionView.swift in Sources */,
|
||||
3AA9E878FDCFF85664AC071F /* ComposerDraftService.swift in Sources */,
|
||||
CB6956565D858C523E3E3B16 /* ComposerDraftServiceProtocol.swift in Sources */,
|
||||
937985546F708339711ECDFC /* ComposerToolbar.swift in Sources */,
|
||||
94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */,
|
||||
71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */,
|
||||
|
@ -44,6 +44,7 @@ final class AppSettings {
|
||||
|
||||
// Feature flags
|
||||
case publicSearchEnabled
|
||||
case draftRestoringEnabled
|
||||
}
|
||||
|
||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||
@ -268,6 +269,9 @@ final class AppSettings {
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.publicSearchEnabled, defaultValue: isDevelopmentBuild, storageType: .volatile)
|
||||
var publicSearchEnabled
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.draftRestoringEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var draftRestoringEnabled
|
||||
|
||||
#endif
|
||||
|
||||
|
@ -549,6 +549,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy)
|
||||
|
||||
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
||||
focussedEventID: focussedEventID,
|
||||
timelineController: timelineController,
|
||||
@ -558,7 +560,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
emojiProvider: emojiProvider,
|
||||
completionSuggestionService: completionSuggestionService,
|
||||
appMediator: appMediator,
|
||||
appSettings: appSettings)
|
||||
appSettings: appSettings,
|
||||
composerDraftService: composerDraftService)
|
||||
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
coordinator.actions
|
||||
|
@ -4415,6 +4415,273 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
||||
}
|
||||
}
|
||||
class ComposerDraftServiceMock: ComposerDraftServiceProtocol {
|
||||
|
||||
//MARK: - saveDraft
|
||||
|
||||
var saveDraftUnderlyingCallsCount = 0
|
||||
var saveDraftCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return saveDraftUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = saveDraftUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
saveDraftUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
saveDraftUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var saveDraftCalled: Bool {
|
||||
return saveDraftCallsCount > 0
|
||||
}
|
||||
var saveDraftReceivedDraft: ComposerDraftProxy?
|
||||
var saveDraftReceivedInvocations: [ComposerDraftProxy] = []
|
||||
|
||||
var saveDraftUnderlyingReturnValue: Result<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 {
|
||||
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
||||
get { return underlyingActions }
|
||||
@ -10387,6 +10654,202 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return matrixToEventPermalinkReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - saveDraft
|
||||
|
||||
var saveDraftUnderlyingCallsCount = 0
|
||||
var saveDraftCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return saveDraftUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = saveDraftUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
saveDraftUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
saveDraftUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var saveDraftCalled: Bool {
|
||||
return saveDraftCallsCount > 0
|
||||
}
|
||||
var saveDraftReceivedDraft: ComposerDraft?
|
||||
var saveDraftReceivedInvocations: [ComposerDraft] = []
|
||||
|
||||
var saveDraftUnderlyingReturnValue: Result<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 {
|
||||
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
|
||||
@ -12712,6 +13175,74 @@ class TimelineProxyMock: TimelineProxyProtocol {
|
||||
return sendPollResponsePollStartIDAnswersReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - getLoadedReplyDetails
|
||||
|
||||
var getLoadedReplyDetailsEventIDUnderlyingCallsCount = 0
|
||||
var getLoadedReplyDetailsEventIDCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return getLoadedReplyDetailsEventIDUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = getLoadedReplyDetailsEventIDUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
getLoadedReplyDetailsEventIDUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
getLoadedReplyDetailsEventIDUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var getLoadedReplyDetailsEventIDCalled: Bool {
|
||||
return getLoadedReplyDetailsEventIDCallsCount > 0
|
||||
}
|
||||
var getLoadedReplyDetailsEventIDReceivedEventID: String?
|
||||
var getLoadedReplyDetailsEventIDReceivedInvocations: [String] = []
|
||||
|
||||
var getLoadedReplyDetailsEventIDUnderlyingReturnValue: Result<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 {
|
||||
|
||||
|
@ -149,5 +149,7 @@ extension RoomProxyMock {
|
||||
|
||||
matrixToPermalinkReturnValue = .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 completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
private let analyticsService: AnalyticsService
|
||||
private let draftService: ComposerDraftServiceProtocol
|
||||
|
||||
private let mentionBuilder: MentionBuilderProtocol
|
||||
private let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
@ -46,15 +47,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
|
||||
private var currentLinkData: WysiwygLinkData?
|
||||
|
||||
private var replyLoadingTask: Task<Void, Never>?
|
||||
|
||||
init(wysiwygViewModel: WysiwygComposerViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mentionDisplayHelper: MentionDisplayHelper,
|
||||
analyticsService: AnalyticsService) {
|
||||
analyticsService: AnalyticsService,
|
||||
composerDraftService: ComposerDraftServiceProtocol) {
|
||||
self.wysiwygViewModel = wysiwygViewModel
|
||||
self.completionSuggestionService = completionSuggestionService
|
||||
self.analyticsService = analyticsService
|
||||
draftService = composerDraftService
|
||||
|
||||
mentionBuilder = MentionBuilder()
|
||||
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder)
|
||||
@ -177,6 +182,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string)
|
||||
case .didToggleFormattingOptions:
|
||||
if context.composerFormattingEnabled {
|
||||
guard !context.plainComposerText.string.isEmpty else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.wysiwygViewModel.textView.flushPills()
|
||||
self.wysiwygViewModel.setMarkdownContent(self.context.plainComposerText.string)
|
||||
@ -191,13 +199,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
switch roomAction {
|
||||
case .setMode(mode: let mode):
|
||||
set(mode: mode)
|
||||
case .setText(text: let text):
|
||||
set(text: text)
|
||||
case .setText(let plainText, let htmlText):
|
||||
if let htmlText, context.composerFormattingEnabled {
|
||||
set(text: htmlText)
|
||||
} else {
|
||||
set(text: plainText)
|
||||
}
|
||||
case .removeFocus:
|
||||
state.bindings.composerFocused = false
|
||||
case .clear:
|
||||
set(mode: .default)
|
||||
set(text: "")
|
||||
case .saveDraft:
|
||||
handleSaveDraft()
|
||||
case .loadDraft:
|
||||
Task {
|
||||
await handleLoadDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,6 +229,99 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func handleLoadDraft() async {
|
||||
guard case let .success(draft) = await draftService.loadDraft(),
|
||||
let draft else {
|
||||
return
|
||||
}
|
||||
|
||||
if let html = draft.htmlText {
|
||||
context.composerFormattingEnabled = true
|
||||
DispatchQueue.main.async {
|
||||
self.set(text: html)
|
||||
}
|
||||
} else {
|
||||
context.composerFormattingEnabled = false
|
||||
set(text: draft.plainText)
|
||||
}
|
||||
|
||||
switch draft.draftType {
|
||||
case .newMessage:
|
||||
set(mode: .default)
|
||||
case .edit(let eventID):
|
||||
set(mode: .edit(originalItemId: .init(timelineID: "", eventID: eventID)))
|
||||
case .reply(let eventID):
|
||||
set(mode: .reply(itemID: .init(timelineID: "", eventID: eventID), replyDetails: .loading(eventID: eventID), isThread: false))
|
||||
replyLoadingTask = Task {
|
||||
let reply = switch await draftService.getReply(eventID: eventID) {
|
||||
case .success(let reply):
|
||||
reply
|
||||
case .failure:
|
||||
TimelineItemReply(details: .error(eventID: eventID, message: L10n.commonSomethingWentWrong), isThreaded: false)
|
||||
}
|
||||
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
set(mode: .reply(itemID: .init(timelineID: "", eventID: eventID), replyDetails: reply.details, isThread: reply.isThreaded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSaveDraft() {
|
||||
let plainText: String
|
||||
let htmlText: String?
|
||||
let type: ComposerDraftProxy.ComposerDraftType
|
||||
|
||||
if context.composerFormattingEnabled {
|
||||
if wysiwygViewModel.isContentEmpty, state.composerMode == .default {
|
||||
Task {
|
||||
await draftService.clearDraft()
|
||||
}
|
||||
return
|
||||
}
|
||||
plainText = wysiwygViewModel.content.markdown
|
||||
htmlText = wysiwygViewModel.content.html
|
||||
} else {
|
||||
if context.plainComposerText.string.isEmpty, state.composerMode == .default {
|
||||
Task {
|
||||
await draftService.clearDraft()
|
||||
}
|
||||
return
|
||||
}
|
||||
plainText = context.plainComposerText.string
|
||||
htmlText = nil
|
||||
}
|
||||
|
||||
switch state.composerMode {
|
||||
case .default:
|
||||
type = .newMessage
|
||||
case .edit(let itemID):
|
||||
guard let eventID = itemID.eventID else {
|
||||
MXLog.error("The event id for this message is missing")
|
||||
return
|
||||
}
|
||||
type = .edit(eventID: eventID)
|
||||
case .reply(let itemID, _, _):
|
||||
guard let eventID = itemID.eventID else {
|
||||
MXLog.error("The event id for this message is missing")
|
||||
return
|
||||
}
|
||||
type = .reply(eventID: eventID)
|
||||
default:
|
||||
// Do not save a draft for the other cases
|
||||
Task {
|
||||
await draftService.clearDraft()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await draftService.saveDraft(.init(plainText: plainText, htmlText: htmlText, draftType: type))
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPlainComposerText() {
|
||||
let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText)
|
||||
|
||||
@ -338,6 +449,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
|
||||
private func set(mode: RoomScreenComposerMode) {
|
||||
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
|
||||
replyLoadingTask?.cancel()
|
||||
}
|
||||
|
||||
guard mode != state.composerMode else { return }
|
||||
|
||||
state.composerMode = mode
|
||||
@ -357,7 +472,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
private func set(text: String) {
|
||||
if context.composerFormattingEnabled {
|
||||
wysiwygViewModel.textView.flushPills()
|
||||
|
||||
wysiwygViewModel.setHtmlContent(text)
|
||||
} else {
|
||||
state.bindings.plainComposerText = .init(string: text)
|
||||
|
@ -292,7 +292,8 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
|
||||
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))]
|
||||
|
||||
@ -315,6 +316,12 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
ComposerToolbar.voiceMessagePreviewMock(uploading: false)
|
||||
}
|
||||
.previewDisplayName("Voice Message")
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ComposerToolbar.replyLoadingPreviewMock(isLoading: true)
|
||||
ComposerToolbar.replyLoadingPreviewMock(isLoading: false)
|
||||
}
|
||||
.previewDisplayName("Reply")
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,7 +335,8 @@ extension ComposerToolbar {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
model.state.composerEmpty = focused
|
||||
return model
|
||||
}
|
||||
@ -344,7 +352,8 @@ extension ComposerToolbar {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
model.state.composerEmpty = focused
|
||||
return model
|
||||
}
|
||||
@ -360,7 +369,8 @@ extension ComposerToolbar {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
|
||||
return model
|
||||
}
|
||||
@ -377,7 +387,8 @@ extension ComposerToolbar {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: uploading)
|
||||
return model
|
||||
}
|
||||
@ -385,4 +396,27 @@ extension ComposerToolbar {
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
|
||||
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
model.state.composerMode = isLoading ? .reply(itemID: .init(timelineID: ""),
|
||||
replyDetails: .loading(eventID: ""),
|
||||
isThread: false) :
|
||||
.reply(itemID: .init(timelineID: ""),
|
||||
replyDetails: .loaded(sender: .init(id: "",
|
||||
displayName: "Test"),
|
||||
eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false)
|
||||
return model
|
||||
}
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,8 @@ struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
|
||||
static var previews: some View {
|
||||
RoomAttachmentPicker(context: viewModel.context)
|
||||
|
@ -30,6 +30,7 @@ struct RoomScreenCoordinatorParameters {
|
||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
let appSettings: AppSettings
|
||||
let composerDraftService: ComposerDraftServiceProtocol
|
||||
}
|
||||
|
||||
enum RoomScreenCoordinatorAction {
|
||||
@ -59,16 +60,17 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
focussedEventID: parameters.focussedEventID,
|
||||
timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: parameters.appSettings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
focussedEventID: parameters.focussedEventID,
|
||||
timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: parameters.appSettings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
self.viewModel = viewModel
|
||||
|
||||
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
|
||||
maxCompressedHeight: ComposerConstant.maxHeight,
|
||||
@ -78,7 +80,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
completionSuggestionService: parameters.completionSuggestionService,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper(roomContext: viewModel.context),
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: parameters.composerDraftService)
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).sink { _ in
|
||||
viewModel.saveDraft()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@ -128,6 +136,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel.process(composerAction: action)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Loading the draft requires the subscriptions to be set up first otherwise the room won't be be able to propagate the information to the composer.
|
||||
viewModel.loadDraft()
|
||||
}
|
||||
|
||||
func focusOnEvent(eventID: String) {
|
||||
@ -135,6 +146,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
viewModel.saveDraft()
|
||||
viewModel.stop()
|
||||
}
|
||||
|
||||
@ -143,7 +155,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: composerViewModel.keyCommands)
|
||||
|
||||
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar))
|
||||
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar)
|
||||
.onDisappear { [weak self] in
|
||||
self?.viewModel.saveDraft()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -265,16 +265,18 @@ class RoomScreenInteractionHandler {
|
||||
|
||||
private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) {
|
||||
let text: String
|
||||
var htmlText: String?
|
||||
switch messageTimelineItem.contentType {
|
||||
case .text:
|
||||
text = messageTimelineItem.body
|
||||
case .emote:
|
||||
text = "/me " + messageTimelineItem.body
|
||||
case .text(let content):
|
||||
text = content.body
|
||||
htmlText = content.formattedBodyHTMLString
|
||||
case .emote(let content):
|
||||
text = "/me " + content.body
|
||||
default:
|
||||
text = messageTimelineItem.body
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .setText(text: text)))
|
||||
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText)))
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
|
||||
}
|
||||
|
||||
|
@ -60,6 +60,29 @@ enum RoomScreenComposerMode: Equatable {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isLoadingReply: Bool {
|
||||
switch self {
|
||||
case .reply(_, let replyDetails, _):
|
||||
switch replyDetails {
|
||||
case .loading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var replyEventID: String? {
|
||||
switch self {
|
||||
case .reply(let itemID, _, _):
|
||||
return itemID.eventID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RoomScreenViewPollAction {
|
||||
@ -110,9 +133,11 @@ enum RoomScreenViewAction {
|
||||
|
||||
enum RoomScreenComposerAction {
|
||||
case setMode(mode: RoomScreenComposerMode)
|
||||
case setText(text: String)
|
||||
case setText(plainText: String, htmlText: String?)
|
||||
case removeFocus
|
||||
case clear
|
||||
case saveDraft
|
||||
case loadDraft
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
|
@ -133,11 +133,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func loadDraft() {
|
||||
guard appSettings.draftRestoringEnabled else {
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.composer(action: .loadDraft))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
|
||||
state.bindings.mediaPreviewItem = nil
|
||||
}
|
||||
|
||||
func saveDraft() {
|
||||
actionsSubject.send(.composer(action: .saveDraft))
|
||||
}
|
||||
|
||||
override func process(viewAction: RoomScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .itemAppeared(let id):
|
||||
|
@ -26,4 +26,6 @@ protocol RoomScreenViewModelProtocol {
|
||||
/// Updates the timeline to show and highlight the item with the corresponding event ID.
|
||||
func focusOnEvent(eventID: String) async
|
||||
func stop()
|
||||
func loadDraft()
|
||||
func saveDraft()
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ enum DeveloperOptionsScreenViewAction {
|
||||
protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var logLevel: TracingConfiguration.LogLevel { get set }
|
||||
var hideUnreadMessagesBadge: Bool { get set }
|
||||
var draftRestoringEnabled: Bool { get set }
|
||||
var elementCallBaseURL: URL { get set }
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,12 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("Hide grey dots")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Room") {
|
||||
Toggle(isOn: $context.draftRestoringEnabled) {
|
||||
Text("Allow drafts to be restored")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Element Call") {
|
||||
TextField(context.elementCallBaseURL.absoluteString, text: $elementCallBaseURLString)
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
@ -126,7 +126,6 @@ protocol RoomProxyProtocol {
|
||||
// MARK: - Element Call
|
||||
|
||||
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError>
|
||||
|
||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
|
||||
@ -134,8 +133,13 @@ protocol RoomProxyProtocol {
|
||||
// MARK: - Permalinks
|
||||
|
||||
func matrixToPermalink() 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 {
|
||||
|
@ -16,6 +16,11 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TimelineItemReply {
|
||||
let details: TimelineItemReplyDetails
|
||||
let isThreaded: Bool
|
||||
}
|
||||
|
||||
enum TimelineItemReplyDetails: Hashable {
|
||||
case notLoaded(eventID: String)
|
||||
case loading(eventID: String)
|
||||
|
@ -216,7 +216,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildTextTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -236,7 +236,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildImageTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -256,7 +256,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildVideoTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -276,7 +276,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildAudioTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -296,7 +296,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildAudioTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -316,7 +316,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildFileTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -336,7 +336,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildNoticeTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -356,7 +356,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -376,7 +376,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
isThreaded: isThreaded,
|
||||
sender: eventItemProxy.sender,
|
||||
content: buildLocationTimelineItemContent(messageContent),
|
||||
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
|
||||
replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageTimelineItem.inReplyTo()),
|
||||
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
@ -645,14 +645,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
|
||||
// MARK: - Reply details
|
||||
|
||||
private func buildReplyToDetailsFrom(details: InReplyToDetails?) -> TimelineItemReplyDetails? {
|
||||
guard let details else { return nil }
|
||||
|
||||
func buildReply(details: InReplyToDetails) -> TimelineItemReply {
|
||||
let isThreaded = details.event.isThreaded
|
||||
switch details.event {
|
||||
case .unavailable:
|
||||
return .notLoaded(eventID: details.eventId)
|
||||
return .init(details: .notLoaded(eventID: details.eventId), isThreaded: isThreaded)
|
||||
case .pending:
|
||||
return .loading(eventID: details.eventId)
|
||||
return .init(details: .loading(eventID: details.eventId), isThreaded: isThreaded)
|
||||
case let .ready(timelineItem, senderID, senderProfile):
|
||||
let sender: TimelineItemSender
|
||||
switch senderProfile {
|
||||
@ -672,7 +671,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
|
||||
switch timelineItem.kind() {
|
||||
case .message:
|
||||
return timelineItemReplyDetails(sender: sender, eventID: details.eventId, messageType: timelineItem.asMessage()?.msgtype())
|
||||
return .init(details: timelineItemReplyDetails(sender: sender, eventID: details.eventId, messageType: timelineItem.asMessage()?.msgtype()), isThreaded: isThreaded)
|
||||
case .poll(let question, _, _, _, _, _, _):
|
||||
replyContent = .poll(question: question)
|
||||
case .sticker(let body, _, _):
|
||||
@ -683,12 +682,20 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent)))
|
||||
}
|
||||
|
||||
return .loaded(sender: sender, eventID: details.eventId, eventContent: replyContent)
|
||||
return .init(details: .loaded(sender: sender, eventID: details.eventId, eventContent: replyContent), isThreaded: isThreaded)
|
||||
case let .error(message):
|
||||
return .error(eventID: details.eventId, message: message)
|
||||
return .init(details: .error(eventID: details.eventId, message: message), isThreaded: isThreaded)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildReplyToDetailsFromDetailsIfAvailable(details: InReplyToDetails?) -> TimelineItemReplyDetails? {
|
||||
guard let details else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return buildReply(details: details).details
|
||||
}
|
||||
|
||||
private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails {
|
||||
let replyContent: EventBasedMessageTimelineItemContentType
|
||||
|
||||
@ -733,3 +740,14 @@ extension Poll.Kind {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension RepliedToEventDetails {
|
||||
var isThreaded: Bool {
|
||||
switch self {
|
||||
case .ready(let content, _, _):
|
||||
return content.asMessage()?.isThreaded() ?? false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
import MatrixRustSDK
|
||||
|
||||
@MainActor
|
||||
protocol RoomTimelineItemFactoryProtocol {
|
||||
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
|
||||
|
||||
func sendAudio(url: URL,
|
||||
|
@ -107,6 +107,8 @@ protocol TimelineProxyProtocol {
|
||||
func endPoll(pollStartID: String, text: 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 {
|
||||
|
@ -256,7 +256,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@ -272,7 +273,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@ -288,7 +290,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@ -304,7 +307,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@ -323,7 +327,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -342,7 +347,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -361,7 +367,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -381,7 +388,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -400,7 +408,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -418,7 +427,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
@ -450,7 +460,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -469,7 +480,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@ -488,7 +500,8 @@ class MockScreen: Identifiable {
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock())
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
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 viewModel: ComposerToolbarViewModel!
|
||||
private var completionSuggestionServiceMock: CompletionSuggestionServiceMock!
|
||||
private var draftServiceMock: ComposerDraftServiceMock!
|
||||
|
||||
override func setUp() {
|
||||
AppSettings.resetAllSettings()
|
||||
@ -33,11 +34,13 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
||||
ServiceLocator.shared.register(appSettings: appSettings)
|
||||
wysiwygViewModel = WysiwygComposerViewModel()
|
||||
completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init())
|
||||
draftServiceMock = ComposerDraftServiceMock()
|
||||
viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: completionSuggestionServiceMock,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: draftServiceMock)
|
||||
|
||||
viewModel.context.composerFormattingEnabled = true
|
||||
}
|
||||
@ -113,7 +116,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
||||
completionSuggestionService: mockCompletionSuggestionService,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: draftServiceMock)
|
||||
|
||||
XCTAssertEqual(viewModel.state.suggestions, suggestions)
|
||||
}
|
||||
@ -186,6 +190,304 @@ class ComposerToolbarViewModelTests: XCTestCase {
|
||||
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
// MARK: - Draft
|
||||
|
||||
func testSaveDraftPlainText() async {
|
||||
let expectation = expectation(description: "Wait for draft to be saved")
|
||||
draftServiceMock.saveDraftClosure = { draft in
|
||||
XCTAssertEqual(draft.plainText, "Hello world!")
|
||||
XCTAssertNil(draft.htmlText)
|
||||
XCTAssertEqual(draft.draftType, .newMessage)
|
||||
defer { expectation.fulfill() }
|
||||
return .success(())
|
||||
}
|
||||
|
||||
viewModel.context.composerFormattingEnabled = false
|
||||
viewModel.context.plainComposerText = .init(string: "Hello world!")
|
||||
viewModel.process(roomAction: .saveDraft)
|
||||
|
||||
await fulfillment(of: [expectation], timeout: 10)
|
||||
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
|
||||
XCTAssertFalse(draftServiceMock.clearDraftCalled)
|
||||
XCTAssertFalse(draftServiceMock.loadDraftCalled)
|
||||
}
|
||||
|
||||
func testSaveDraftFormattedText() async {
|
||||
let expectation = expectation(description: "Wait for draft to be saved")
|
||||
draftServiceMock.saveDraftClosure = { draft in
|
||||
XCTAssertEqual(draft.plainText, "__Hello__ world!")
|
||||
XCTAssertEqual(draft.htmlText, "<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 {
|
||||
|
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