Volatile draft to restore the composer after an edit. (#2996)

This commit is contained in:
Mauro
2024-07-02 16:48:50 +02:00
committed by GitHub
parent 457bd3160a
commit 94f98345b0
7 changed files with 280 additions and 23 deletions
@@ -4480,6 +4480,47 @@ class ComposerDraftServiceMock: ComposerDraftServiceProtocol {
return saveDraftReturnValue return saveDraftReturnValue
} }
} }
//MARK: - saveVolatileDraft
var saveVolatileDraftUnderlyingCallsCount = 0
var saveVolatileDraftCallsCount: Int {
get {
if Thread.isMainThread {
return saveVolatileDraftUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = saveVolatileDraftUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
saveVolatileDraftUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
saveVolatileDraftUnderlyingCallsCount = newValue
}
}
}
}
var saveVolatileDraftCalled: Bool {
return saveVolatileDraftCallsCount > 0
}
var saveVolatileDraftReceivedDraft: ComposerDraftProxy?
var saveVolatileDraftReceivedInvocations: [ComposerDraftProxy] = []
var saveVolatileDraftClosure: ((ComposerDraftProxy) -> Void)?
func saveVolatileDraft(_ draft: ComposerDraftProxy) {
saveVolatileDraftCallsCount += 1
saveVolatileDraftReceivedDraft = draft
DispatchQueue.main.async {
self.saveVolatileDraftReceivedInvocations.append(draft)
}
saveVolatileDraftClosure?(draft)
}
//MARK: - loadDraft //MARK: - loadDraft
var loadDraftUnderlyingCallsCount = 0 var loadDraftUnderlyingCallsCount = 0
@@ -4544,6 +4585,70 @@ class ComposerDraftServiceMock: ComposerDraftServiceProtocol {
return loadDraftReturnValue return loadDraftReturnValue
} }
} }
//MARK: - loadVolatileDraft
var loadVolatileDraftUnderlyingCallsCount = 0
var loadVolatileDraftCallsCount: Int {
get {
if Thread.isMainThread {
return loadVolatileDraftUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = loadVolatileDraftUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
loadVolatileDraftUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
loadVolatileDraftUnderlyingCallsCount = newValue
}
}
}
}
var loadVolatileDraftCalled: Bool {
return loadVolatileDraftCallsCount > 0
}
var loadVolatileDraftUnderlyingReturnValue: ComposerDraftProxy?
var loadVolatileDraftReturnValue: ComposerDraftProxy? {
get {
if Thread.isMainThread {
return loadVolatileDraftUnderlyingReturnValue
} else {
var returnValue: ComposerDraftProxy?? = nil
DispatchQueue.main.sync {
returnValue = loadVolatileDraftUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
loadVolatileDraftUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
loadVolatileDraftUnderlyingReturnValue = newValue
}
}
}
}
var loadVolatileDraftClosure: (() -> ComposerDraftProxy?)?
func loadVolatileDraft() -> ComposerDraftProxy? {
loadVolatileDraftCallsCount += 1
if let loadVolatileDraftClosure = loadVolatileDraftClosure {
return loadVolatileDraftClosure()
} else {
return loadVolatileDraftReturnValue
}
}
//MARK: - clearDraft //MARK: - clearDraft
var clearDraftUnderlyingCallsCount = 0 var clearDraftUnderlyingCallsCount = 0
@@ -4608,6 +4713,41 @@ class ComposerDraftServiceMock: ComposerDraftServiceProtocol {
return clearDraftReturnValue return clearDraftReturnValue
} }
} }
//MARK: - clearVolatileDraft
var clearVolatileDraftUnderlyingCallsCount = 0
var clearVolatileDraftCallsCount: Int {
get {
if Thread.isMainThread {
return clearVolatileDraftUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = clearVolatileDraftUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
clearVolatileDraftUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
clearVolatileDraftUnderlyingCallsCount = newValue
}
}
}
}
var clearVolatileDraftCalled: Bool {
return clearVolatileDraftCallsCount > 0
}
var clearVolatileDraftClosure: (() -> Void)?
func clearVolatileDraft() {
clearVolatileDraftCallsCount += 1
clearVolatileDraftClosure?()
}
//MARK: - getReply //MARK: - getReply
var getReplyEventIDUnderlyingCallsCount = 0 var getReplyEventIDUnderlyingCallsCount = 0
@@ -156,8 +156,13 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .cancelReply: case .cancelReply:
set(mode: .default) set(mode: .default)
case .cancelEdit: case .cancelEdit:
set(mode: .default) if let draft = draftService.loadVolatileDraft() {
set(text: "") handleLoadDraft(draft)
draftService.clearVolatileDraft()
} else {
set(text: "")
set(mode: .default)
}
case .attach(let attachment): case .attach(let attachment):
state.bindings.composerFocused = false state.bindings.composerFocused = false
actionsSubject.send(.attach(attachment)) actionsSubject.send(.attach(attachment))
@@ -198,6 +203,9 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
func process(roomAction: RoomScreenComposerAction) { func process(roomAction: RoomScreenComposerAction) {
switch roomAction { switch roomAction {
case .setMode(mode: let mode): case .setMode(mode: let mode):
if state.composerMode.isComposingNewMessage, mode.isEdit {
handleSaveDraft(isVolatile: true)
}
set(mode: mode) set(mode: mode)
case .setText(let plainText, let htmlText): case .setText(let plainText, let htmlText):
if let htmlText, context.composerFormattingEnabled { if let htmlText, context.composerFormattingEnabled {
@@ -208,13 +216,22 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .removeFocus: case .removeFocus:
state.bindings.composerFocused = false state.bindings.composerFocused = false
case .clear: case .clear:
set(mode: .default) if let draft = draftService.loadVolatileDraft() {
set(text: "") handleLoadDraft(draft)
draftService.clearVolatileDraft()
} else {
set(mode: .default)
set(text: "")
}
case .saveDraft: case .saveDraft:
handleSaveDraft() handleSaveDraft(isVolatile: false)
case .loadDraft: case .loadDraft:
Task { Task {
await handleLoadDraft() guard case let .success(draft) = await draftService.loadDraft(),
let draft else {
return
}
handleLoadDraft(draft)
} }
} }
} }
@@ -229,12 +246,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
// MARK: - Private // MARK: - Private
private func handleLoadDraft() async { private func handleLoadDraft(_ draft: ComposerDraftProxy) {
guard case let .success(draft) = await draftService.loadDraft(),
let draft else {
return
}
if let html = draft.htmlText { if let html = draft.htmlText {
context.composerFormattingEnabled = true context.composerFormattingEnabled = true
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -269,15 +281,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
} }
private func handleSaveDraft() { private func handleSaveDraft(isVolatile: Bool) {
let plainText: String let plainText: String
let htmlText: String? let htmlText: String?
let type: ComposerDraftProxy.ComposerDraftType let type: ComposerDraftProxy.ComposerDraftType
if context.composerFormattingEnabled { if context.composerFormattingEnabled {
if wysiwygViewModel.isContentEmpty, state.composerMode == .default { if wysiwygViewModel.isContentEmpty, state.composerMode == .default {
Task { if isVolatile {
await draftService.clearDraft() draftService.clearVolatileDraft()
} else {
Task {
await draftService.clearDraft()
}
} }
return return
} }
@@ -285,8 +301,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
htmlText = wysiwygViewModel.content.html htmlText = wysiwygViewModel.content.html
} else { } else {
if context.plainComposerText.string.isEmpty, state.composerMode == .default { if context.plainComposerText.string.isEmpty, state.composerMode == .default {
Task { if isVolatile {
await draftService.clearDraft() draftService.clearVolatileDraft()
} else {
Task {
await draftService.clearDraft()
}
} }
return return
} }
@@ -310,15 +330,22 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
type = .reply(eventID: eventID) type = .reply(eventID: eventID)
default: default:
// Do not save a draft for the other cases if isVolatile {
Task { draftService.clearVolatileDraft()
await draftService.clearDraft() } else {
Task {
await draftService.clearDraft()
}
} }
return return
} }
Task { if isVolatile {
await draftService.saveDraft(.init(plainText: plainText, htmlText: htmlText, draftType: type)) draftService.saveVolatileDraft(.init(plainText: plainText, htmlText: htmlText, draftType: type))
} else {
Task {
await draftService.saveDraft(.init(plainText: plainText, htmlText: htmlText, draftType: type))
}
} }
} }
@@ -276,8 +276,9 @@ class RoomScreenInteractionHandler {
text = messageTimelineItem.body text = messageTimelineItem.body
} }
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText))) // Always update the mode first and then the text so that the composer has time to save the text draft
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id)))) actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText)))
} }
// MARK: Polls // MARK: Polls
@@ -83,6 +83,15 @@ enum RoomScreenComposerMode: Equatable {
return nil return nil
} }
} }
var isComposingNewMessage: Bool {
switch self {
case .default, .reply:
return true
default:
return false
}
}
} }
enum RoomScreenViewPollAction { enum RoomScreenViewPollAction {
@@ -21,6 +21,7 @@ import MatrixRustSDK
final class ComposerDraftService: ComposerDraftServiceProtocol { final class ComposerDraftService: ComposerDraftServiceProtocol {
private let roomProxy: RoomProxyProtocol private let roomProxy: RoomProxyProtocol
private let timelineItemfactory: RoomTimelineItemFactoryProtocol private let timelineItemfactory: RoomTimelineItemFactoryProtocol
private var volatileDraft: ComposerDraftProxy?
init(roomProxy: RoomProxyProtocol, timelineItemfactory: RoomTimelineItemFactoryProtocol) { init(roomProxy: RoomProxyProtocol, timelineItemfactory: RoomTimelineItemFactoryProtocol) {
self.roomProxy = roomProxy self.roomProxy = roomProxy
@@ -71,4 +72,16 @@ final class ComposerDraftService: ComposerDraftServiceProtocol {
return .failure(.failedToClearDraft) return .failure(.failedToClearDraft)
} }
} }
func saveVolatileDraft(_ draft: ComposerDraftProxy) {
volatileDraft = draft
}
func loadVolatileDraft() -> ComposerDraftProxy? {
volatileDraft
}
func clearVolatileDraft() {
volatileDraft = nil
}
} }
@@ -74,7 +74,10 @@ enum ComposerDraftServiceError: Error {
// sourcery: AutoMockable // sourcery: AutoMockable
protocol ComposerDraftServiceProtocol { protocol ComposerDraftServiceProtocol {
func saveDraft(_ draft: ComposerDraftProxy) async -> Result<Void, ComposerDraftServiceError> func saveDraft(_ draft: ComposerDraftProxy) async -> Result<Void, ComposerDraftServiceError>
func saveVolatileDraft(_ draft: ComposerDraftProxy)
func loadDraft() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError> func loadDraft() async -> Result<ComposerDraftProxy?, ComposerDraftServiceError>
func loadVolatileDraft() -> ComposerDraftProxy?
func clearDraft() async -> Result<Void, ComposerDraftServiceError> func clearDraft() async -> Result<Void, ComposerDraftServiceError>
func clearVolatileDraft()
func getReply(eventID: String) async -> Result<TimelineItemReply, ComposerDraftServiceError> func getReply(eventID: String) async -> Result<TimelineItemReply, ComposerDraftServiceError>
} }
@@ -489,6 +489,70 @@ class ComposerToolbarViewModelTests: XCTestCase {
await fulfillment(of: [loadReplyExpectation], timeout: 10) await fulfillment(of: [loadReplyExpectation], timeout: 10)
XCTAssertEqual(viewModel.state.composerMode, .default) XCTAssertEqual(viewModel.state.composerMode, .default)
} }
func testSaveVolatileDraftWhenEditing() {
viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: .random)))
let draft = draftServiceMock.saveVolatileDraftReceivedDraft
XCTAssertNotNil(draft)
XCTAssertEqual(draft?.plainText, "Hello world!")
XCTAssertNil(draft?.htmlText)
XCTAssertEqual(draft?.draftType, .newMessage)
}
func testRestoreVolatileDraftWhenCancellingEdit() async {
let expectation = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
viewModel.process(viewAction: .cancelEdit)
await fulfillment(of: [expectation])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
}
func testRestoreVolatileDraftWhenClearing() async {
let expectation1 = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation1.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
let expectation2 = expectation(description: "The draft should also be cleared after being loaded")
draftServiceMock.clearVolatileDraftClosure = {
expectation2.fulfill()
}
viewModel.process(roomAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
}
func testRestoreVolatileDraftDoubleClear() async {
let expectation1 = expectation(description: "Wait for draft to be restored")
draftServiceMock.loadVolatileDraftClosure = {
defer { expectation1.fulfill() }
return .init(plainText: "Hello world",
htmlText: nil,
draftType: .newMessage)
}
let expectation2 = expectation(description: "The draft should also be cleared after being loaded")
draftServiceMock.clearVolatileDraftClosure = {
expectation2.fulfill()
}
viewModel.process(roomAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
}
} }
private extension MentionSuggestionItem { private extension MentionSuggestionItem {