mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Store and restore drafts (#2898)
This commit is contained in:
+118
-4
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user