Store and restore drafts (#2898)

This commit is contained in:
Mauro
2024-06-13 14:19:38 +02:00
committed by GitHub
parent b8dea8ac4e
commit cefa38049f
31 changed files with 1384 additions and 63 deletions
@@ -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()
}