diff --git a/DocmostlyMacTests/EngagementParityTests.swift b/DocmostlyMacTests/EngagementParityTests.swift new file mode 100644 index 0000000..4cb6f08 --- /dev/null +++ b/DocmostlyMacTests/EngagementParityTests.swift @@ -0,0 +1,248 @@ +import Foundation +import Testing +@testable import DocmostlyMac + +@MainActor +struct EngagementParityTests { + @Test func paginationDeduplicatesStableIDsAndStopsRepeatedCursors() { + var accumulator = CursorPageAccumulator() + accumulator.replace(with: PaginatedResponse( + items: [TestItem(id: "1"), TestItem(id: "2")], + meta: paginationMeta(nextCursor: "cursor-2") + )) + + accumulator.append( + PaginatedResponse( + items: [TestItem(id: "2"), TestItem(id: "3")], + meta: paginationMeta(nextCursor: "cursor-2") + ), + requestedCursor: "cursor-2" + ) + + #expect(accumulator.items.map(\.id) == ["1", "2", "3"]) + #expect(accumulator.hasNextPage == false) + } + + @Test func unreadCountReconcilesAndClampsServerValues() { + let store = NotificationStore() + + store.reconcile(unreadCount: 7) + #expect(store.unreadCount == 7) + + store.reconcile(unreadCount: -1) + #expect(store.unreadCount == 0) + } + + @Test func optimisticReadUpdatesCountAndRollsBackAfterFailure() async { + let notification = makeNotification(id: "notification-1") + let viewModel = NotificationListViewModel() + let store = NotificationStore() + store.reconcile(unreadCount: 2) + viewModel.applyInitialPage(notificationResponse(items: [notification])) + + await viewModel.markRead(notification, store: store) { } + + #expect(viewModel.isUnread(notification) == false) + #expect(store.unreadCount == 1) + + let failingNotification = makeNotification(id: "notification-2") + viewModel.applyInitialPage(notificationResponse(items: [failingNotification])) + await viewModel.markRead(failingNotification, store: store) { + throw TestFailure.failed + } + + #expect(viewModel.isUnread(failingNotification)) + #expect(store.unreadCount == 1) + #expect(viewModel.errorMessage != nil) + } + + @Test func markAllReadRollsBackLoadedRowsAndCount() async { + let first = makeNotification(id: "notification-1") + let second = makeNotification(id: "notification-2") + let viewModel = NotificationListViewModel() + let store = NotificationStore() + store.reconcile(unreadCount: 4) + viewModel.applyInitialPage(notificationResponse(items: [first, second])) + + await viewModel.markAllRead(store: store) { + throw TestFailure.failed + } + + #expect(viewModel.isUnread(first)) + #expect(viewModel.isUnread(second)) + #expect(store.unreadCount == 4) + } + + @Test func commentNotificationCarriesCommentThroughAppNavigation() throws { + let notification = makeNotification( + id: "notification-1", + type: .commentCreated, + commentID: "comment-1" + ) + let target = try #require(PageOpenTarget(notification: notification)) + let appState = makeAppState() + + appState.openPage(target) + + #expect(target.commentId == "comment-1") + #expect(appState.selectedPageID == "roadmap") + #expect(appState.selectedSpaceID == "space-1") + #expect(appState.selectedCommentID == "comment-1") + } + + @Test func favoriteRemovalStaysRemovedAfterSuccessAndRollsBackAfterFailure() async { + let first = pageFavorite(id: "favorite-page") + let second = spaceFavorite(id: "favorite-space", name: "Product") + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(favoriteResponse(items: [first, second])) + + await viewModel.remove(first) { } + #expect(viewModel.favorites.map(\.id) == [second.id]) + + viewModel.applyInitialPage(favoriteResponse(items: [first, second])) + await viewModel.remove(first) { + throw TestFailure.failed + } + + #expect(viewModel.favorites.map(\.id) == [first.id, second.id]) + #expect(viewModel.errorMessage != nil) + } + + @Test func favoriteSectionsRepresentSpacesPagesAndTemplates() { + let zulu = spaceFavorite(id: "favorite-zulu", name: "Zulu") + let alpha = spaceFavorite(id: "favorite-alpha", name: "Alpha") + let page = pageFavorite(id: "favorite-page") + let template = templateFavorite(id: "favorite-template") + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(favoriteResponse(items: [zulu, page, template, alpha])) + + #expect(viewModel.sections.map(\.type) == [.space, .page, .template]) + #expect(viewModel.sections.first?.favorites.map(\.title) == ["Alpha", "Zulu"]) + #expect(PageOpenTarget(favorite: page)?.slugId == "roadmap") + #expect(zulu.targetID == "space-1") + #expect(template.title == "Planning Template") + } + + private func notificationResponse( + items: [DocmostNotification] + ) -> PaginatedResponse { + PaginatedResponse(items: items, meta: paginationMeta()) + } + + private func favoriteResponse( + items: [DocmostFavorite] + ) -> PaginatedResponse { + PaginatedResponse(items: items, meta: paginationMeta()) + } + + private func paginationMeta(nextCursor: String? = nil) -> PaginationMeta { + PaginationMeta( + limit: 30, + hasNextPage: nextCursor != nil, + hasPrevPage: false, + nextCursor: nextCursor, + prevCursor: nil + ) + } + + private func makeNotification( + id: String, + type: DocmostNotificationType = .pageUpdated, + commentID: String? = nil + ) -> DocmostNotification { + DocmostNotification( + id: id, + userId: "user-1", + workspaceId: "workspace-1", + type: type, + actorId: nil, + pageId: "page-1", + spaceId: "space-1", + commentId: commentID, + data: nil, + readAt: nil, + emailedAt: nil, + archivedAt: nil, + createdAt: .now, + actor: nil, + page: DocmostNotificationPage(id: "page-1", title: "Roadmap", slugId: "roadmap", icon: nil), + space: DocmostNotificationSpace(id: "space-1", name: "Product", slug: "product"), + comment: nil + ) + } + + private func pageFavorite(id: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: "page-1", + spaceId: nil, + templateId: nil, + type: .page, + workspaceId: "workspace-1", + createdAt: .now, + page: DocmostFavoritePage( + id: "page-1", + slugId: "roadmap", + title: "Roadmap", + icon: nil, + spaceId: "space-1" + ), + space: DocmostFavoriteSpace(id: "space-1", name: "Product", slug: "product", logo: nil), + template: nil + ) + } + + private func spaceFavorite(id: String, name: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: nil, + spaceId: "space-1", + templateId: nil, + type: .space, + workspaceId: "workspace-1", + createdAt: .now, + page: nil, + space: DocmostFavoriteSpace(id: "space-1", name: name, slug: name.lowercased(), logo: nil), + template: nil + ) + } + + private func templateFavorite(id: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: nil, + spaceId: nil, + templateId: "template-1", + type: .template, + workspaceId: "workspace-1", + createdAt: .now, + page: nil, + space: nil, + template: DocmostFavoriteTemplate( + id: "template-1", + title: "Planning Template", + description: nil, + icon: nil, + spaceId: nil + ) + ) + } + + private func makeAppState() -> AppState { + let suiteName = "Docmostly.EngagementParityTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard + userDefaults.removePersistentDomain(forName: suiteName) + return AppState(settingsStore: LocalSettingsStore(userDefaults: userDefaults)) + } + + private struct TestItem: Codable, Identifiable, Sendable { + let id: String + } + + private enum TestFailure: Error { + case failed + } +} diff --git a/DocmostlyMacTests/NativeEditorCollaborationPresenceTests.swift b/DocmostlyMacTests/NativeEditorCollaborationPresenceTests.swift new file mode 100644 index 0000000..9d02e16 --- /dev/null +++ b/DocmostlyMacTests/NativeEditorCollaborationPresenceTests.swift @@ -0,0 +1,252 @@ +import Foundation +import Testing +@testable import DocmostlyMac + +@MainActor +@Suite(.serialized) +struct NativeEditorCollaborationPresenceTests { + @Test func awarenessStoreHonorsMonotonicClocksRefreshExpiryAndExplicitRemoval() throws { + var store = NativeEditorAwarenessStateStore() + let initialDate = Date(timeIntervalSince1970: 1_000) + let alice = awarenessState(clientID: 11, clock: 5, userID: "user-2", name: "Alice") + + #expect(store.apply([alice], receivedAt: initialDate) == [alice]) + #expect(store.apply([ + awarenessState(clientID: 11, clock: 4, userID: "user-2", name: "Outdated") + ], receivedAt: initialDate.addingTimeInterval(5)) == [alice]) + + #expect(store.apply([alice], receivedAt: initialDate.addingTimeInterval(20)) == [alice]) + #expect(store.pruneStaleStates(now: initialDate.addingTimeInterval(49)) == nil) + #expect(store.pruneStaleStates(now: initialDate.addingTimeInterval(50)) == []) + + let rejoined = awarenessState(clientID: 11, clock: 6, userID: "user-2", name: "Alice") + #expect(store.apply([rejoined], receivedAt: initialDate.addingTimeInterval(51)) == [rejoined]) + #expect(store.apply([ + NativeEditorAwarenessState(clientID: 11, clock: 6, payload: nil) + ], receivedAt: initialDate.addingTimeInterval(52)) == []) + } + + @Test func awarenessStoreResetAllowsLowerClocksAfterReconnect() { + var store = NativeEditorAwarenessStateStore() + let highClock = awarenessState(clientID: 11, clock: 100, userID: "user-2", name: "Alice") + let reconnected = awarenessState(clientID: 11, clock: 1, userID: "user-2", name: "Alice") + + #expect(store.apply([highClock]) == [highClock]) + store.reset() + #expect(store.apply([reconnected]) == [reconnected]) + } + + @Test func multipleSessionsKeepDistinctCursorIdentityAndSuppressLocalClient() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + let states = [ + awarenessState(clientID: 10, clock: 1, userID: "local", name: "Local", cursorClock: 1), + awarenessState(clientID: 11, clock: 1, userID: "user-2", name: "Alice", cursorClock: 2), + awarenessState(clientID: 12, clock: 1, userID: "user-2", name: "Alice", cursorClock: 3) + ] + + viewModel.applyAwarenessStates(states, localClientID: 10) + + #expect(viewModel.activeCollaborators.map(\.id) == ["user-2"]) + #expect(viewModel.remoteCursors.map(\.id) == ["client-11", "client-12"]) + #expect(viewModel.remoteCursors.map(\.collaboratorID) == ["user-2", "user-2"]) + #expect(viewModel.remoteCursors.allSatisfy { $0.colorName == "#958DF1" }) + } + + @Test func projectionRoutesRootNestedAndMultiBlockSelectionsWithoutMergingSessions() throws { + let document = nestedDocument() + let projection = NativeEditorRemotePresenceProjection(document: document, cursors: [ + resolvedCursor( + id: "client-11", + collaboratorID: "user-2", + name: "Alice", + anchor: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 2), + head: NativeEditorRemoteTextPosition(blockIndex: 2, characterOffset: 3) + ), + resolvedCursor( + id: "client-12", + collaboratorID: "user-2", + name: "Alice", + anchor: NativeEditorRemoteTextPosition(blockIndex: 3, characterOffset: 2), + head: NativeEditorRemoteTextPosition(blockIndex: 3, characterOffset: 2) + ), + resolvedCursor( + id: "client-20", + collaboratorID: "user-3", + name: "Bob", + anchor: NativeEditorRemoteTextPosition(blockIndex: 3, characterOffset: 4), + head: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 1) + ) + ]) + let calloutScope = [NativeEditorRemotePresenceScope(containerBlockIndex: 1, target: .callout)] + + let rootStart = projection.segments(scope: [], blockIndex: 0) + let aliceRootStart = try #require(rootStart.first { $0.cursorID == "client-11" }) + let bobRootStart = try #require(rootStart.first { $0.cursorID == "client-20" }) + #expect(aliceRootStart.characterRange == 2..<4) + #expect(aliceRootStart.caretOffset == nil) + #expect(bobRootStart.characterRange == 1..<4) + #expect(bobRootStart.caretOffset == 1) + + let firstNested = projection.segments(scope: calloutScope, blockIndex: 0) + let secondNested = projection.segments(scope: calloutScope, blockIndex: 1) + #expect(firstNested.first { $0.cursorID == "client-11" }?.characterRange == 0..<6) + #expect(secondNested.first { $0.cursorID == "client-11" }?.characterRange == 0..<3) + #expect(secondNested.first { $0.cursorID == "client-11" }?.caretOffset == 3) + + let rootTail = projection.segments(scope: [], blockIndex: 2) + #expect(rootTail.first { $0.cursorID == "client-12" }?.caretOffset == 2) + #expect(rootTail.first { $0.cursorID == "client-20" }?.characterRange == 0..<4) + #expect(projection.rootBlockIndex(for: "user-2") == 2) + #expect(projection.rootBlockIndex(for: "user-3") == 0) + } + + @Test func collaboratorJumpTargetsNestedCaretContainerWithoutChangingFocus() throws { + let document = nestedDocument() + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = document + let originalActiveBlockID = viewModel.activeBlockID + viewModel.resolvedRemoteCursors = [resolvedCursor( + id: "client-11", + collaboratorID: "user-2", + name: "Alice", + anchor: NativeEditorRemoteTextPosition(blockIndex: 2, characterOffset: 3), + head: NativeEditorRemoteTextPosition(blockIndex: 2, characterOffset: 3) + )] + + #expect(viewModel.rootBlockID(forCollaboratorID: "user-2") == document.blocks[1].id) + #expect(viewModel.activeBlockID == originalActiveBlockID) + } + + @Test func relativeSelectionSurvivesRemoteSnapshotAfterInsertionBeforeCaret() async throws { + let source = try NativeEditorJSCRDTRuntimeSource.bundled(in: .main) + let sourceEngine = try NativeEditorJSCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: textDocument("Seed"), + runtimeSource: source + ) + let receivingEngine = try NativeEditorJSCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: textDocument("Seed"), + runtimeSource: source + ) + let updates = await sourceEngine.localUpdates() + var iterator = updates.makeAsyncIterator() + + try await sourceEngine.integrateLocalChange(change(before: "Seed", after: "Hello world")) + _ = try await receivingEngine.applyRemoteUpdateCapturingSnapshot(try #require(await iterator.next())) + let relativeCursor = try #require(try await receivingEngine.encodeLocalAwarenessCursor( + for: NativeEditorLocalTextSelection( + anchor: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 6), + head: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 11) + ) + )) + #expect(relativeCursor.targetsDocmostDefaultFragment) + + try await sourceEngine.integrateLocalChange(change(before: "Hello world", after: "Hello brave world")) + let secondUpdate = try #require(await iterator.next()) + let snapshot = try #require( + try await receivingEngine.applyRemoteUpdateCapturingSnapshot(secondUpdate) + ) + let resolved = try #require(try await receivingEngine.resolveRemoteCursor(NativeEditorRemoteCursor( + id: "client-11", + collaboratorID: "user-2", + name: "Alice", + colorName: "#958DF1", + cursor: relativeCursor + ))) + + #expect(snapshot.document.blocks.map { String($0.text.characters) } == ["Hello brave world"]) + #expect(resolved.anchor == NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 12)) + #expect(resolved.head == NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 17)) + } + + private func awarenessState( + clientID: Int, + clock: Int, + userID: String, + name: String, + cursorClock: Int? = nil + ) -> NativeEditorAwarenessState { + NativeEditorAwarenessState( + clientID: clientID, + clock: clock, + payload: NativeEditorAwarenessPayload( + user: NativeEditorAwarenessUser(id: userID, name: name, color: "#958DF1"), + cursor: cursorClock.map { clock in + NativeEditorAwarenessCursor( + anchor: relativePosition(client: clientID, clock: clock), + head: relativePosition(client: clientID, clock: clock) + ) + } + ) + ) + } + + private func relativePosition(client: Int, clock: Int) -> NativeEditorYjsRelativePosition { + NativeEditorYjsRelativePosition( + type: .name("text"), + targetName: NativeEditorCollaborationDocument.yjsFragmentName, + item: NativeEditorYjsID(client: client, clock: clock), + assoc: 0 + ) + } + + private func resolvedCursor( + id: String, + collaboratorID: String, + name: String, + anchor: NativeEditorRemoteTextPosition, + head: NativeEditorRemoteTextPosition + ) -> NativeEditorResolvedRemoteCursor { + NativeEditorResolvedRemoteCursor( + id: id, + collaboratorID: collaboratorID, + name: name, + colorName: "#958DF1", + anchor: anchor, + head: head + ) + } + + private func nestedDocument() -> NativeEditorDocument { + NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + paragraph("Root"), + ProseMirrorNode( + type: "callout", + attrs: ["type": .string("info")], + content: [paragraph("Inside"), paragraph("Again")] + ), + paragraph("Tail") + ])) + } + + private func paragraph(_ text: String) -> ProseMirrorNode { + ProseMirrorNode(type: "paragraph", content: [ProseMirrorNode(type: "text", text: text)]) + } + + private func textDocument(_ text: String) -> NativeEditorDocument { + NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .paragraph, text: AttributedString(text), alignment: .left) + ]) + } + + private func change(before: String, after: String) -> NativeEditorCRDTLocalChange { + NativeEditorCRDTLocalChange( + before: historySnapshot(text: before), + after: historySnapshot(text: after) + ) + } + + private func historySnapshot(text: String) -> NativeEditorHistorySnapshot { + NativeEditorHistorySnapshot( + title: "Page", + document: textDocument(text), + activeBlockID: nil, + selectedBlockID: nil, + visibleBlockControlsID: nil, + isTitleFocused: false + ) + } +} diff --git a/docmostly/App/AppState+CollaborativeDraftResolution.swift b/docmostly/App/AppState+CollaborativeDraftResolution.swift new file mode 100644 index 0000000..5dd0eea --- /dev/null +++ b/docmostly/App/AppState+CollaborativeDraftResolution.swift @@ -0,0 +1,105 @@ +import Foundation + +extension AppState { + func pauseOfflineReplayForCollaborativeResolution() async { + let replayTask = offlineReplayTask + replayTask?.cancel() + await replayTask?.value + offlineReplayTask = nil + } + + func discardPendingCollaborativeDraft( + pageId: String, + through cutoff: Date + ) async throws -> OfflinePageUpdateAcknowledgementResult { + await pauseOfflineReplayForCollaborativeResolution() + + do { + let result = try await acknowledgeCollaborativeDraft( + pageId: pageId, + through: cutoff + ) + await refreshOfflineMutationCount() + scheduleOfflineQueueReconciliation() + return result + } catch { + await refreshOfflineMutationCount() + scheduleOfflineQueueReconciliation() + throw error + } + } + + // swiftlint:disable:next function_parameter_count + func keepPendingCollaborativeDraft( + pageId: String, + title: String, + document: ProseMirrorDocument, + remoteBaseTitle: String, + remoteBaseDocument: ProseMirrorDocument, + replacingThrough cutoff: Date + ) async throws -> OfflinePageUpdateSupersessionResult { + await pauseOfflineReplayForCollaborativeResolution() + let scope = try requireCacheScope( + message: "The local draft cannot be secured until you sign in again." + ) + let resolvedAt = Date.now + let result: OfflinePageUpdateSupersessionResult + + if let offlineQueueRepository { + result = try await offlineQueueRepository.resolvePendingPageUpdateKeepingLocal( + pageId: pageId, + title: title, + document: document, + remoteBaseTitle: remoteBaseTitle, + remoteBaseDocument: remoteBaseDocument, + replacingThrough: cutoff, + resolvedAt: resolvedAt, + scope: scope + ) + } else if let offlineQueue { + result = try offlineQueue.resolvePendingPageUpdateKeepingLocal( + pageId: pageId, + title: title, + document: document, + remoteBaseTitle: remoteBaseTitle, + remoteBaseDocument: remoteBaseDocument, + replacingThrough: cutoff, + resolvedAt: resolvedAt, + scope: scope + ) + } else { + throw APIError.connectionFailed("Local draft storage is unavailable on this device.") + } + + guard result != .newerPendingUpdatePreserved else { return result } + _ = try await saveLocalEditableDraft( + pageId: pageId, + title: title, + document: document + ) + await refreshOfflineMutationCount() + return result + } + + private func acknowledgeCollaborativeDraft( + pageId: String, + through cutoff: Date + ) async throws -> OfflinePageUpdateAcknowledgementResult { + guard let cacheScope else { return .noPendingUpdate } + + if let offlineQueueRepository { + return try await offlineQueueRepository.acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: cutoff, + scope: cacheScope + ) + } + + guard let offlineQueue else { return .noPendingUpdate } + return try offlineQueue.acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: cutoff, + scope: cacheScope + ) + } +} diff --git a/docmostly/App/AppState+Comments.swift b/docmostly/App/AppState+Comments.swift index 43fe8b6..41fba09 100644 --- a/docmostly/App/AppState+Comments.swift +++ b/docmostly/App/AppState+Comments.swift @@ -21,9 +21,13 @@ extension AppState { } func addPageComment(pageId: String, text: String) async throws -> DocmostComment { - let content = CommentPayload.plainText(text).jsonString + try await addPageComment(pageId: pageId, body: CommentBody(plainText: text)) + } + + func addPageComment(pageId: String, body: CommentBody) async throws -> DocmostComment { + let content = body.jsonString guard let apiClient else { - return try await queueComment(pageId: pageId, text: text, content: content, type: .page) + return try await queueComment(pageId: pageId, body: body, content: content, type: .page) } do { @@ -40,12 +44,24 @@ extension AppState { guard canQueueOfflineMutation(after: error) else { throw error } isOffline = true statusMessage = error.localizedDescription - return try await queueComment(pageId: pageId, text: text, content: content, type: .page) + return try await queueComment(pageId: pageId, body: body, content: content, type: .page) } } func addCommentReply(pageId: String, parentCommentId: String, text: String) async throws -> DocmostComment { - let content = CommentPayload.plainText(text).jsonString + try await addCommentReply( + pageId: pageId, + parentCommentId: parentCommentId, + body: CommentBody(plainText: text) + ) + } + + func addCommentReply( + pageId: String, + parentCommentId: String, + body: CommentBody + ) async throws -> DocmostComment { + let content = body.jsonString guard let apiClient else { throw CommentMutationAvailabilityError.onlineRequired } @@ -75,11 +91,25 @@ extension AppState { selectedText: String, yjsSelection: NativeEditorYjsSelection? = nil ) async throws -> DocmostComment { - let content = CommentPayload.plainText(text).jsonString + try await addInlineComment( + pageId: pageId, + body: CommentBody(plainText: text), + selectedText: selectedText, + yjsSelection: yjsSelection + ) + } + + func addInlineComment( + pageId: String, + body: CommentBody, + selectedText: String, + yjsSelection: NativeEditorYjsSelection? = nil + ) async throws -> DocmostComment { + let content = body.jsonString guard let apiClient else { return try await queueComment( pageId: pageId, - text: text, + body: body, content: content, type: .inline, selection: selectedText, @@ -105,7 +135,7 @@ extension AppState { statusMessage = error.localizedDescription return try await queueComment( pageId: pageId, - text: text, + body: body, content: content, type: .inline, selection: selectedText, @@ -138,11 +168,15 @@ extension AppState { } func updateComment(_ existingComment: DocmostComment, text: String) async throws -> DocmostComment { + try await updateComment(existingComment, body: CommentBody(plainText: text)) + } + + func updateComment(_ existingComment: DocmostComment, body: CommentBody) async throws -> DocmostComment { guard existingComment.isNativelyEditable else { throw CommentMutationAvailabilityError.unsupportedRichContentEdit } - let content = CommentPayload.plainText(text).jsonString + let content = body.jsonString guard let apiClient else { throw CommentMutationAvailabilityError.onlineRequired } @@ -196,7 +230,7 @@ extension AppState { private func queueComment( pageId: String, - text: String, + body: CommentBody, content: String, type: DocmostCommentType, selection: String? = nil, @@ -207,7 +241,7 @@ extension AppState { localId: localId, pageId: pageId, content: content, - plainText: text, + plainText: body.plainText, type: type, selection: selection, yjsSelection: yjsSelection @@ -215,14 +249,15 @@ extension AppState { let comment = DocmostComment( id: localId, - content: text, + content: body.plainText, selection: selection, type: type.rawValue, creatorId: currentUser?.user.id ?? "offline", pageId: pageId, workspaceId: currentUser?.workspace.id, createdAt: Date.now, - creator: currentUser?.user + creator: currentUser?.user, + body: body ) applyLocalComment(comment) return comment @@ -293,7 +328,9 @@ extension AppState { editedAt: existing.editedAt, deletedAt: existing.deletedAt, creator: existing.creator, - resolvedBy: resolved ? currentUser?.user : nil + resolvedBy: resolved ? currentUser?.user : nil, + body: existing.body, + isNativelyEditable: existing.isNativelyEditable ) } diff --git a/docmostly/App/AppState+EditorPersistence.swift b/docmostly/App/AppState+EditorPersistence.swift new file mode 100644 index 0000000..f7e9a7b --- /dev/null +++ b/docmostly/App/AppState+EditorPersistence.swift @@ -0,0 +1,278 @@ +import Foundation + +nonisolated struct CollaborativePagePersistenceResult: Sendable { + let page: DocmostEditablePage? + let persistedTitle: String + let updatedAt: Date? +} + +extension AppState { + func updatePage( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String? = nil, + baseDocument: ProseMirrorDocument? = nil + ) async throws -> DocmostEditablePage { + guard let apiClient else { + return try await queuePageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument + ) + } + + do { + let page: DocmostEditablePage = try await apiClient.send(.updatePage( + pageId: pageId, + title: title, + content: document, + format: .json, + operation: .replace + )) + isOffline = false + if let cacheScope { + scheduleCacheWrite(.saveEditablePage(page, scope: cacheScope)) + } + do { + try await clearPendingPageUpdate(pageId: pageId, title: title, document: document) + scheduleOfflineQueueReconciliation() + } catch { + statusMessage = error.localizedDescription + } + return page + } catch { + guard canQueueOfflineMutation(after: error) else { throw error } + isOffline = true + statusMessage = error.localizedDescription + return try await queuePageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument + ) + } + } + + // swiftlint:disable:next function_parameter_count + func updateCollaborativePageTitle( + pageId: String, + title: String, + documentSnapshot: ProseMirrorDocument, + baseTitle: String, + baseDocument: ProseMirrorDocument, + snapshotCapturedAt: Date + ) async throws -> CollaborativePagePersistenceResult { + guard let apiClient else { + return try await persistCollaborativePageLocally( + pageId: pageId, + title: title, + document: documentSnapshot, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt + ) + } + + let page: DocmostEditablePage + do { + page = try await apiClient.send(.updatePage( + pageId: pageId, + title: title + )) + } catch { + guard canQueueOfflineMutation(after: error) else { throw error } + isOffline = true + statusMessage = error.localizedDescription + return try await persistCollaborativePageLocally( + pageId: pageId, + title: title, + document: documentSnapshot, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt + ) + } + + isOffline = false + cacheCollaborativeSnapshot(page: page, document: documentSnapshot) + + do { + let replayDecision = OfflinePageUpdateReplayDecision.resolve( + serverPage: page, + queuedTitle: title, + queuedDocument: documentSnapshot, + baseTitle: baseTitle, + baseDocument: baseDocument + ) + switch replayDecision { + case .alreadySynchronized, .updateTitleOnly: + _ = try await acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: snapshotCapturedAt + ) + await refreshOfflineMutationCount() + scheduleOfflineQueueReconciliation() + case .replaceDocument, .conflict: + _ = try await supersedePendingPageUpdate( + pageId: pageId, + title: title, + document: documentSnapshot, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt + ) + await refreshOfflineMutationCount() + statusMessage = replayDecision == .conflict ? + "Saved locally. A newer remote version must be resolved before this draft can sync." : + "Saved locally. Waiting for the collaborative document to sync." + } + } catch { + statusMessage = "Could not make the collaborative document durable: " + error.localizedDescription + throw error + } + return CollaborativePagePersistenceResult( + page: page, + persistedTitle: page.title, + updatedAt: page.updatedAt + ) + } + + // swiftlint:disable:next function_parameter_count + func persistDeferredCollaborativeDraft( + pageId: String, + title: String, + documentSnapshot: ProseMirrorDocument, + baseTitle: String, + baseDocument: ProseMirrorDocument, + snapshotCapturedAt: Date + ) async throws -> CollaborativePagePersistenceResult { + try await persistCollaborativePageLocally( + pageId: pageId, + title: title, + document: documentSnapshot, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt + ) + } +} + +private extension AppState { + func cacheCollaborativeSnapshot(page: DocmostEditablePage, document: ProseMirrorDocument) { + guard let cacheScope else { return } + let cachedPage = DocmostEditablePage( + id: page.id, + slugId: page.slugId, + title: page.title, + content: document, + icon: page.icon, + spaceId: page.spaceId, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + permissions: page.permissions, + creator: page.creator, + lastUpdatedBy: page.lastUpdatedBy + ) + scheduleCacheWrite(.saveEditablePage(cachedPage, scope: cacheScope)) + } + + // swiftlint:disable:next function_parameter_count + func supersedePendingPageUpdate( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String, + baseDocument: ProseMirrorDocument, + snapshotCapturedAt: Date + ) async throws -> OfflinePageUpdateSupersessionResult { + guard let cacheScope else { + throw APIError.connectionFailed("Offline document durability is unavailable until you sign in.") + } + if let offlineQueueRepository { + return try await offlineQueueRepository.supersedePendingPageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt, + scope: cacheScope + ) + } + guard let offlineQueue else { + throw APIError.connectionFailed("Offline document durability is unavailable on this device.") + } + return try offlineQueue.supersedePendingPageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt, + scope: cacheScope + ) + } + + func acknowledgePendingPageUpdate( + pageId: String, + snapshotCapturedAt: Date + ) async throws -> OfflinePageUpdateAcknowledgementResult { + guard let cacheScope else { return .noPendingUpdate } + if let offlineQueueRepository { + return try await offlineQueueRepository.acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: snapshotCapturedAt, + scope: cacheScope + ) + } + guard let offlineQueue else { return .noPendingUpdate } + return try offlineQueue.acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: snapshotCapturedAt, + scope: cacheScope + ) + } + + // swiftlint:disable:next function_parameter_count + func persistCollaborativePageLocally( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String, + baseDocument: ProseMirrorDocument, + snapshotCapturedAt: Date + ) async throws -> CollaborativePagePersistenceResult { + let supersessionResult = try await supersedePendingPageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt + ) + await refreshOfflineMutationCount() + + guard supersessionResult != .newerPendingUpdatePreserved else { + return CollaborativePagePersistenceResult( + page: nil, + persistedTitle: title, + updatedAt: nil + ) + } + + let page = try await saveLocalEditableDraft( + pageId: pageId, + title: title, + document: document + ) + return CollaborativePagePersistenceResult( + page: page, + persistedTitle: page.title, + updatedAt: page.updatedAt + ) + } +} diff --git a/docmostly/App/AppState+Engagement.swift b/docmostly/App/AppState+Engagement.swift index 6ef78d9..502aa28 100644 --- a/docmostly/App/AppState+Engagement.swift +++ b/docmostly/App/AppState+Engagement.swift @@ -162,6 +162,7 @@ extension AppState { spaceId: spaceId, templateId: templateId ) + favoriteRevision &+= 1 return } @@ -187,8 +188,8 @@ extension AppState { templateId: templateId ) } + favoriteRevision &+= 1 } - func removeFavorite( type: FavoriteType, pageId: String? = nil, @@ -203,6 +204,7 @@ extension AppState { spaceId: spaceId, templateId: templateId ) + favoriteRevision &+= 1 return } @@ -234,6 +236,7 @@ extension AppState { templateId: templateId ) } + favoriteRevision &+= 1 } func loadNotifications( diff --git a/docmostly/App/AppState+Management.swift b/docmostly/App/AppState+Management.swift index 8990ccc..897a10d 100644 --- a/docmostly/App/AppState+Management.swift +++ b/docmostly/App/AppState+Management.swift @@ -1,6 +1,10 @@ import Foundation extension AppState { + var hasPageUpdatePersistence: Bool { + apiClient != nil || offlineQueue != nil + } + func createPage(spaceId: String, parentPageId: String? = nil, title: String? = nil) async throws -> DocmostPage { guard let apiClient else { throw APIError.connectionFailed("Creating pages requires a network connection.") diff --git a/docmostly/App/AppState+Navigation.swift b/docmostly/App/AppState+Navigation.swift index d57b755..4feffbc 100644 --- a/docmostly/App/AppState+Navigation.swift +++ b/docmostly/App/AppState+Navigation.swift @@ -17,6 +17,7 @@ extension AppState { selectedSidebarDestination = destination selectedPageID = nil + selectedCommentID = nil } func selectSpace(id spaceID: String, clearsPage: Bool = true) { @@ -25,10 +26,16 @@ extension AppState { if clearsPage { selectedPageID = nil + selectedCommentID = nil } } - func selectPage(id pageID: String, spaceID: String? = nil, revealSpaceInSidebar: Bool = false) { + func selectPage( + id pageID: String, + spaceID: String? = nil, + revealSpaceInSidebar: Bool = false, + commentID: String? = nil + ) { if let spaceID { selectedSpaceID = spaceID @@ -38,24 +45,33 @@ extension AppState { } selectedPageID = pageID + selectedCommentID = commentID } func openPage(_ target: PageOpenTarget) { selectPage( id: target.slugId, spaceID: target.spaceId, - revealSpaceInSidebar: target.revealSpaceInSidebar + revealSpaceInSidebar: target.revealSpaceInSidebar, + commentID: target.commentId ) } func clearSelectedPage() { selectedPageID = nil + selectedCommentID = nil + } + + func clearSelectedPage(ifMatching pageID: String) { + guard selectedPageID == pageID else { return } + clearSelectedPage() } func resetNavigationSelection() { selectedSidebarDestination = nil selectedSpaceID = nil selectedPageID = nil + selectedCommentID = nil } func selectDefaultSpaceIfNeeded() { @@ -75,6 +91,7 @@ extension AppState { selectedSpaceID = nil selectedPageID = nil + selectedCommentID = nil guard let firstSpaceID = spaces.first?.id else { if shouldRevealDefaultSpace { diff --git a/docmostly/App/AppState+OfflineQueue.swift b/docmostly/App/AppState+OfflineQueue.swift index ca0e4b3..e14cc45 100644 --- a/docmostly/App/AppState+OfflineQueue.swift +++ b/docmostly/App/AppState+OfflineQueue.swift @@ -1,10 +1,21 @@ import Foundation +// swiftlint:disable file_length + +private enum OfflineReplayFailureOutcome { + case stop + case continueProcessing + case quarantineForCurrentPass +} + extension AppState { func canQueueOfflineMutation(after error: Error) -> Bool { if error is CancellationError { return false } + if error is OfflinePageUpdateReplayConflict { + return false + } guard let apiError = error as? APIError else { return true @@ -88,12 +99,49 @@ extension AppState { func queuePageUpdate( pageId: String, title: String, - document: ProseMirrorDocument + document: ProseMirrorDocument, + baseTitle: String? = nil, + baseDocument: ProseMirrorDocument? = nil ) async throws -> DocmostEditablePage { - try await queueOfflineMutation(.updatePage(pageId: pageId, title: title, document: document)) + try await queueOfflineMutation(.updatePage( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument + )) return try await saveLocalEditableDraft(pageId: pageId, title: title, document: document) } + func overlayPendingPageUpdate(on page: DocmostEditablePage) async throws -> DocmostEditablePage { + guard let cacheScope else { return page } + let pendingRecords = try await pendingOfflineMutations(scope: cacheScope) + guard let draft = pendingRecords.reversed().compactMap({ record -> (String, ProseMirrorDocument)? in + guard case .updatePage(let pageID, let title, let document, _, _) = record.payload, + pageID == page.id || pageID == page.slugId + else { + return nil + } + return (title, document) + }).first else { + return page + } + + return DocmostEditablePage( + id: page.id, + slugId: page.slugId, + title: draft.0, + content: draft.1, + icon: page.icon, + spaceId: page.spaceId, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + permissions: page.permissions, + creator: page.creator, + lastUpdatedBy: page.lastUpdatedBy + ) + } + func setProjectedFavorite( type: FavoriteType, pageId: String?, @@ -124,6 +172,7 @@ extension AppState { spaceWatchStatusByID.removeAll(keepingCapacity: true) } + // swiftlint:disable:next cyclomatic_complexity private func reconcileOfflineMutations() async { defer { offlineReplayTask = nil @@ -132,42 +181,46 @@ extension AppState { guard let apiClient, let cacheScope else { return } var inlineCommentIDMappings: [String: String] = [:] + var quarantinedRecordIDs: Set = [] do { while true { - let records = try await pendingOfflineMutations(scope: cacheScope, limit: 25) - guard records.isEmpty == false else { + let pendingRecords = try await pendingOfflineMutations(scope: cacheScope) + guard pendingRecords.isEmpty == false else { pendingOfflineMutationCount = 0 return } + let records = pendingRecords + .filter { quarantinedRecordIDs.contains($0.id) == false } + .prefix(25) + guard records.isEmpty == false else { + pendingOfflineMutationCount = pendingRecords.count + return + } for record in records { + let payload = record.payload.replacingCommentIDs(inlineCommentIDMappings) do { - let payload = record.payload.replacingCommentIDs(inlineCommentIDMappings) if let mapping = try await replay(record, payload: payload, using: apiClient) { inlineCommentIDMappings[mapping.localID] = mapping.serverID } try await removeOfflineMutation(id: record.id, scope: cacheScope) await refreshOfflineMutationCount() } catch { - if shouldDropOfflineMutation(after: error) { - try? await removeOfflineMutation(id: record.id, scope: cacheScope) - await refreshOfflineMutationCount() - statusMessage = "Dropped queued offline change: \(error.localizedDescription)" + switch await handleOfflineReplayFailure( + error, + record: record, + payload: payload, + scope: cacheScope + ) { + case .stop: + return + case .continueProcessing: + continue + case .quarantineForCurrentPass: + quarantinedRecordIDs.insert(record.id) continue } - - try? await markOfflineMutationFailed( - id: record.id, - scope: cacheScope, - message: error.localizedDescription - ) - await refreshOfflineMutationCount() - if canQueueOfflineMutation(after: error) { - isOffline = true - } - statusMessage = "Could not sync queued offline change: \(error.localizedDescription)" - return } } } @@ -176,21 +229,49 @@ extension AppState { } } - private func shouldDropOfflineMutation(after error: Error) -> Bool { - if error is CancellationError { - return true + private func handleOfflineReplayFailure( + _ error: any Error, + record: OfflineMutationRecord, + payload: OfflineMutationPayload, + scope: CacheScope + ) async -> OfflineReplayFailureOutcome { + switch OfflineMutationReplayFailureDisposition(error: error, payload: payload) { + case .stopWithoutMutation: + return .stop + case .dropRecord: + try? await removeOfflineMutation(id: record.id, scope: scope) + await refreshOfflineMutationCount() + statusMessage = "Dropped queued offline change: \(error.localizedDescription)" + return .continueProcessing + case .retainForRetry: + try? await markOfflineMutationFailed( + id: record.id, + scope: scope, + message: error.localizedDescription + ) + await refreshOfflineMutationCount() + if canQueueOfflineMutation(after: error) { + isOffline = true + } + statusMessage = "Could not sync queued offline change: \(error.localizedDescription)" + return shouldQuarantineForCurrentPass(error: error, payload: payload) ? + .quarantineForCurrentPass : .stop } + } - guard let apiError = error as? APIError else { - return false + private func shouldQuarantineForCurrentPass( + error: any Error, + payload: OfflineMutationPayload + ) -> Bool { + if error is OfflinePageUpdateReplayConflict { + return true } - - switch apiError { - case .httpStatus(let status, _): - return status >= 400 && status < 500 && status != 401 && status != 403 && status != 408 && status != 429 - case .connectionFailed, .invalidResponse, .missingData, .decodingFailed, .responseTooLarge: + guard payload.canDropAfterPermanentClientFailure == false, + let apiError = error as? APIError, + case .httpStatus(let status, _) = apiError else { return false } + return status >= 400 && status < 500 && status != 401 && status != 403 && status != 408 && status != 429 } private func replay( @@ -199,11 +280,13 @@ extension AppState { using apiClient: DocmostAPIClient ) async throws -> (localID: String, serverID: String)? { switch payload { - case .updatePage(let pageId, let title, let document): + case .updatePage(let pageId, let title, let document, let baseTitle, let baseDocument): try await replayPageUpdate( pageId: pageId, title: title, document: document, + baseTitle: baseTitle, + baseDocument: baseDocument, scope: record.scope, using: apiClient ) @@ -240,20 +323,40 @@ extension AppState { } } + // swiftlint:disable:next function_parameter_count private func replayPageUpdate( pageId: String, title: String, document: ProseMirrorDocument, + baseTitle: String?, + baseDocument: ProseMirrorDocument?, scope: CacheScope, using apiClient: DocmostAPIClient ) async throws { - let page: DocmostEditablePage = try await apiClient.send(.updatePage( - pageId: pageId, - title: title, - content: document, - format: .json, - operation: .replace - )) + let serverPage: DocmostEditablePage = try await apiClient.send(.pageInfo(pageId: pageId, format: .json)) + let page: DocmostEditablePage + switch OfflinePageUpdateReplayDecision.resolve( + serverPage: serverPage, + queuedTitle: title, + queuedDocument: document, + baseTitle: baseTitle, + baseDocument: baseDocument + ) { + case .alreadySynchronized: + page = serverPage + case .updateTitleOnly: + page = try await apiClient.send(.updatePage(pageId: pageId, title: title)) + case .replaceDocument(let replacementTitle): + page = try await apiClient.send(.updatePage( + pageId: pageId, + title: replacementTitle, + content: document, + format: .json, + operation: .replace + )) + case .conflict: + throw OfflinePageUpdateReplayConflict(pageID: pageId) + } scheduleCacheWrite(.saveEditablePage(page, scope: scope)) } @@ -447,11 +550,11 @@ extension AppState { // swiftlint:disable cyclomatic_complexity private func applyOfflineProjection(_ payload: OfflineMutationPayload) { switch payload { - case .createComment(let localId, let pageId, _, let plainText, let type, let selection, _): + case .createComment(let localId, let pageId, let content, let plainText, let type, let selection, _): applyProjectedCommentCreation( localId: localId, pageId: pageId, - plainText: plainText, + body: CommentBody(jsonString: content) ?? CommentBody(plainText: plainText), type: type, selection: selection ) @@ -494,20 +597,21 @@ extension AppState { private func applyProjectedCommentCreation( localId: String, pageId: String, - plainText: String, + body: CommentBody, type: DocmostCommentType, selection: String? ) { let comment = DocmostComment( id: localId, - content: plainText, + content: body.plainText, selection: selection, type: type.rawValue, creatorId: currentUser?.user.id ?? "offline", pageId: pageId, workspaceId: currentUser?.workspace.id, createdAt: Date.now, - creator: currentUser?.user + creator: currentUser?.user, + body: body ) applyReplayedComment(comment) } diff --git a/docmostly/App/AppState+Transclusions.swift b/docmostly/App/AppState+Transclusions.swift new file mode 100644 index 0000000..0909cee --- /dev/null +++ b/docmostly/App/AppState+Transclusions.swift @@ -0,0 +1,59 @@ +import Foundation + +extension AppState { + func loadTransclusion( + sourcePageId: String, + transclusionId: String + ) async throws -> DocmostTransclusionLookupItem { + let reference = DocmostTransclusionReference( + sourcePageId: sourcePageId, + transclusionId: transclusionId + ) + let results = try await loadTransclusions([reference]) + guard let result = results.first else { + throw APIError.decodingFailed("The synced block lookup returned no result.") + } + return result + } + + func loadTransclusions( + _ references: [DocmostTransclusionReference] + ) async throws -> [DocmostTransclusionLookupItem] { + guard references.isEmpty == false else { return [] } + guard let apiClient else { + throw APIError.connectionFailed("Synced blocks require a network connection.") + } + + var results: [DocmostTransclusionLookupItem] = [] + var startIndex = references.startIndex + + while startIndex < references.endIndex { + let endIndex = min( + startIndex + DocmostTransclusionLookupRequest.maximumReferences, + references.endIndex + ) + let batch = Array(references[startIndex.. DocmostEditablePage { - guard let apiClient else { - return try await queuePageUpdate(pageId: pageId, title: title, document: document) - } - - do { - let page: DocmostEditablePage = try await apiClient.send(.updatePage( - pageId: pageId, - title: title, - content: document, - format: .json, - operation: .replace - )) - isOffline = false - if let cacheScope { - scheduleCacheWrite(.saveEditablePage(page, scope: cacheScope)) - } - do { - try await clearPendingPageUpdate(pageId: pageId, title: title, document: document) - scheduleOfflineQueueReconciliation() - } catch { - statusMessage = error.localizedDescription - } - return page - } catch { - guard canQueueOfflineMutation(after: error) else { throw error } - isOffline = true - statusMessage = error.localizedDescription - return try await queuePageUpdate(pageId: pageId, title: title, document: document) - } - } - func search( query: String, spaceId: String?, diff --git a/docmostly/App/CommentPayload.swift b/docmostly/App/CommentPayload.swift index ca55e84..20403f5 100644 --- a/docmostly/App/CommentPayload.swift +++ b/docmostly/App/CommentPayload.swift @@ -1,42 +1,17 @@ import Foundation -nonisolated struct CommentPayload: Encodable, Sendable { - let type: String - let content: [CommentParagraph] +nonisolated struct CommentPayload: Sendable { + let body: CommentBody static func plainText(_ text: String) -> CommentPayload { - let paragraphs = text - .split(separator: "\n", omittingEmptySubsequences: false) - .map { line in - CommentParagraph( - type: "paragraph", - content: line.isEmpty ? [] : [CommentText(type: "text", text: String(line))] - ) - } - return CommentPayload( - type: "doc", - content: paragraphs.isEmpty ? [CommentParagraph(type: "paragraph", content: [])] : paragraphs - ) + CommentPayload(body: CommentBody(plainText: text)) } - var jsonString: String { - let encoder = JSONEncoder() - guard - let data = try? encoder.encode(self), - let string = String(data: data, encoding: .utf8) - else { - return #"{"type":"doc","content":[]}"# - } - return string + static func richText(_ text: AttributedString) -> CommentPayload { + CommentPayload(body: CommentBody(attributedText: text)) } -} - -nonisolated struct CommentParagraph: Encodable, Sendable { - let type: String - let content: [CommentText] -} -nonisolated struct CommentText: Encodable, Sendable { - let type: String - let text: String + var jsonString: String { + body.jsonString + } } diff --git a/docmostly/App/OfflineMutationReplayFailureDisposition.swift b/docmostly/App/OfflineMutationReplayFailureDisposition.swift new file mode 100644 index 0000000..bd5ae8a --- /dev/null +++ b/docmostly/App/OfflineMutationReplayFailureDisposition.swift @@ -0,0 +1,32 @@ +import Foundation + +nonisolated enum OfflineMutationReplayFailureDisposition: Equatable { + case stopWithoutMutation + case dropRecord + case retainForRetry + + init(error: any Error, payload: OfflineMutationPayload? = nil) { + if error is CancellationError { + self = .stopWithoutMutation + return + } + + guard let apiError = error as? APIError else { + self = .retainForRetry + return + } + + switch apiError { + case .httpStatus(let status, _) + where status >= 400 && status < 500 && status != 401 && status != 403 && status != 408 && status != 429: + self = payload?.canDropAfterPermanentClientFailure == true ? .dropRecord : .retainForRetry + case .connectionFailed, + .invalidResponse, + .httpStatus, + .missingData, + .decodingFailed, + .responseTooLarge: + self = .retainForRetry + } + } +} diff --git a/docmostly/Features/Comments/CommentBody.swift b/docmostly/Features/Comments/CommentBody.swift new file mode 100644 index 0000000..12b39c0 --- /dev/null +++ b/docmostly/Features/Comments/CommentBody.swift @@ -0,0 +1,125 @@ +import Foundation +import SwiftUI + +nonisolated struct CommentBody: Hashable, Sendable { + let document: ProseMirrorDocument + + init(document: ProseMirrorDocument) { + self.document = document + } + + init(plainText: String) { + self.init(attributedText: AttributedString(plainText)) + } + + init(attributedText: AttributedString) { + document = Self.document(from: attributedText) + } + + init?(jsonString: String) { + guard let data = jsonString.data(using: .utf8), + let document = try? JSONDecoder().decode(ProseMirrorDocument.self, from: data) else { + return nil + } + self.document = document + } + + var attributedText: AttributedString { + document.content.enumerated().reduce(into: AttributedString("")) { result, entry in + if entry.offset > 0 { + result += AttributedString("\n") + } + + result += NativeEditorDocument.attributedText( + from: NativeEditorDocument.inlineContent(from: entry.element.content ?? []) + ) + } + } + + var plainText: String { + String(attributedText.characters) + } + + var isEmpty: Bool { + plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var isSupportedForEditing: Bool { + document.type == "doc" + && document.content.allSatisfy(Self.isSupportedParagraph) + } + + var jsonString: String { + let encoder = JSONEncoder() + guard + let data = try? encoder.encode(document), + let string = String(data: data, encoding: .utf8) + else { + return #"{"type":"doc","content":[]}"# + } + return string + } + + private static func document(from text: AttributedString) -> ProseMirrorDocument { + let inlineNodes = NativeEditorDocument.inlineNodes(from: text) + var paragraphs: [[ProseMirrorNode]] = [[]] + + for node in inlineNodes { + if node.type == "hardBreak" { + paragraphs.append([]) + } else { + paragraphs[paragraphs.index(before: paragraphs.endIndex)].append(node) + } + } + + return ProseMirrorDocument(content: paragraphs.map { nodes in + ProseMirrorNode(type: "paragraph", content: nodes) + }) + } + + private static func isSupportedParagraph(_ node: ProseMirrorNode) -> Bool { + guard node.type == "paragraph", + node.attrs?.isEmpty != false, + node.marks?.isEmpty != false, + node.text == nil else { + return false + } + + return (node.content ?? []).allSatisfy(isSupportedInlineNode) + } + + private static func isSupportedInlineNode(_ node: ProseMirrorNode) -> Bool { + guard (node.marks ?? []).allSatisfy(isSupportedMark) else { return false } + + switch node.type { + case "text": + return node.text != nil + && node.attrs?.isEmpty != false + && node.content == nil + case "hardBreak": + return node.attrs?.isEmpty != false + && node.content == nil + && node.text == nil + case "mention": + let entityType = node.attrs?["entityType"]?.stringValue + return entityType == "user" || entityType == "page" + default: + return false + } + } + + private static func isSupportedMark(_ mark: ProseMirrorMark) -> Bool { + switch mark.type { + case "bold", "italic", "strike", "code": + return mark.attrs?.isEmpty != false + case "link": + guard let href = mark.attrs?["href"]?.stringValue else { return false } + return NativeEditorDocument.preservedLink( + href: href, + isInternal: mark.attrs?["internal"]?.boolValue ?? false + ) != nil + default: + return false + } + } +} diff --git a/docmostly/Features/Comments/CommentBodyView.swift b/docmostly/Features/Comments/CommentBodyView.swift new file mode 100644 index 0000000..4ecb920 --- /dev/null +++ b/docmostly/Features/Comments/CommentBodyView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct CommentBodyView: View { + let comment: DocmostComment + + var body: some View { + Text(comment.body?.attributedText ?? AttributedString(comment.content ?? "")) + .font(.body) + .foregroundStyle(comment.isResolved ? .secondary : .primary) + .textSelection(.enabled) + .accessibilityLabel(comment.content ?? "Comment") + } +} diff --git a/docmostly/Features/Comments/CommentComposerEmojiMenu.swift b/docmostly/Features/Comments/CommentComposerEmojiMenu.swift new file mode 100644 index 0000000..b67b34c --- /dev/null +++ b/docmostly/Features/Comments/CommentComposerEmojiMenu.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct CommentComposerEmojiMenu: View { + let draft: CommentComposerState + let isEnabled: Bool + + var body: some View { + Menu("Insert Emoji", systemImage: "face.smiling") { + ForEach(CommentEmoji.all) { emoji in + Button("\(emoji.symbol) \(emoji.name.capitalized)") { + draft.insertEmoji(emoji) + } + } + } + .labelStyle(.iconOnly) + .menuStyle(.button) + .disabled(isEnabled == false) + .help("Insert Emoji") + } +} diff --git a/docmostly/Features/Comments/CommentComposerFormat.swift b/docmostly/Features/Comments/CommentComposerFormat.swift new file mode 100644 index 0000000..826520c --- /dev/null +++ b/docmostly/Features/Comments/CommentComposerFormat.swift @@ -0,0 +1,49 @@ +import SwiftUI + +nonisolated enum CommentComposerFormat: String, CaseIterable, Identifiable, Sendable { + case bold + case italic + case strikethrough + case code + + var id: String { rawValue } + + var title: String { + switch self { + case .bold: + "Bold" + case .italic: + "Italic" + case .strikethrough: + "Strikethrough" + case .code: + "Inline Code" + } + } + + var systemImage: String { + switch self { + case .bold: + "bold" + case .italic: + "italic" + case .strikethrough: + "strikethrough" + case .code: + "chevron.left.forwardslash.chevron.right" + } + } + + var presentationIntent: InlinePresentationIntent { + switch self { + case .bold: + .stronglyEmphasized + case .italic: + .emphasized + case .strikethrough: + .strikethrough + case .code: + .code + } + } +} diff --git a/docmostly/Features/Comments/CommentComposerFormattingButtons.swift b/docmostly/Features/Comments/CommentComposerFormattingButtons.swift new file mode 100644 index 0000000..8c4f3a4 --- /dev/null +++ b/docmostly/Features/Comments/CommentComposerFormattingButtons.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CommentComposerFormattingButtons: View { + let draft: CommentComposerState + let isEnabled: Bool + + var body: some View { + ForEach(CommentComposerFormat.allCases) { format in + Button(format.title, systemImage: format.systemImage) { + draft.toggle(format) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .foregroundStyle(draft.isActive(format) ? DocmostlyTheme.primary : .secondary) + .disabled(draft.hasSelection == false || isEnabled == false) + .help(format.title) + } + } +} diff --git a/docmostly/Features/Comments/CommentComposerState.swift b/docmostly/Features/Comments/CommentComposerState.swift new file mode 100644 index 0000000..0b72e4b --- /dev/null +++ b/docmostly/Features/Comments/CommentComposerState.swift @@ -0,0 +1,192 @@ +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class CommentComposerState { + var text: AttributedString + var selection = AttributedTextSelection() + + init(body: CommentBody? = nil) { + text = body?.attributedText ?? AttributedString("") + } + + var body: CommentBody { + CommentBody(attributedText: text) + } + + var plainText: String { + String(text.characters) + } + + var isEmpty: Bool { + body.isEmpty + } + + var hasSelection: Bool { + selection.hasSelectedRanges(in: text) + } + + func reset(body: CommentBody? = nil) { + text = body?.attributedText ?? AttributedString("") + selection = AttributedTextSelection() + } + + func toggle(_ format: CommentComposerFormat) { + guard hasSelection else { return } + let removesIntent = selectedRuns.allSatisfy { run in + (run.inlinePresentationIntent ?? []).contains(format.presentationIntent) + } + var updatedSelection = selection + text.transformAttributes(in: &updatedSelection) { attributes in + var intent = attributes.inlinePresentationIntent ?? [] + if removesIntent { + intent.remove(format.presentationIntent) + } else { + intent.insert(format.presentationIntent) + } + attributes.inlinePresentationIntent = intent.isEmpty ? nil : intent + } + selection = updatedSelection + } + + func isActive(_ format: CommentComposerFormat) -> Bool { + hasSelection && selectedRuns.allSatisfy { run in + (run.inlinePresentationIntent ?? []).contains(format.presentationIntent) + } + } + + func applyLink(_ rawHref: String) throws { + guard hasSelection else { throw CommentLinkValidationError.selectText } + let trimmedHref = rawHref.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmedHref.isEmpty { + removeLink() + return + } + + let normalizedLink: (href: String, url: URL)? + if let safeLink = NativeEditorDocument.normalizedSafeWebLink(from: trimmedHref) { + normalizedLink = safeLink + } else if let mailURL = NativeEditorDocument.safeLinkURL(from: trimmedHref), mailURL.scheme == "mailto" { + normalizedLink = (trimmedHref, mailURL) + } else { + normalizedLink = nil + } + + guard let normalizedLink else { throw CommentLinkValidationError.invalidURL } + var updatedSelection = selection + text.transformAttributes(in: &updatedSelection) { attributes in + attributes[NativeEditorLinkAttribute.self] = NativeEditorLink( + href: normalizedLink.href, + isInternal: false + ) + attributes.link = normalizedLink.url + } + selection = updatedSelection + } + + func removeLink() { + guard hasSelection else { return } + var updatedSelection = selection + text.transformAttributes(in: &updatedSelection) { attributes in + attributes[NativeEditorLinkAttribute.self] = nil + attributes.link = nil + } + selection = updatedSelection + } + + func insertMention(_ user: DocmostMentionUserSuggestion, creatorID: String?) { + let mention = NativeEditorMention(userSuggestion: user, creatorID: creatorID) + var segment = AttributedString(mention.displayText) + segment[NativeEditorMentionAttribute.self] = mention + segment.foregroundColor = DocmostlyTheme.primary + segment += AttributedString(" ") + replaceActiveTrigger("@", with: segment) + } + + func insertEmoji(_ emoji: CommentEmoji) { + replaceActiveTrigger(":", with: AttributedString("\(emoji.symbol) ")) + } + + func activeQuery(after trigger: Character) -> String? { + guard let triggerRange = activeTriggerRange(trigger) else { return nil } + let queryStart = text.characters.index(after: triggerRange.lowerBound) + return String(text.characters[queryStart.. + switch selection.indices(in: text) { + case .ranges(let ranges): + range = ranges.ranges.first ?? text.endIndex.., with replacement: AttributedString) { + let insertionOffset = text.characters.distance(from: text.startIndex, to: range.lowerBound) + text.replaceSubrange(range, with: replacement) + let selectionOffset = insertionOffset + replacement.characters.count + let insertionPoint = text.characters.index(text.startIndex, offsetBy: selectionOffset) + selection = AttributedTextSelection(insertionPoint: insertionPoint) + } + + private func activeTriggerRange(_ trigger: Character) -> Range? { + let cursor: AttributedString.Index + switch selection.indices(in: text) { + case .ranges(let ranges): + guard let range = ranges.ranges.first, range.isEmpty else { return nil } + cursor = range.lowerBound + case .insertionPoint(let insertionPoint): + cursor = insertionPoint + } + + let prefix = String(text.characters[text.startIndex.. prefix.startIndex { + let previousIndex = prefix.index(before: triggerIndex) + guard prefix[previousIndex].isWhitespace || prefix[previousIndex].isPunctuation else { return nil } + } + + let triggerOffset = prefix.distance(from: prefix.startIndex, to: triggerIndex) + let lowerBound = text.characters.index(text.startIndex, offsetBy: triggerOffset) + return lowerBound.. Void + let cancel: (() -> Void)? + + var body: some View { + if let cancel { + Button("Cancel", action: cancel) + .controlSize(.small) + .keyboardShortcut(.cancelAction) + } + + if isSubmitting { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Posting comment") + } + + Button(submitTitle, systemImage: "arrow.up", action: submit) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isEnabled == false || isSubmitting || draft.isEmpty) + .keyboardShortcut(.return, modifiers: .command) + } +} diff --git a/docmostly/Features/Comments/CommentComposerToolbarView.swift b/docmostly/Features/Comments/CommentComposerToolbarView.swift new file mode 100644 index 0000000..e9fd867 --- /dev/null +++ b/docmostly/Features/Comments/CommentComposerToolbarView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct CommentComposerToolbarView: View { + @Bindable var draft: CommentComposerState + + let submitTitle: String + let isEnabled: Bool + let isSubmitting: Bool + let showLinkEditor: () -> Void + let showMentionPicker: () -> Void + let submit: () -> Void + let cancel: (() -> Void)? + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack { + CommentComposerFormattingButtons(draft: draft, isEnabled: isEnabled) + + Button("Add Link", systemImage: "link", action: showLinkEditor) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(draft.hasSelection == false || isEnabled == false) + .help("Add Link") + + Button("Mention Person", systemImage: "at", action: showMentionPicker) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(isEnabled == false) + .help("Mention Person") + + CommentComposerEmojiMenu(draft: draft, isEnabled: isEnabled) + + Spacer(minLength: 0) + + CommentComposerSubmissionControls( + draft: draft, + submitTitle: submitTitle, + isEnabled: isEnabled, + isSubmitting: isSubmitting, + submit: submit, + cancel: cancel + ) + } + + HStack { + Menu("Comment Tools", systemImage: "textformat") { + Section("Formatting") { + ForEach(CommentComposerFormat.allCases) { format in + Button( + format.title, + systemImage: draft.isActive(format) ? "checkmark" : format.systemImage + ) { + draft.toggle(format) + } + .disabled(draft.hasSelection == false) + } + } + + Button("Add Link", systemImage: "link", action: showLinkEditor) + .disabled(draft.hasSelection == false) + Button("Mention Person", systemImage: "at", action: showMentionPicker) + + Menu("Insert Emoji", systemImage: "face.smiling") { + ForEach(CommentEmoji.all) { emoji in + Button("\(emoji.symbol) \(emoji.name.capitalized)") { + draft.insertEmoji(emoji) + } + } + } + } + .disabled(isEnabled == false) + + Spacer(minLength: 0) + + CommentComposerSubmissionControls( + draft: draft, + submitTitle: submitTitle, + isEnabled: isEnabled, + isSubmitting: isSubmitting, + submit: submit, + cancel: cancel + ) + } + } + } +} diff --git a/docmostly/Features/Comments/CommentEmoji.swift b/docmostly/Features/Comments/CommentEmoji.swift new file mode 100644 index 0000000..4201dd7 --- /dev/null +++ b/docmostly/Features/Comments/CommentEmoji.swift @@ -0,0 +1,43 @@ +import Foundation + +nonisolated struct CommentEmoji: Identifiable, Hashable, Sendable { + let name: String + let symbol: String + + var id: String { name } + + static func suggestions(matching query: String) -> [CommentEmoji] { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedQuery.isEmpty == false else { return Array(all.prefix(8)) } + return all.filter { emoji in + emoji.name.localizedStandardContains(trimmedQuery) + } + } + + static let all: [CommentEmoji] = [ + CommentEmoji(name: "thumbs up", symbol: "👍"), + CommentEmoji(name: "heart", symbol: "❤️"), + CommentEmoji(name: "smile", symbol: "😊"), + CommentEmoji(name: "laugh", symbol: "😂"), + CommentEmoji(name: "party", symbol: "🎉"), + CommentEmoji(name: "rocket", symbol: "🚀"), + CommentEmoji(name: "eyes", symbol: "👀"), + CommentEmoji(name: "thinking", symbol: "🤔"), + CommentEmoji(name: "check", symbol: "✅"), + CommentEmoji(name: "warning", symbol: "⚠️"), + CommentEmoji(name: "fire", symbol: "🔥"), + CommentEmoji(name: "sparkles", symbol: "✨"), + CommentEmoji(name: "wave", symbol: "👋"), + CommentEmoji(name: "pray", symbol: "🙏"), + CommentEmoji(name: "clap", symbol: "👏"), + CommentEmoji(name: "hundred", symbol: "💯"), + CommentEmoji(name: "bulb", symbol: "💡"), + CommentEmoji(name: "pin", symbol: "📌"), + CommentEmoji(name: "link", symbol: "🔗"), + CommentEmoji(name: "memo", symbol: "📝"), + CommentEmoji(name: "calendar", symbol: "📅"), + CommentEmoji(name: "bug", symbol: "🐛"), + CommentEmoji(name: "wrench", symbol: "🔧"), + CommentEmoji(name: "lock", symbol: "🔒") + ] +} diff --git a/docmostly/Features/Comments/CommentLinkEditorView.swift b/docmostly/Features/Comments/CommentLinkEditorView.swift new file mode 100644 index 0000000..1973390 --- /dev/null +++ b/docmostly/Features/Comments/CommentLinkEditorView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct CommentLinkEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var href = "" + @State private var errorMessage: String? + + let draft: CommentComposerState + + var body: some View { + NavigationStack { + Form { + TextField("Link", text: $href, prompt: Text("https://example.com")) + .textContentType(.URL) + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + .onSubmit(applyLink) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(DocmostlyTheme.destructive) + } + } + .formStyle(.grouped) + .navigationTitle("Add Link") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: dismiss.callAsFunction) + } + + ToolbarItem(placement: .confirmationAction) { + Button("Apply", action: applyLink) + } + } + } + .frame(minWidth: 320, minHeight: 220) + } + + private func applyLink() { + do { + try draft.applyLink(href) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/docmostly/Features/Comments/CommentMentionPickerView.swift b/docmostly/Features/Comments/CommentMentionPickerView.swift new file mode 100644 index 0000000..7f80c96 --- /dev/null +++ b/docmostly/Features/Comments/CommentMentionPickerView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct CommentMentionPickerView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @State private var users: [DocmostMentionUserSuggestion] = [] + @State private var isSearching = false + @State private var errorMessage: String? + + let draft: CommentComposerState + + var body: some View { + NavigationStack { + List { + if isSearching { + ProgressView("Searching people") + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(DocmostlyTheme.destructive) + } + + ForEach(users) { user in + Button { + insert(user) + } label: { + VStack(alignment: .leading) { + Label(user.name.isEmpty ? "Unnamed person" : user.name, systemImage: "person.crop.circle") + if let email = user.email, email.isEmpty == false { + Text(email) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + } + } + .overlay { + if query.isEmpty { + ContentUnavailableView("Search people", systemImage: "at") + } else if users.isEmpty && isSearching == false && errorMessage == nil { + ContentUnavailableView.search(text: query) + } + } + .navigationTitle("Mention Person") + .searchable(text: $query, prompt: "Search people") + .task(id: query) { + await search() + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: dismiss.callAsFunction) + } + } + } + .frame(minWidth: 340, minHeight: 380) + } + + private func search() async { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedQuery.count >= 2 else { + users = [] + errorMessage = nil + return + } + + do { + try await Task.sleep(for: .milliseconds(250)) + try Task.checkCancellation() + isSearching = true + errorMessage = nil + defer { isSearching = false } + let response = try await appState.searchMentionSuggestions( + query: trimmedQuery, + spaceId: appState.selectedSpaceID + ) + try Task.checkCancellation() + users = response.users + } catch is CancellationError { + return + } catch { + users = [] + errorMessage = error.localizedDescription + } + } + + private func insert(_ user: DocmostMentionUserSuggestion) { + draft.insertMention(user, creatorID: appState.currentUser?.user.id) + dismiss() + } +} diff --git a/docmostly/Features/Comments/CommentMentionSuggestionsView.swift b/docmostly/Features/Comments/CommentMentionSuggestionsView.swift new file mode 100644 index 0000000..aba1285 --- /dev/null +++ b/docmostly/Features/Comments/CommentMentionSuggestionsView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CommentMentionSuggestionsView: View { + @Environment(AppState.self) private var appState + @State private var users: [DocmostMentionUserSuggestion] = [] + @State private var isSearching = false + + let query: String + let draft: CommentComposerState + + var body: some View { + Group { + if isSearching { + ProgressView() + .controlSize(.small) + } else if users.isEmpty == false { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(users.prefix(5)) { user in + Button(user.name, systemImage: "person.crop.circle") { + draft.insertMention(user, creatorID: appState.currentUser?.user.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .scrollIndicators(.hidden) + } + } + .accessibilityLabel("Mention suggestions") + .task(id: query) { + await search() + } + } + + private func search() async { + guard query.count >= 2 else { + users = [] + return + } + + do { + try await Task.sleep(for: .milliseconds(250)) + try Task.checkCancellation() + isSearching = true + defer { isSearching = false } + let response = try await appState.searchMentionSuggestions( + query: query, + spaceId: appState.selectedSpaceID, + limit: 5 + ) + try Task.checkCancellation() + users = response.users + } catch { + users = [] + } + } +} diff --git a/docmostly/Features/Comments/CommentPermissionPolicy.swift b/docmostly/Features/Comments/CommentPermissionPolicy.swift new file mode 100644 index 0000000..baaf137 --- /dev/null +++ b/docmostly/Features/Comments/CommentPermissionPolicy.swift @@ -0,0 +1,42 @@ +import Foundation + +nonisolated enum CommentPermissionPolicy { + static func canCreate(pageCanEdit: Bool, allowViewerComments: Bool) -> Bool { + pageCanEdit || allowViewerComments + } + + static func canEdit( + _ comment: DocmostComment, + currentUserID: String?, + canComment: Bool, + isOnline: Bool + ) -> Bool { + isOnline + && canComment + && comment.creatorId == currentUserID + && comment.isNativelyEditable + && comment.isLocallyQueued == false + } + + static func canDelete( + _ comment: DocmostComment, + currentUserID: String?, + isSpaceAdmin: Bool, + isOnline: Bool + ) -> Bool { + isOnline + && comment.isLocallyQueued == false + && (comment.creatorId == currentUserID || isSpaceAdmin) + } + + static func canResolve( + _ comment: DocmostComment, + canComment: Bool, + isOnline: Bool + ) -> Bool { + isOnline + && canComment + && comment.parentCommentId == nil + && comment.isLocallyQueued == false + } +} diff --git a/docmostly/Features/Comments/CommentReplyComposerView.swift b/docmostly/Features/Comments/CommentReplyComposerView.swift new file mode 100644 index 0000000..fb2c0f4 --- /dev/null +++ b/docmostly/Features/Comments/CommentReplyComposerView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct CommentReplyComposerView: View { + let commentID: String + let draft: CommentComposerState + let isEnabled: Bool + let isSubmitting: Bool + let postReply: () -> Void + + var body: some View { + VStack(alignment: .leading) { + Divider() + + CommentRichComposerView( + draft: draft, + placeholder: "Write a reply", + submitTitle: "Reply", + accessibilityIdentifier: "comment-reply-field-\(commentID)", + isEnabled: isEnabled, + isSubmitting: isSubmitting, + autofocus: false, + submit: postReply + ) + .accessibilityIdentifier("comment-reply-submit-\(commentID)") + } + .padding(.top) + } +} diff --git a/docmostly/Features/Comments/CommentRichComposerView.swift b/docmostly/Features/Comments/CommentRichComposerView.swift new file mode 100644 index 0000000..cd1b18a --- /dev/null +++ b/docmostly/Features/Comments/CommentRichComposerView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct CommentRichComposerView: View { + @Bindable var draft: CommentComposerState + @FocusState private var isFocused: Bool + @State private var isShowingLinkEditor = false + @State private var isShowingMentionPicker = false + + let placeholder: String + let submitTitle: String + let accessibilityIdentifier: String + let isEnabled: Bool + let isSubmitting: Bool + let autofocus: Bool + let submit: () -> Void + var cancel: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topLeading) { + if draft.isEmpty { + Text(placeholder) + .foregroundStyle(.tertiary) + .padding(.horizontal, 6) + .padding(.vertical, 8) + .accessibilityHidden(true) + } + + TextEditor(text: $draft.text, selection: $draft.selection) + .font(.body) + .scrollContentBackground(.hidden) + .focused($isFocused) + .frame(minHeight: 72, maxHeight: 180) + .accessibilityLabel(placeholder) + .accessibilityIdentifier(accessibilityIdentifier) + } + .padding(4) + .background(.quaternary.opacity(0.35), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke( + isFocused ? + AnyShapeStyle(DocmostlyTheme.primary.opacity(0.55)) : AnyShapeStyle(.quaternary) + ) + } + + if let mentionQuery = draft.activeQuery(after: "@"), mentionQuery.isEmpty == false { + CommentMentionSuggestionsView(query: mentionQuery, draft: draft) + } else if let emojiQuery = draft.activeQuery(after: ":") { + CommentEmojiSuggestionsView(query: emojiQuery, draft: draft) + } + + CommentComposerToolbarView( + draft: draft, + submitTitle: submitTitle, + isEnabled: isEnabled, + isSubmitting: isSubmitting, + showLinkEditor: { + isShowingLinkEditor = true + }, + showMentionPicker: { + isShowingMentionPicker = true + }, + submit: submit, + cancel: cancel + ) + } + .disabled(isEnabled == false) + .popover(isPresented: $isShowingLinkEditor) { + CommentLinkEditorView(draft: draft) + .presentationCompactAdaptation(.sheet) + } + .popover(isPresented: $isShowingMentionPicker) { + CommentMentionPickerView(draft: draft) + .presentationCompactAdaptation(.sheet) + } + .onAppear { + if autofocus { + isFocused = true + } + } + } +} + +private struct CommentEmojiSuggestionsView: View { + let query: String + let draft: CommentComposerState + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(CommentEmoji.suggestions(matching: query).prefix(5)) { emoji in + Button("\(emoji.symbol) \(emoji.name.capitalized)") { + draft.insertEmoji(emoji) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .scrollIndicators(.hidden) + .accessibilityLabel("Emoji suggestions") + } +} diff --git a/docmostly/Features/Comments/CommentThreadView.swift b/docmostly/Features/Comments/CommentThreadView.swift new file mode 100644 index 0000000..eaed0f3 --- /dev/null +++ b/docmostly/Features/Comments/CommentThreadView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct CommentThreadView: View { + let comment: DocmostComment + let replies: [DocmostComment] + let viewModel: PageReaderViewModel + let canComment: Bool + let isReplyEnabled: Bool + let focusedCommentID: String? + let canEditComment: (DocmostComment) -> Bool + let canDeleteComment: (DocmostComment) -> Bool + let canToggleResolved: (DocmostComment) -> Bool + let toggleResolved: (DocmostComment) -> Void + let updateComment: (DocmostComment) -> Void + let deleteComment: (DocmostComment) -> Void + let postReply: (DocmostComment) -> Void + let focusInlineComment: (String) -> Void + + var body: some View { + DocmostlyGlassPanel(cornerRadius: 12) { + VStack(alignment: .leading) { + commentRow(comment, isReply: false) + + if replies.isEmpty == false { + VStack(alignment: .leading) { + ForEach(replies) { reply in + commentRow(reply, isReply: true) + .padding(.leading) + } + } + } + + if comment.isResolved == false, canComment { + CommentReplyComposerView( + commentID: comment.id, + draft: viewModel.replyDraft(for: comment.id), + isEnabled: isReplyEnabled && comment.isLocallyQueued == false, + isSubmitting: viewModel.isPostingReply(to: comment.id), + postReply: { + postReply(comment) + } + ) + } + } + .padding() + } + } + + private func commentRow(_ rowComment: DocmostComment, isReply: Bool) -> some View { + CommentRowView( + comment: rowComment, + isReply: isReply, + isResolving: viewModel.isResolvingComment(id: rowComment.id), + canToggleResolved: canToggleResolved(rowComment), + canEdit: canEditComment(rowComment), + canDelete: canDeleteComment(rowComment), + isEditing: viewModel.isEditingComment(id: rowComment.id), + isUpdating: viewModel.isUpdatingComment(id: rowComment.id), + isDeleting: viewModel.isDeletingComment(id: rowComment.id), + editDraft: viewModel.editDraftsByCommentID[rowComment.id], + errorMessage: viewModel.commentErrorsByID[rowComment.id], + isFocused: focusedCommentID == rowComment.id, + toggleResolved: { + toggleResolved(rowComment) + }, + beginEditing: { + viewModel.beginEditing(rowComment) + }, + cancelEditing: { + viewModel.cancelEditing(commentID: rowComment.id) + }, + saveEditing: { + updateComment(rowComment) + }, + deleteComment: { + deleteComment(rowComment) + }, + focusInlineComment: { + focusInlineComment(rowComment.id) + } + ) + } +} diff --git a/docmostly/Features/Comments/DocmostComment+Projection.swift b/docmostly/Features/Comments/DocmostComment+Projection.swift new file mode 100644 index 0000000..1abc89c --- /dev/null +++ b/docmostly/Features/Comments/DocmostComment+Projection.swift @@ -0,0 +1,30 @@ +import Foundation + +nonisolated extension DocmostComment { + func projectingResolution( + resolved: Bool, + resolvedBy user: DocmostUser?, + resolvedAt date: Date = .now + ) -> DocmostComment { + DocmostComment( + id: id, + content: content, + selection: selection, + type: type, + creatorId: creatorId, + pageId: pageId, + parentCommentId: parentCommentId, + resolvedById: resolved ? user?.id : nil, + resolvedAt: resolved ? date : nil, + workspaceId: workspaceId, + spaceId: spaceId, + createdAt: createdAt, + editedAt: editedAt, + deletedAt: deletedAt, + creator: creator, + resolvedBy: resolved ? user : nil, + body: body, + isNativelyEditable: isNativelyEditable + ) + } +} diff --git a/docmostly/Features/Comments/NativeRichEditorViewModel+CommentFocus.swift b/docmostly/Features/Comments/NativeRichEditorViewModel+CommentFocus.swift new file mode 100644 index 0000000..b1d34ce --- /dev/null +++ b/docmostly/Features/Comments/NativeRichEditorViewModel+CommentFocus.swift @@ -0,0 +1,23 @@ +import Foundation + +extension NativeRichEditorViewModel { + func blockID(containingInlineComment commentID: String) -> UUID? { + document.blocks.first { block in + block.text.runs.contains { $0.hasNativeEditorInlineComment(commentID: commentID) } + || block.rawNode.map { Self.containsInlineComment(commentID, in: $0) } == true + }?.id + } + + private static func containsInlineComment(_ commentID: String, in rootNode: ProseMirrorNode) -> Bool { + var pendingNodes = [rootNode] + while let node = pendingNodes.popLast() { + if (node.marks ?? []).contains(where: { mark in + mark.type == "comment" && mark.attrs?["commentId"]?.stringValue == commentID + }) { + return true + } + pendingNodes.append(contentsOf: node.content ?? []) + } + return false + } +} diff --git a/docmostly/Features/Comments/PageCommentComposerView.swift b/docmostly/Features/Comments/PageCommentComposerView.swift new file mode 100644 index 0000000..1f59cc9 --- /dev/null +++ b/docmostly/Features/Comments/PageCommentComposerView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct PageCommentComposerView: View { + let draft: CommentComposerState + let isSubmitting: Bool + let isEnabled: Bool + let postComment: () -> Void + + var body: some View { + VStack(alignment: .leading) { + Divider() + + CommentRichComposerView( + draft: draft, + placeholder: "Add a page comment", + submitTitle: "Comment", + accessibilityIdentifier: "page-comment-composer-field", + isEnabled: isEnabled, + isSubmitting: isSubmitting, + autofocus: false, + submit: postComment + ) + .accessibilityIdentifier("page-comment-submit-button") + } + } +} diff --git a/docmostly/Features/Comments/PageReaderViewModel+CommentThreads.swift b/docmostly/Features/Comments/PageReaderViewModel+CommentThreads.swift new file mode 100644 index 0000000..e5f9720 --- /dev/null +++ b/docmostly/Features/Comments/PageReaderViewModel+CommentThreads.swift @@ -0,0 +1,13 @@ +import Foundation + +extension PageReaderViewModel { + func replies(for parentCommentID: String) -> [DocmostComment] { + comments.filter { $0.parentCommentId == parentCommentID } + } + + func rootComment(containing commentID: String) -> DocmostComment? { + guard let comment = comments.first(where: { $0.id == commentID }) else { return nil } + guard let parentCommentID = comment.parentCommentId else { return comment } + return comments.first { $0.id == parentCommentID } + } +} diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index 77b232c..6514b87 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -132,6 +132,13 @@ nonisolated extension AttributedSubstring { ) ] } + + mutating func setNativeEditorInlineComments(_ comments: [NativeEditorInlineCommentMark]) { + let normalizedComments = comments.normalizedNativeEditorInlineComments + self[NativeEditorCommentMarksAttribute.self] = normalizedComments.isEmpty ? nil : normalizedComments + self[NativeEditorCommentIDAttribute.self] = normalizedComments.first?.commentID + self[NativeEditorCommentResolvedAttribute.self] = normalizedComments.first?.isResolved + } } nonisolated extension AttributeContainer { diff --git a/docmostly/Features/Editor/NativeEditorAutosaveCoordinator.swift b/docmostly/Features/Editor/NativeEditorAutosaveCoordinator.swift new file mode 100644 index 0000000..4c2c8dc --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorAutosaveCoordinator.swift @@ -0,0 +1,146 @@ +import Foundation + +@MainActor +final class NativeEditorAutosaveCoordinator { + typealias Operation = @MainActor () async -> Void + typealias Wait = @MainActor () async throws -> Void + + private let waitForDebounce: Wait + private var debounceTask: Task? + private var persistenceTask: Task? + private var requestRevision: UInt = 0 + private var readyRevision: UInt = 0 + private var attemptedRevision: UInt = 0 + private var completedRevision: UInt = 0 + private var pendingOperation: Operation? + private var readyOperation: Operation? + private var completionWaiters: [UUID: CompletionWaiter] = [:] + + private struct CompletionWaiter { + let targetRevision: UInt + let continuation: CheckedContinuation + } + + init( + debounceDelay: Duration = .milliseconds(900), + waitForDebounce: Wait? = nil + ) { + self.waitForDebounce = waitForDebounce ?? { + try await Task.sleep(for: debounceDelay) + } + } + + deinit { + debounceTask?.cancel() + for waiter in completionWaiters.values { + waiter.continuation.resume(returning: false) + } + } + + func schedule(_ operation: @escaping Operation) { + let revision = registerRequest(operation) + debounceTask?.cancel() + debounceTask = Task { [weak self, waitForDebounce] in + do { + try await waitForDebounce() + try Task.checkCancellation() + } catch { + return + } + + self?.markReady(revision: revision) + } + } + + func flush(_ operation: @escaping Operation) { + let revision = registerRequest(operation) + debounceTask?.cancel() + debounceTask = nil + markReady(revision: revision) + } + + func waitForCurrentPersistence(timeout: Duration) async -> Bool { + let targetRevision = requestRevision + guard targetRevision > completedRevision else { return true } + + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume(returning: false) + return + } + if targetRevision <= completedRevision { + continuation.resume(returning: true) + return + } + + completionWaiters[waiterID] = CompletionWaiter( + targetRevision: targetRevision, + continuation: continuation + ) + Task { [weak self] in + try? await Task.sleep(for: timeout) + self?.resolveCompletionWaiter(id: waiterID, didComplete: false) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.resolveCompletionWaiter(id: waiterID, didComplete: false) + } + } + } + + private func registerRequest(_ operation: @escaping Operation) -> UInt { + requestRevision += 1 + pendingOperation = operation + readyRevision = attemptedRevision + readyOperation = nil + return requestRevision + } + + private func markReady(revision: UInt) { + guard revision == requestRevision, let pendingOperation else { return } + + readyRevision = revision + readyOperation = pendingOperation + self.pendingOperation = nil + debounceTask = nil + startPersistenceIfNeeded() + } + + private func startPersistenceIfNeeded() { + guard persistenceTask == nil else { return } + guard readyRevision > attemptedRevision, let readyOperation else { return } + + let revision = readyRevision + attemptedRevision = revision + self.readyOperation = nil + persistenceTask = Task { [weak self, readyOperation] in + await readyOperation() + self?.finishPersistence(revision: revision) + } + } + + private func finishPersistence(revision: UInt) { + guard revision == attemptedRevision else { return } + + persistenceTask = nil + completedRevision = max(completedRevision, revision) + resolveCompletedWaiters() + startPersistenceIfNeeded() + } + + private func resolveCompletedWaiters() { + let completedWaiterIDs = completionWaiters.compactMap { waiterID, waiter in + waiter.targetRevision <= completedRevision ? waiterID : nil + } + for waiterID in completedWaiterIDs { + resolveCompletionWaiter(id: waiterID, didComplete: true) + } + } + + private func resolveCompletionWaiter(id: UUID, didComplete: Bool) { + completionWaiters.removeValue(forKey: id)?.continuation.resume(returning: didComplete) + } +} diff --git a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift index f6ad100..04913f0 100644 --- a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift +++ b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift @@ -38,6 +38,8 @@ struct NativeEditorBlockPrefix: View { .labelStyle(.iconOnly) .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) .buttonStyle(.plain) + .frame(width: 44, height: 44) + .contentShape(.interaction, .rect) } else { Image(systemName: systemImage) .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) diff --git a/docmostly/Features/Editor/NativeEditorBlockRow.swift b/docmostly/Features/Editor/NativeEditorBlockRow.swift index 80033cc..706fc78 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRow.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRow.swift @@ -15,8 +15,15 @@ struct NativeEditorBlockRow: View { let pageID: String let spaceID: String? let serverURLString: String? + let remotePresenceSegments: [NativeEditorRemotePresenceSegment] + let presenceProjection: NativeEditorRemotePresenceProjection + let presenceScope: [NativeEditorRemotePresenceScope] + let presenceBlockIndex: Int let focusBlock: () -> Void let moveBefore: (UUID) -> Void + let splitBlock: (Range) -> Bool + let insertHardBreak: (Range) -> Bool + let mergeBlockBackward: () -> Bool let blockChanged: () -> Void let selectionChanged: () -> Void let dropText: (String) -> Bool @@ -54,16 +61,19 @@ struct NativeEditorBlockRow: View { if showsEditableTextEditor { NativeEditorBlockTextSurface(kind: block.kind) { - TextEditor(text: $block.text, selection: $block.selection) - .font(block.kind.editorFont) - .scrollContentBackground(.hidden) - .scrollDisabled(true) - .contentMargins(.horizontal, 0, for: .scrollContent) - .contentMargins(.vertical, 0, for: .scrollContent) + NativeEditorTextInputView( + block: $block, + isFocused: blockFocusBinding, + accessibilityLabel: block.kind.accessibilityLabel, + actions: NativeEditorTextInputActions( + handleReturn: handleReturn, + insertHardBreak: insertHardBreak, + mergeBlockBackward: mergeBlockBackward + ), + remotePresenceSegments: remotePresenceSegments + ) .frame(maxWidth: .infinity, minHeight: minimumEditorHeight, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - .focused(focusedField, equals: .block(block.id)) - .accessibilityLabel(block.kind.accessibilityLabel) .onChange(of: block.selection) { _, _ in selectionChanged() } @@ -75,7 +85,10 @@ struct NativeEditorBlockRow: View { block: block, pageID: pageID, spaceID: spaceID, - serverURLString: serverURLString + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: presenceScope, + presenceBlockIndex: presenceBlockIndex ) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) @@ -90,7 +103,10 @@ struct NativeEditorBlockRow: View { richBlockActions: activeRichBlockActions, pageID: pageID, spaceID: spaceID, - serverURLString: serverURLString + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: presenceScope, + presenceBlockIndex: presenceBlockIndex ) .frame(maxWidth: .infinity, alignment: .leading) } @@ -130,6 +146,32 @@ struct NativeEditorBlockRow: View { return dropText(rawBlockID) } + .contextMenu { + if isReadOnly == false { + Button("Select Block", systemImage: "checkmark.square", action: select) + Button("Insert Below", systemImage: "plus", action: insertBelow) + Divider() + Button("Delete Block", systemImage: "trash", role: .destructive, action: delete) + } + } + #if os(macOS) + .onDeleteCommand { + guard isReadOnly == false, isSelected else { return } + delete() + } + #endif + .accessibilityAction(named: "Show Block Actions") { + guard isReadOnly == false else { return } + showControls() + } + .accessibilityAction(named: "Insert Block Below") { + guard isReadOnly == false else { return } + insertBelow() + } + .accessibilityAction(named: "Delete Block") { + guard isReadOnly == false else { return } + delete() + } .onChange(of: block) { _, _ in blockChanged() } @@ -185,4 +227,29 @@ struct NativeEditorBlockRow: View { private var activeRichBlockActions: NativeEditorRichBlockEditingActions? { showsControls ? richBlockActions : nil } + + private var blockFocusBinding: Binding { + Binding { + focusedField.wrappedValue == .block(block.id) + } set: { shouldFocus in + if shouldFocus { + focusedField.wrappedValue = .block(block.id) + } else if focusedField.wrappedValue == .block(block.id) { + focusedField.wrappedValue = nil + } + } + } + + private func handleReturn(_ selection: Range) -> Bool { + switch NativeEditorReturnKeyBehavior.resolve( + kind: block.kind, + text: block.text, + selection: selection + ) { + case .splitBlock: + splitBlock(selection) + case .insertHardBreak: + insertHardBreak(selection) + } + } } diff --git a/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift index e948724..5643361 100644 --- a/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift +++ b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift @@ -62,7 +62,13 @@ struct NativeEditorHorizontalRuleView: View { struct NativeEditorCalloutBlockView: View { let blockID: UUID let callout: NativeEditorCalloutBlock + let content: [ProseMirrorNode] let actions: NativeEditorRichBlockEditingActions? + let pageID: String + let spaceID: String? + let serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var presenceScope: [NativeEditorRemotePresenceScope] = [] var body: some View { let presentation = NativeEditorCalloutPresentation(style: callout.style) @@ -72,10 +78,28 @@ struct NativeEditorCalloutBlockView: View { NativeEditorCalloutIcon(presentation: presentation, customIcon: callout.icon) .padding(.top, 1) - Text(callout.previewText.isEmpty ? "Callout" : callout.previewText) - .font(.body) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) + Group { + if let actions { + NativeEditorNestedDocumentView( + blockID: blockID, + target: .callout, + content: content, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: presenceScope + ) { updatedContent in + actions.updateNestedContent(blockID, .callout, updatedContent) + } + } else { + NativeEditorNestedDocumentPreview( + content: content, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } if let actions { @@ -99,13 +123,35 @@ struct NativeEditorCalloutBlockView: View { struct NativeEditorDetailsBlockView: View { let blockID: UUID let details: NativeEditorDetailsBlock + let bodyContent: [ProseMirrorNode] let actions: NativeEditorRichBlockEditingActions? + let pageID: String + let spaceID: String? + let serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var presenceScope: [NativeEditorRemotePresenceScope] = [] @State private var transientIsOpen: Bool - init(blockID: UUID, details: NativeEditorDetailsBlock, actions: NativeEditorRichBlockEditingActions?) { + init( + blockID: UUID, + details: NativeEditorDetailsBlock, + bodyContent: [ProseMirrorNode], + actions: NativeEditorRichBlockEditingActions?, + pageID: String, + spaceID: String?, + serverURLString: String?, + presenceProjection: NativeEditorRemotePresenceProjection? = nil, + presenceScope: [NativeEditorRemotePresenceScope] = [] + ) { self.blockID = blockID self.details = details + self.bodyContent = bodyContent self.actions = actions + self.pageID = pageID + self.spaceID = spaceID + self.serverURLString = serverURLString + self.presenceProjection = presenceProjection + self.presenceScope = presenceScope _transientIsOpen = State(initialValue: details.isOpen) } @@ -128,16 +174,27 @@ struct NativeEditorDetailsBlockView: View { .accessibilityLabel(isOpen ? "Collapse toggle block" : "Expand toggle block") if isOpen { - if details.previewText.isEmpty == false { - Text(details.previewText) - .font(.body) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - if let actions { NativeEditorDetailsEditor(blockID: blockID, details: details, actions: actions) .padding(.top, 2) + + NativeEditorNestedDocumentView( + blockID: blockID, + target: .detailsContent, + content: bodyContent, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: presenceScope + ) { updatedContent in + actions.updateNestedContent(blockID, .detailsContent, updatedContent) + } + } else { + NativeEditorNestedDocumentPreview( + content: bodyContent, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) } } } @@ -161,22 +218,31 @@ struct NativeEditorDetailsBlockView: View { struct NativeEditorColumnsBlockView: View { let blockID: UUID let columns: NativeEditorColumnsBlock + let columnNodes: [ProseMirrorNode] let actions: NativeEditorRichBlockEditingActions? + let pageID: String + let spaceID: String? + let serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var parentPresenceScope: [NativeEditorRemotePresenceScope] = [] + var presenceBlockIndex: Int? var body: some View { VStack(alignment: .leading, spacing: 10) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 10) { - ForEach(columns.normalizedColumnTexts.enumerated(), id: \.offset) { item in - NativeEditorColumnPreview(text: item.element) - .frame(minWidth: 180, maxWidth: .infinity, alignment: .topLeading) - } - } - - VStack(alignment: .leading, spacing: 10) { - ForEach(columns.normalizedColumnTexts.enumerated(), id: \.offset) { item in - NativeEditorColumnPreview(text: item.element) - } + NativeEditorResponsiveColumnsLayout(weights: columnWeights) { + ForEach(columnNodes.enumerated(), id: \.offset) { item in + NativeEditorColumnContent( + blockID: blockID, + index: item.offset, + content: item.element.content ?? [], + actions: actions, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + parentPresenceScope: parentPresenceScope, + presenceBlockIndex: presenceBlockIndex + ) } } @@ -192,18 +258,21 @@ struct NativeEditorColumnsBlockView: View { } -private struct NativeEditorColumnPreview: View { - let text: String - - var body: some View { - Text(text.isEmpty ? "Empty column" : text) - .font(.body) - .foregroundStyle(text.isEmpty ? .secondary : .primary) - .frame(maxWidth: .infinity, alignment: .leading) +private extension NativeEditorColumnsBlockView { + var columnWeights: [Double] { + NativeEditorColumnsLayoutPolicy.weights( + layout: columns.layout, + explicitWidths: columns.normalizedColumnWidths, + count: columnNodes.count + ) } } struct NativeEditorDiagramBlockView: View { + @Environment(AppState.self) private var appState + @State private var authorizedSourceURL: URL? + @State private var resourceCookies: [StoredHTTPCookie] = [] + let blockID: UUID let diagram: NativeEditorDiagramBlock let title: String @@ -214,16 +283,31 @@ struct NativeEditorDiagramBlockView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { if let sourceURL { - NativeEditorWebEmbedView( - html: NativeEditorWebEmbedHTML.imageHTML(source: sourceURL, title: displayTitle), - allowedHosts: Set([sourceURL.host()?.lowercased()].compactMap(\.self)) - ) + Group { + if authorizedSourceURL == sourceURL { + NativeEditorWebEmbedView( + html: NativeEditorWebEmbedHTML.imageHTML(source: sourceURL, title: displayTitle), + allowedHosts: Set([sourceURL.host()?.lowercased()].compactMap(\.self)), + cookies: resourceCookies + ) + } else { + ProgressView("Loading diagram") + .frame(maxWidth: .infinity) + } + } .frame(minHeight: 260) .clipShape(.rect(cornerRadius: 10)) .overlay { RoundedRectangle(cornerRadius: 10) .stroke(.quaternary, lineWidth: 1) } + .task(id: sourceURL) { + authorizedSourceURL = nil + let cookies = await appState.activeSessionCookies(for: sourceURL) + guard Task.isCancelled == false else { return } + resourceCookies = cookies + authorizedSourceURL = sourceURL + } } else { HStack(alignment: .top, spacing: 10) { Image(systemName: systemImage) diff --git a/docmostly/Features/Editor/NativeEditorBodyView.swift b/docmostly/Features/Editor/NativeEditorBodyView.swift index a479d6b..30162ae 100644 --- a/docmostly/Features/Editor/NativeEditorBodyView.swift +++ b/docmostly/Features/Editor/NativeEditorBodyView.swift @@ -7,22 +7,39 @@ struct NativeEditorBodyView: View { var serverURLString: String? var importAttachment: (NativeEditorAttachmentImportKind) -> Void = { _ in } var applyCommand: ((NativeEditorCommand) -> Void)? + var applyPendingRemoteUpdate: (() -> Void)? + var keepPendingLocalUpdate: (() -> Void)? + var slashCommandFilter: (NativeEditorCommand) -> Bool = { _ in true } + var showsTitle = true + var showsCollaborationStatus = true + var presenceProjection: NativeEditorRemotePresenceProjection? + var presenceScope: [NativeEditorRemotePresenceScope] = [] var body: some View { LazyVStack(alignment: .leading, spacing: 6) { - TextField("Page title", text: $viewModel.title, axis: .vertical) - .font(.largeTitle) - .bold() - .textFieldStyle(.plain) - .focused(focusedField, equals: .title) - .disabled(authoringIsAvailable == false) - .accessibilityLabel("Page title") + if showsTitle { + TextField("Page title", text: $viewModel.title, axis: .vertical) + .font(.largeTitle) + .bold() + .textFieldStyle(.plain) + .focused(focusedField, equals: .title) + .submitLabel(.next) + .onSubmit(advanceFromTitle) + .disabled(authoringIsAvailable == false) + .accessibilityLabel("Page title") + } if let saveErrorMessage = viewModel.saveErrorMessage { NativeEditorSaveErrorView(message: saveErrorMessage) } - NativeEditorCollaborationStatusView(viewModel: viewModel) + if showsCollaborationStatus { + NativeEditorCollaborationStatusView( + viewModel: viewModel, + applyPendingRemoteUpdate: applyPendingRemoteUpdate, + keepPendingLocalUpdate: keepPendingLocalUpdate + ) + } ForEach($viewModel.document.blocks) { $block in VStack(alignment: .leading, spacing: 6) { @@ -53,6 +70,10 @@ struct NativeEditorBodyView: View { pageID: viewModel.currentPageID, spaceID: viewModel.currentSpaceID, serverURLString: serverURLString, + remotePresenceSegments: remotePresenceSegments(for: block.id), + presenceProjection: activePresenceProjection, + presenceScope: presenceScope, + presenceBlockIndex: blockIndex(for: block.id), focusBlock: { guard authoringIsAvailable else { return } viewModel.focus(blockID: block.id) @@ -61,6 +82,18 @@ struct NativeEditorBodyView: View { guard authoringIsAvailable else { return } viewModel.moveBlock(movedBlockID, before: block.id) }, + splitBlock: { characterRange in + guard authoringIsAvailable else { return false } + return viewModel.splitBlock(block.id, replacing: characterRange) != nil + }, + insertHardBreak: { characterRange in + guard authoringIsAvailable else { return false } + return viewModel.insertSoftBreak(in: block.id, replacing: characterRange) + }, + mergeBlockBackward: { + guard authoringIsAvailable else { return false } + return viewModel.mergeBlockBackward(block.id) + }, blockChanged: { guard authoringIsAvailable else { return } viewModel.handleDocumentChanged() @@ -84,16 +117,12 @@ struct NativeEditorBodyView: View { NativeEditorSlashCommandMenu( viewModel: viewModel, importAttachment: importAttachment, - applyCommand: applyCommand + applyCommand: applyCommand, + commandFilter: slashCommandFilter ) .padding(.leading, 34) } - let remoteCursors = viewModel.resolvedCursorsForBlock(id: block.id) - if remoteCursors.isEmpty == false { - NativeEditorRemoteCursorBadgeStack(cursors: remoteCursors) - .padding(.leading, 34) - } } } @@ -113,7 +142,31 @@ struct NativeEditorBodyView: View { } private var authoringIsAvailable: Bool { - isAuthoringEnabled && viewModel.canEdit + isAuthoringEnabled && viewModel.canEdit && viewModel.isResolvingConflict == false + } + + private var activePresenceProjection: NativeEditorRemotePresenceProjection { + presenceProjection ?? viewModel.remotePresenceProjection + } + + private func blockIndex(for blockID: UUID) -> Int { + viewModel.document.blocks.firstIndex(where: { $0.id == blockID }) ?? 0 + } + + private func remotePresenceSegments(for blockID: UUID) -> [NativeEditorRemotePresenceSegment] { + activePresenceProjection.segments( + scope: presenceScope, + blockIndex: blockIndex(for: blockID) + ) + } + + private func advanceFromTitle() { + guard authoringIsAvailable else { return } + if let firstEditableBlock = viewModel.document.blocks.first(where: \.isEditable) { + viewModel.focus(blockID: firstEditableBlock.id) + } else { + viewModel.appendBlock() + } } private var tableEditingActions: NativeEditorTableEditingActions { @@ -141,6 +194,9 @@ struct NativeEditorBodyView: View { updateCallout: viewModel.updateCallout, updateDetails: viewModel.updateDetails, updateColumns: viewModel.updateColumns, + updateNestedContent: viewModel.updateNestedContent, + setColumnCount: viewModel.setColumnCount, + updateColumnWidth: viewModel.updateColumnWidth, updateTransclusionSource: viewModel.updateTransclusionSource, updateTransclusionReference: viewModel.updateTransclusionReference, updateMediaBlock: viewModel.updateMediaBlock, diff --git a/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift b/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift index dd2613d..23411f6 100644 --- a/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift +++ b/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift @@ -10,7 +10,7 @@ nonisolated struct NativeEditorCRDTSaveResult: Equatable, Sendable { } } -nonisolated struct NativeEditorCRDTDocumentSnapshot: Sendable { +nonisolated struct NativeEditorCRDTDocumentSnapshot: Equatable, Sendable { let title: String? let document: NativeEditorDocument let updatedAt: Date? @@ -28,9 +28,11 @@ nonisolated struct NativeEditorCRDTLocalChange: Sendable { } protocol NativeEditorCRDTDocumentEngine: AnyObject, Sendable { + var requiresInitialRemoteSnapshot: Bool { get } func encodeStateVector() async throws -> Data func encodeStateAsUpdate(for stateVector: Data) async throws -> Data func applyRemoteUpdate(_ update: Data) async throws + func applyRemoteUpdateCapturingSnapshot(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws func resolveRemoteCursor(_ cursor: NativeEditorRemoteCursor) async throws -> NativeEditorResolvedRemoteCursor? func encodeLocalAwarenessCursor(for selection: NativeEditorLocalTextSelection) async throws @@ -53,6 +55,15 @@ protocol NativeEditorCRDTDocumentEngineFactory: AnyObject { } extension NativeEditorCRDTDocumentEngine { + var requiresInitialRemoteSnapshot: Bool { + false + } + + func applyRemoteUpdateCapturingSnapshot(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? { + try await applyRemoteUpdate(update) + return nil + } + func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { } func resolveRemoteCursor(_ cursor: NativeEditorRemoteCursor) async throws -> NativeEditorResolvedRemoteCursor? { diff --git a/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift b/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift index 6046601..a78acb7 100644 --- a/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift +++ b/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift @@ -9,11 +9,16 @@ actor NativeEditorCRDTSyncCoordinator { nonisolated static let maximumRemoteSyncSessionBytes = 10_000_000 private let documentEngine: any NativeEditorCRDTDocumentEngine + private let remoteUpdateHandler: (@Sendable (Data) async throws -> Void)? private var pendingLocalEchoCounts: [Data: Int] = [:] private var remoteSyncSessionBytes = 0 - init(documentEngine: any NativeEditorCRDTDocumentEngine) { + init( + documentEngine: any NativeEditorCRDTDocumentEngine, + remoteUpdateHandler: (@Sendable (Data) async throws -> Void)? = nil + ) { self.documentEngine = documentEngine + self.remoteUpdateHandler = remoteUpdateHandler } func makeInitialSyncMessage() async throws -> NativeEditorYjsSyncMessage { @@ -30,7 +35,11 @@ actor NativeEditorCRDTSyncCoordinator { try validateRemotePayload(update) try recordRemotePayload(update) guard consumeLocalEcho(for: update) == false else { return [] } - try await documentEngine.applyRemoteUpdate(update) + if let remoteUpdateHandler { + try await remoteUpdateHandler(update) + } else { + try await documentEngine.applyRemoteUpdate(update) + } return [] } } @@ -49,6 +58,17 @@ actor NativeEditorCRDTSyncCoordinator { await documentEngine.localUpdates() } + func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { + try await documentEngine.integrateLocalChange(change) + } + + func flushPendingLocalChanges( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTSaveResult { + try await documentEngine.flushPendingLocalChanges(title: title, document: document) + } + private func consumeLocalEcho(for update: Data) -> Bool { guard let count = pendingLocalEchoCounts[update] else { return false } diff --git a/docmostly/Features/Editor/NativeEditorCollaboration.swift b/docmostly/Features/Editor/NativeEditorCollaboration.swift index 18443e0..a5f6c6c 100644 --- a/docmostly/Features/Editor/NativeEditorCollaboration.swift +++ b/docmostly/Features/Editor/NativeEditorCollaboration.swift @@ -22,14 +22,53 @@ nonisolated enum NativeEditorCollaborationEvent: Equatable, Sendable { case syncStatus(Bool) } +nonisolated enum NativeEditorCollaborationParticipation: Equatable, Sendable { + case interactive + case receiveOnly + + var allowsLocalDocumentUpdates: Bool { + self == .interactive + } + + var allowsLocalAwarenessUpdates: Bool { + self == .interactive + } +} + nonisolated struct NativeEditorCollaborationSessionContext: Sendable { let url: URL let token: String let documentName: String + let participation: NativeEditorCollaborationParticipation let user: DocmostUser? let syncDriver: NativeEditorCollaborationSyncDriver? let localAwarenessCursor: (@Sendable () async -> NativeEditorAwarenessCursor?)? let localAwarenessUpdates: AsyncStream? + + func allowsInitialDocumentSync(for scope: NativeEditorCollaborationScope) -> Bool { + scope.allowsInitialDocumentSync + } + + func allowsLocalAwarenessUpdates(for scope: NativeEditorCollaborationScope) -> Bool { + participation.allowsLocalAwarenessUpdates && scope.allowsLocalAwarenessUpdates + } + + func allowsLocalDocumentUpdates(for scope: NativeEditorCollaborationScope) -> Bool { + participation.allowsLocalDocumentUpdates && scope.allowsLocalDocumentUpdates + } + + func allowsSyncReply( + to message: NativeEditorYjsSyncMessage, + authenticatedScope: NativeEditorCollaborationScope? + ) -> Bool { + guard let authenticatedScope else { return false } + switch message { + case .stepOne: + return participation.allowsLocalDocumentUpdates && authenticatedScope.allowsSyncReply(to: message) + case .stepTwo, .update: + return authenticatedScope.allowsSyncReply(to: message) + } + } } enum NativeEditorCollaboratorSource: Equatable, Sendable { @@ -67,7 +106,7 @@ struct NativeEditorCollaborator: Equatable, Identifiable, Sendable { let identifier = user?.id ?? "client-\(awarenessState.clientID)" id = identifier name = user?.name ?? "Someone" - colorName = user?.color ?? Self.colorName(for: identifier) + colorName = user?.color ?? NativeEditorPresenceColor.color(for: identifier) source = .presence } @@ -99,7 +138,7 @@ enum NativeEditorPresenceStatusText { nonisolated enum NativeEditorPresenceColor { static func color(for identifier: String) -> String { - let palette = ["#6B7280", "#2563EB", "#059669", "#EA580C", "#7C3AED"] + let palette = ["#958DF1", "#F98181", "#FBBC88", "#FAF594", "#70CFF8", "#94FADB", "#B9F18D"] let index = stableHash(for: identifier) % palette.count return palette[index] } @@ -127,13 +166,20 @@ nonisolated struct NativeEditorAwarenessStateStore: Sendable { receivedAt: Date = .now ) -> [NativeEditorAwarenessState] { for state in updates { - if let latestClock = latestClockByClientID[state.clientID], state.clock <= latestClock { - if state.clock == latestClock, - state.payload != nil, - statesByClientID[state.clientID] != nil { - lastSeenByClientID[state.clientID] = receivedAt + if let latestClock = latestClockByClientID[state.clientID] { + if state.clock < latestClock { + continue + } + + if state.clock == latestClock { + if state.payload == nil, statesByClientID[state.clientID] != nil { + statesByClientID.removeValue(forKey: state.clientID) + lastSeenByClientID.removeValue(forKey: state.clientID) + } else if state.payload != nil, statesByClientID[state.clientID] != nil { + lastSeenByClientID[state.clientID] = receivedAt + } + continue } - continue } latestClockByClientID[state.clientID] = state.clock diff --git a/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift b/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift index 76168a0..93ed232 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift @@ -26,6 +26,7 @@ actor NativeEditorCollaborationPresenceClient { url: URL, token: String, documentName: String, + participation: NativeEditorCollaborationParticipation = .interactive, user: DocmostUser?, syncDriver: NativeEditorCollaborationSyncDriver? = nil, localAwarenessCursor: (@Sendable () async -> NativeEditorAwarenessCursor?)? = nil, @@ -38,6 +39,7 @@ actor NativeEditorCollaborationPresenceClient { url: url, token: token, documentName: documentName, + participation: participation, user: user, syncDriver: syncDriver, localAwarenessCursor: localAwarenessCursor, @@ -152,8 +154,7 @@ actor NativeEditorCollaborationPresenceClient { case .authenticationFailed(let reason): throw NativeEditorCollabAuthFailure(reason: reason) case .queryAwareness: - guard authenticatedScope?.allowsLocalAwarenessUpdates == true else { return } - try await sendLocalAwareness(context: context) + try await sendLocalAwarenessIfPermitted(context: context) case .awareness(let states): let currentStates = awarenessStore.apply(states) continuation.yield(.awareness(states: currentStates, localClientID: localClientID)) @@ -164,13 +165,20 @@ actor NativeEditorCollaborationPresenceClient { case .sync(let syncMessage): try await sendCRDTSyncReply( for: syncMessage, - authenticatedScope: authenticatedScope, - using: context.syncDriver + context: context ) case .close(let reason): throw APIError.connectionFailed(reason) } } + + private func sendLocalAwarenessIfPermitted( + context: NativeEditorCollaborationSessionContext + ) async throws { + guard let authenticatedScope else { return } + guard context.allowsLocalAwarenessUpdates(for: authenticatedScope) else { return } + try await sendLocalAwareness(context: context) + } } private extension NativeEditorCollaborationPresenceClient { @@ -189,17 +197,17 @@ private extension NativeEditorCollaborationPresenceClient { ) async throws { authenticatedScope = scope try await sendInitialCRDTSync(for: scope, using: context.syncDriver) - configureLocalDocumentUpdates(for: scope, syncDriver: context.syncDriver) + configureLocalDocumentUpdates(for: scope, context: context) try await configureLocalAwarenessUpdates(for: scope, context: context) continuation.yield(.authenticated(scope)) } func configureLocalDocumentUpdates( for scope: NativeEditorCollaborationScope, - syncDriver: NativeEditorCollaborationSyncDriver? + context: NativeEditorCollaborationSessionContext ) { - if scope.allowsLocalDocumentUpdates { - startLocalUpdateSender(using: syncDriver) + if context.allowsLocalDocumentUpdates(for: scope) { + startLocalUpdateSender(using: context.syncDriver) } else { stopLocalUpdateSender() } @@ -209,7 +217,7 @@ private extension NativeEditorCollaborationPresenceClient { for scope: NativeEditorCollaborationScope, context: NativeEditorCollaborationSessionContext ) async throws { - if scope.allowsLocalAwarenessUpdates { + if context.allowsLocalAwarenessUpdates(for: scope) { try await sendLocalAwareness(context: context) startHeartbeat(context: context) startLocalAwarenessUpdateSender(context: context) @@ -240,11 +248,11 @@ private extension NativeEditorCollaborationPresenceClient { func sendCRDTSyncReply( for message: NativeEditorYjsSyncMessage, - authenticatedScope: NativeEditorCollaborationScope?, - using syncDriver: NativeEditorCollaborationSyncDriver? + context: NativeEditorCollaborationSessionContext ) async throws { + let syncDriver = context.syncDriver guard let syncDriver else { return } - guard authenticatedScope?.allowsSyncReply(to: message) ?? false else { return } + guard context.allowsSyncReply(to: message, authenticatedScope: authenticatedScope) else { return } let frames = try await syncDriver.outboundFrames(for: message) try await send(frames) } diff --git a/docmostly/Features/Editor/NativeEditorCollaborationSession.swift b/docmostly/Features/Editor/NativeEditorCollaborationSession.swift index 97ce8dd..08c77d2 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationSession.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationSession.swift @@ -2,6 +2,7 @@ import Foundation struct NativeEditorCollaborationSession: Sendable { let document: NativeEditorCollaborationDocument + let participation: NativeEditorCollaborationParticipation let syncDriver: NativeEditorCollaborationSyncDriver? let localAwarenessCursor: (@Sendable () async -> NativeEditorAwarenessCursor?)? let localAwarenessUpdates: AsyncStream? diff --git a/docmostly/Features/Editor/NativeEditorCollaborationStatusPresentation.swift b/docmostly/Features/Editor/NativeEditorCollaborationStatusPresentation.swift index e90dbe3..8fb3e44 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationStatusPresentation.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationStatusPresentation.swift @@ -3,15 +3,11 @@ import Foundation struct NativeEditorCollabStatusPresentation { let realtimeStatus: NativeEditorRealtimeStatus let canEdit: Bool - let activeCollaborators: [NativeEditorCollaborator] let pendingRemoteUpdate: NativeEditorRemoteUpdate? var title: String { switch realtimeStatus { case .connected: - if let editingTitle = NativeEditorPresenceStatusText.editingTitle(for: presenceCollaborators) { - return editingTitle - } return canEdit ? "Live" : "Read-only" case .connecting: return "Reconnecting" @@ -46,23 +42,13 @@ struct NativeEditorCollabStatusPresentation { var isVisible: Bool { switch realtimeStatus { case .disconnected: - hasCollaborators || pendingRemoteUpdate != nil || canEdit == false + pendingRemoteUpdate != nil || canEdit == false case .connected: - hasCollaborators || pendingRemoteUpdate != nil || canEdit == false - case .connecting, .conflict, .authenticationFailed, .failed: + pendingRemoteUpdate != nil || canEdit == false + case .connecting: + false + case .conflict, .authenticationFailed, .failed: true } } - - var presenceCollaborators: [NativeEditorCollaborator] { - activeCollaborators.filter { $0.source == .presence } - } - - var recentEditors: [NativeEditorCollaborator] { - activeCollaborators.filter { $0.source == .recentEditor } - } - - private var hasCollaborators: Bool { - activeCollaborators.isEmpty == false - } } diff --git a/docmostly/Features/Editor/NativeEditorCollaborationStatusView.swift b/docmostly/Features/Editor/NativeEditorCollaborationStatusView.swift index 5ee38af..b1d45ff 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationStatusView.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationStatusView.swift @@ -2,6 +2,8 @@ import SwiftUI struct NativeEditorCollaborationStatusView: View { @Bindable var viewModel: NativeRichEditorViewModel + var applyPendingRemoteUpdate: (() -> Void)? + var keepPendingLocalUpdate: (() -> Void)? var body: some View { if presentation.isVisible { @@ -11,29 +13,27 @@ struct NativeEditorCollaborationStatusView: View { .foregroundStyle(statusStyle) if let pendingRemoteUpdate = viewModel.pendingRemoteUpdate { - VStack(alignment: .leading, spacing: 2) { - Text(pendingRemoteUpdate.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - - if let lastUpdatedBy = pendingRemoteUpdate.lastUpdatedBy { - Text("Edited by \(lastUpdatedBy.name)") - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } + Text(pendingRemoteUpdate.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) Spacer(minLength: 0) - Button("Apply", systemImage: "arrow.down.doc", action: viewModel.acceptPendingRemoteUpdate) - Button("Keep Mine", systemImage: "xmark", action: viewModel.rejectPendingRemoteUpdate) - } else if presentation.presenceCollaborators.isEmpty == false { - Spacer(minLength: 0) - } else if presentation.recentEditors.isEmpty == false { - collaboratorNames - Spacer(minLength: 0) + Button("Apply", systemImage: "arrow.down.doc") { + if let applyPendingRemoteUpdate { + applyPendingRemoteUpdate() + } else { + viewModel.acceptPendingRemoteUpdate() + } + } + Button("Keep Mine", systemImage: "xmark") { + if let keepPendingLocalUpdate { + keepPendingLocalUpdate() + } else { + viewModel.rejectPendingRemoteUpdate() + } + } } } .padding(.horizontal) @@ -43,18 +43,10 @@ struct NativeEditorCollaborationStatusView: View { } } - private var collaboratorNames: some View { - Text(presentation.recentEditors.map(\.name).joined(separator: ", ")) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - private var presentation: NativeEditorCollabStatusPresentation { NativeEditorCollabStatusPresentation( realtimeStatus: viewModel.realtimeStatus, canEdit: viewModel.canEdit, - activeCollaborators: viewModel.activeCollaborators, pendingRemoteUpdate: viewModel.pendingRemoteUpdate ) } diff --git a/docmostly/Features/Editor/NativeEditorCollaboratorSurface.swift b/docmostly/Features/Editor/NativeEditorCollaboratorSurface.swift new file mode 100644 index 0000000..066b7cd --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorCollaboratorSurface.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct NativeEditorCollaboratorSurface: View { + let collaborators: [NativeEditorCollaborator] + let reveal: ((String) -> Void)? + + var body: some View { + HStack(spacing: 4) { + ForEach(collaborators) { collaborator in + if let reveal { + Button { + reveal(collaborator.id) + } label: { + NativeEditorCollaboratorLabel(collaborator: collaborator) + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to \(collaborator.name)'s caret") + .help("Jump to \(collaborator.name)") + } else { + NativeEditorCollaboratorLabel(collaborator: collaborator) + .accessibilityLabel("\(collaborator.name) is editing") + } + } + } + .accessibilityElement(children: .contain) + } +} + +private struct NativeEditorCollaboratorLabel: View { + let collaborator: NativeEditorCollaborator + + var body: some View { + HStack(spacing: 4) { + Text(initials) + .font(.caption2) + .bold() + .foregroundStyle(collaboratorForeground) + .frame(width: 24, height: 24) + .background(collaboratorColor, in: .circle) + .overlay { + Circle() + .stroke(.primary.opacity(0.35), lineWidth: 1) + } + + Text(collaborator.name) + .font(.caption) + .lineLimit(1) + } + .padding(.trailing, 6) + .contentShape(.capsule) + } + + private var collaboratorColor: Color { + Color(docmostlyHex: collaborator.colorName) ?? .secondary + } + + private var collaboratorForeground: Color { + var hex = collaborator.colorName.trimmingCharacters(in: .whitespacesAndNewlines) + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6, let rawValue = Int(hex, radix: 16) else { return .primary } + + let red = Double((rawValue >> 16) & 0xff) / 255 + let green = Double((rawValue >> 8) & 0xff) / 255 + let blue = Double(rawValue & 0xff) / 255 + let luminance = (0.2126 * red) + (0.7152 * green) + (0.0722 * blue) + return luminance > 0.55 ? .black : .white + } + + private var initials: String { + let words = collaborator.name.split(whereSeparator: \.isWhitespace) + let characters = words.prefix(2).compactMap(\.first) + return characters.isEmpty ? "?" : String(characters).uppercased() + } +} diff --git a/docmostly/Features/Editor/NativeEditorColumnContent.swift b/docmostly/Features/Editor/NativeEditorColumnContent.swift new file mode 100644 index 0000000..5a9cb84 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorColumnContent.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct NativeEditorColumnContent: View { + let blockID: UUID + let index: Int + let content: [ProseMirrorNode] + let actions: NativeEditorRichBlockEditingActions? + let pageID: String + let spaceID: String? + let serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var parentPresenceScope: [NativeEditorRemotePresenceScope] = [] + var presenceBlockIndex: Int? + + var body: some View { + Group { + if let actions { + NativeEditorNestedDocumentView( + blockID: blockID, + target: .column(index: index), + content: content, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: columnPresenceScope + ) { updatedContent in + actions.updateNestedContent(blockID, .column(index: index), updatedContent) + } + } else { + NativeEditorNestedDocumentPreview( + content: content, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + } + } + .padding(10) + .frame(minHeight: 56) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.12), in: .rect(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary, lineWidth: 1) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Column \(index + 1)") + } + + private var columnPresenceScope: [NativeEditorRemotePresenceScope] { + guard let presenceBlockIndex else { return parentPresenceScope } + return parentPresenceScope + [NativeEditorRemotePresenceScope( + containerBlockIndex: presenceBlockIndex, + target: .column(index: index) + )] + } +} diff --git a/docmostly/Features/Editor/NativeEditorColumnsLayout.swift b/docmostly/Features/Editor/NativeEditorColumnsLayout.swift new file mode 100644 index 0000000..9587b97 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorColumnsLayout.swift @@ -0,0 +1,105 @@ +import SwiftUI + +nonisolated enum NativeEditorColumnsLayoutPolicy { + static func weights(layout: String, explicitWidths: [Double?], count: Int) -> [Double] { + guard count > 0 else { return [] } + let preset = presetWeights(layout: layout, count: count) + return (0.. 0 else { + return preset[index] + } + return min(max(width, 0.25), 4) + } + } + + static func shouldStack(availableWidth: CGFloat, count: Int) -> Bool { + availableWidth < CGFloat(count) * 190 + } + + private static func presetWeights(layout: String, count: Int) -> [Double] { + let weights: [Double] + switch layout { + case "two_left_sidebar": + weights = [0.65, 1.35] + case "two_right_sidebar": + weights = [1.35, 0.65] + case "three_left_wide": + weights = [1.6, 0.7, 0.7] + case "three_right_wide": + weights = [0.7, 0.7, 1.6] + case "three_with_sidebars": + weights = [0.7, 1.6, 0.7] + default: + weights = Array(repeating: 1, count: count) + } + guard weights.count == count else { return Array(repeating: 1, count: count) } + return weights + } +} + +struct NativeEditorResponsiveColumnsLayout: Layout { + let weights: [Double] + var spacing: CGFloat = 10 + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + guard subviews.isEmpty == false else { return .zero } + let availableWidth = proposal.width ?? 0 + if NativeEditorColumnsLayoutPolicy.shouldStack(availableWidth: availableWidth, count: subviews.count) { + let sizes = subviews.map { $0.sizeThatFits(.init(width: availableWidth, height: nil)) } + return CGSize( + width: sizes.map(\.width).max() ?? availableWidth, + height: sizes.map(\.height).reduce(0, +) + spacing * CGFloat(max(subviews.count - 1, 0)) + ) + } + + let widths = resolvedWidths(availableWidth: availableWidth, count: subviews.count) + let sizes = zip(subviews, widths).map { subview, width in + subview.sizeThatFits(.init(width: width, height: nil)) + } + return CGSize(width: availableWidth, height: sizes.map(\.height).max() ?? 0) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + guard subviews.isEmpty == false else { return } + if NativeEditorColumnsLayoutPolicy.shouldStack(availableWidth: bounds.width, count: subviews.count) { + var verticalOffset = bounds.minY + for subview in subviews { + let size = subview.sizeThatFits(.init(width: bounds.width, height: nil)) + subview.place( + at: CGPoint(x: bounds.minX, y: verticalOffset), + anchor: .topLeading, + proposal: .init(width: bounds.width, height: size.height) + ) + verticalOffset += size.height + spacing + } + return + } + + let widths = resolvedWidths(availableWidth: bounds.width, count: subviews.count) + var horizontalOffset = bounds.minX + for (subview, width) in zip(subviews, widths) { + subview.place( + at: CGPoint(x: horizontalOffset, y: bounds.minY), + anchor: .topLeading, + proposal: .init(width: width, height: bounds.height) + ) + horizontalOffset += width + spacing + } + } + + private func resolvedWidths(availableWidth: CGFloat, count: Int) -> [CGFloat] { + let availableContentWidth = max(availableWidth - spacing * CGFloat(max(count - 1, 0)), 0) + let resolvedWeights = weights.count == count ? weights : Array(repeating: 1, count: count) + let totalWeight = max(resolvedWeights.reduce(0, +), 1) + return resolvedWeights.map { availableContentWidth * CGFloat($0 / totalWeight) } + } +} diff --git a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift index b2a585c..91e7eea 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift @@ -19,8 +19,6 @@ extension NativeEditorCommand { .pdf, .fileAttachment, .table, - .baseInline, - .kanban, .details, .callout, .mathInline, diff --git a/docmostly/Features/Editor/NativeEditorCommand.swift b/docmostly/Features/Editor/NativeEditorCommand.swift index e027aa3..507ae00 100644 --- a/docmostly/Features/Editor/NativeEditorCommand.swift +++ b/docmostly/Features/Editor/NativeEditorCommand.swift @@ -71,8 +71,6 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { .pdf, .fileAttachment, .table, - .baseInline, - .kanban, .callout, .details, .pageBreak, diff --git a/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift index afea4b9..c235e62 100644 --- a/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift +++ b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift @@ -208,6 +208,7 @@ enum NativeEditorWebEmbedHTML { struct NativeEditorWebEmbedView: UIViewRepresentable { let html: String let allowedHosts: Set + var cookies: [StoredHTTPCookie] = [] func makeCoordinator() -> NativeEditorWebEmbedCoordinator { NativeEditorWebEmbedCoordinator(allowedHosts: allowedHosts) @@ -224,13 +225,14 @@ struct NativeEditorWebEmbedView: UIViewRepresentable { func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.allowedHosts = allowedHosts - context.coordinator.load(html: html, in: webView) + context.coordinator.load(html: html, cookies: cookies, in: webView) } } #elseif os(macOS) struct NativeEditorWebEmbedView: NSViewRepresentable { let html: String let allowedHosts: Set + var cookies: [StoredHTTPCookie] = [] func makeCoordinator() -> NativeEditorWebEmbedCoordinator { NativeEditorWebEmbedCoordinator(allowedHosts: allowedHosts) @@ -245,7 +247,7 @@ struct NativeEditorWebEmbedView: NSViewRepresentable { func updateNSView(_ webView: WKWebView, context: Context) { context.coordinator.allowedHosts = allowedHosts - context.coordinator.load(html: html, in: webView) + context.coordinator.load(html: html, cookies: cookies, in: webView) } } #endif @@ -253,16 +255,28 @@ struct NativeEditorWebEmbedView: NSViewRepresentable { final class NativeEditorWebEmbedCoordinator: NSObject, WKNavigationDelegate { var allowedHosts: Set private var loadedHTML: String? + private var loadedCookies: [StoredHTTPCookie] = [] + private var loadTask: Task? init(allowedHosts: Set) { self.allowedHosts = allowedHosts super.init() } - func load(html: String, in webView: WKWebView) { - guard loadedHTML != html else { return } + func load(html: String, cookies: [StoredHTTPCookie], in webView: WKWebView) { + guard loadedHTML != html || loadedCookies != cookies else { return } loadedHTML = html - webView.loadHTMLString(html, baseURL: nil) + loadedCookies = cookies + loadTask?.cancel() + loadTask = Task { @MainActor [weak webView] in + guard let webView else { return } + await CookieBridge.installInWebKit( + cookies, + store: webView.configuration.websiteDataStore.httpCookieStore + ) + guard Task.isCancelled == false else { return } + webView.loadHTMLString(html, baseURL: nil) + } } func webView( diff --git a/docmostly/Features/Editor/NativeEditorImageBlockView.swift b/docmostly/Features/Editor/NativeEditorImageBlockView.swift new file mode 100644 index 0000000..1c2477a --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorImageBlockView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct NativeEditorImageBlockView: View { + let blockID: UUID + let media: NativeEditorMediaBlock + let serverURLString: String? + let actions: NativeEditorRichBlockEditingActions? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Group { + if let sourceURL { + AsyncImage(url: sourceURL) { phase in + switch phase { + case .empty: + ProgressView("Loading image") + .frame(maxWidth: .infinity, minHeight: 120) + case .success(let image): + image + .resizable() + .scaledToFit() + case .failure: + unavailableImage + @unknown default: + unavailableImage + } + } + .containerRelativeFrame(.horizontal) { length, _ in + resolvedWidth(containerWidth: length) + } + .frame(maxWidth: .infinity, alignment: frameAlignment) + .clipShape(.rect(cornerRadius: 8)) + } else { + unavailableImage + } + } + .frame(maxWidth: .infinity, alignment: frameAlignment) + + if let actions { + NativeEditorMediaBlockEditor(blockID: blockID, media: media, actions: actions) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(media.alternativeText ?? "Image") + } + + private var unavailableImage: some View { + Label("Image unavailable", systemImage: "photo") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + .background(.quaternary.opacity(0.12), in: .rect(cornerRadius: 8)) + } + + private var sourceURL: URL? { + NativeEditorWebURLPolicy.documentResourceURL( + from: media.source, + serverURLString: serverURLString + ) + } + + private var frameAlignment: Alignment { + switch media.alignment { + case "left": .leading + case "right": .trailing + default: .center + } + } + + private func resolvedWidth(containerWidth: CGFloat) -> CGFloat { + guard let width = media.width?.trimmingCharacters(in: .whitespacesAndNewlines), width.isEmpty == false else { + return containerWidth + } + if width.hasSuffix("%"), let percentage = Double(width.dropLast()) { + return containerWidth * CGFloat(min(max(percentage, 1), 100) / 100) + } + if let pixels = Double(width) { + return min(containerWidth, CGFloat(max(pixels, 44))) + } + return containerWidth + } +} diff --git a/docmostly/Features/Editor/NativeEditorInlineCommentComposerView.swift b/docmostly/Features/Editor/NativeEditorInlineCommentComposerView.swift index 8ce7dbd..70594bd 100644 --- a/docmostly/Features/Editor/NativeEditorInlineCommentComposerView.swift +++ b/docmostly/Features/Editor/NativeEditorInlineCommentComposerView.swift @@ -2,28 +2,29 @@ import SwiftUI struct NativeEditorInlineCommentComposerView: View { @Environment(\.dismiss) private var dismiss - @State private var draft = "" + @State private var draft = CommentComposerState() @State private var isSubmitting = false @State private var errorMessage: String? let selectedText: String - let submit: (String) async throws -> Void + let submit: (CommentBody) async throws -> Void var body: some View { NavigationStack { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading) { NativeEditorInlineCommentSelectionView(text: selectedText) - TextEditor(text: $draft) - .scrollContentBackground(.hidden) - .frame(minHeight: 132) - .padding(10) - .background(.background, in: .rect(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(.quaternary) - } - .accessibilityLabel("Comment") + CommentRichComposerView( + draft: draft, + placeholder: "Add an inline comment", + submitTitle: "Add", + accessibilityIdentifier: "inline-comment-field", + isEnabled: true, + isSubmitting: isSubmitting, + autofocus: true, + submit: addComment, + cancel: dismiss.callAsFunction + ) if let errorMessage { Text(errorMessage) @@ -39,24 +40,13 @@ struct NativeEditorInlineCommentComposerView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel", action: dismiss.callAsFunction) - .disabled(isSubmitting) - } - - ToolbarItem(placement: .confirmationAction) { - Button("Add", systemImage: "checkmark", action: addComment) - .disabled(isSubmitting || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } } .presentationDetents([.medium, .large]) } private func addComment() { - let commentText = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard commentText.isEmpty == false else { return } + guard draft.isEmpty == false else { return } + let body = draft.body Task { isSubmitting = true @@ -64,7 +54,7 @@ struct NativeEditorInlineCommentComposerView: View { defer { isSubmitting = false } do { - try await submit(commentText) + try await submit(body) dismiss() } catch { errorMessage = error.localizedDescription diff --git a/docmostly/Features/Editor/NativeEditorJSCRDTDocumentEngine+Selections.swift b/docmostly/Features/Editor/NativeEditorJSCRDTDocumentEngine+Selections.swift index fa8b62a..444f8af 100644 --- a/docmostly/Features/Editor/NativeEditorJSCRDTDocumentEngine+Selections.swift +++ b/docmostly/Features/Editor/NativeEditorJSCRDTDocumentEngine+Selections.swift @@ -3,13 +3,13 @@ import Foundation extension NativeEditorJSCRDTDocumentEngine { func resolveRemoteCursor(_ cursor: NativeEditorRemoteCursor) async throws -> NativeEditorResolvedRemoteCursor? { - let cursor = try optionalRuntimeResult( + let resolvedCursor = try optionalRuntimeResult( function: "resolveRemoteCursor", payload: RuntimeRemoteCursor(cursor: cursor), as: RuntimeResolvedRemoteCursor.self ) - return cursor?.nativeCursor + return resolvedCursor?.nativeCursor(collaboratorID: cursor.collaboratorID) } func encodeLocalAwarenessCursor(for selection: NativeEditorLocalTextSelection) async throws @@ -93,9 +93,10 @@ private struct RuntimeResolvedRemoteCursor: Decodable { let anchor: RuntimeTextPosition let head: RuntimeTextPosition - var nativeCursor: NativeEditorResolvedRemoteCursor { + func nativeCursor(collaboratorID: String) -> NativeEditorResolvedRemoteCursor { NativeEditorResolvedRemoteCursor( id: id, + collaboratorID: collaboratorID, name: name, colorName: colorName, anchor: anchor.nativePosition, diff --git a/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift b/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift index aefe992..7b96120 100644 --- a/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift +++ b/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift @@ -38,6 +38,7 @@ nonisolated enum NativeEditorJSCRDTEngineError: Error, LocalizedError, Equatable @MainActor final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { + nonisolated let requiresInitialRemoteSnapshot = true private let context: JSContext private let runtimeDocument: JSValue private let localUpdateStream: AsyncStream @@ -114,9 +115,19 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { } func applyRemoteUpdate(_ update: Data) async throws { - try Self.validateRemotePayload(update) - _ = try callRequired("applyRemoteUpdate", arguments: [update.base64EncodedString()]) - try drainRuntimeOutputs() + for snapshot in try applyRemoteUpdateCapturingSnapshots(update) { + snapshotContinuation.yield(snapshot) + } + } + + func applyRemoteUpdateCapturingSnapshot(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? { + try applyRemoteUpdateAndCaptureSnapshotSynchronously(update) + } + + func applyRemoteUpdateAndCaptureSnapshotSynchronously( + _ update: Data + ) throws -> NativeEditorCRDTDocumentSnapshot? { + try applyRemoteUpdateCapturingSnapshots(update).last } func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { @@ -158,7 +169,18 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { private func drainRuntimeOutputs() throws { try drainLocalUpdates() - try drainDocumentSnapshots() + for snapshot in try takeDocumentSnapshots() { + snapshotContinuation.yield(snapshot) + } + } + + private func applyRemoteUpdateCapturingSnapshots( + _ update: Data + ) throws -> [NativeEditorCRDTDocumentSnapshot] { + try Self.validateRemotePayload(update) + _ = try callRequired("applyRemoteUpdate", arguments: [update.base64EncodedString()]) + try drainLocalUpdates() + return try takeDocumentSnapshots() } private func drainLocalUpdates() throws { @@ -173,17 +195,14 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { } } - private func drainDocumentSnapshots() throws { - guard let value = try callOptional("drainDocumentSnapshots") else { return } + private func takeDocumentSnapshots() throws -> [NativeEditorCRDTDocumentSnapshot] { + guard let value = try callOptional("drainDocumentSnapshots") else { return [] } let snapshots = try decode( [NativeEditorJSCRDTRuntimeSnapshot].self, from: value, function: "drainDocumentSnapshots" ) - - for snapshot in snapshots { - snapshotContinuation.yield(try snapshot.crdtSnapshot()) - } + return try snapshots.map { try $0.crdtSnapshot() } } private func callRequired(_ name: String, arguments: [Any] = []) throws -> JSValue { diff --git a/docmostly/Features/Editor/NativeEditorLocalTextSelection.swift b/docmostly/Features/Editor/NativeEditorLocalTextSelection.swift index 01af32b..75bc024 100644 --- a/docmostly/Features/Editor/NativeEditorLocalTextSelection.swift +++ b/docmostly/Features/Editor/NativeEditorLocalTextSelection.swift @@ -36,9 +36,10 @@ struct NativeEditorLocalTextSelection: Equatable, Sendable { index: AttributedString.Index, text: AttributedString ) -> NativeEditorRemoteTextPosition { - NativeEditorRemoteTextPosition( - blockIndex: blockIndex, - characterOffset: text.characters.distance(from: text.startIndex, to: index) - ) + let characterOffset = text.characters.distance(from: text.startIndex, to: index) + let plainText = String(text.characters) + let stringIndex = plainText.index(plainText.startIndex, offsetBy: characterOffset) + let utf16Offset = plainText[.. { - Binding { - media.alignment ?? "" - } set: { alignment in - update(alignment: alignment) - } + private var alignmentTitle: String { + (media.alignment ?? NativeEditorMediaBlock.defaultAlignment).capitalized } private func update( diff --git a/docmostly/Features/Editor/NativeEditorNestedContentTarget.swift b/docmostly/Features/Editor/NativeEditorNestedContentTarget.swift new file mode 100644 index 0000000..25b8ee1 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorNestedContentTarget.swift @@ -0,0 +1,19 @@ +import Foundation + +nonisolated enum NativeEditorNestedContentTarget: Equatable, Hashable, Sendable { + case callout + case detailsContent + case column(index: Int) + case transclusionSource +} + +nonisolated extension NativeEditorNestedContentTarget { + var permitsSyncedBlocks: Bool { + switch self { + case .transclusionSource: + false + case .callout, .detailsContent, .column: + true + } + } +} diff --git a/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift b/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift new file mode 100644 index 0000000..767ae36 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct NativeEditorNestedDocumentView: View { + @State private var viewModel: NativeRichEditorViewModel + @FocusState private var focusedField: NativeEditorFocus? + + let target: NativeEditorNestedContentTarget + let content: [ProseMirrorNode] + let serverURLString: String? + let presenceProjection: NativeEditorRemotePresenceProjection? + let presenceScope: [NativeEditorRemotePresenceScope] + let update: ([ProseMirrorNode]) -> Void + + init( + blockID: UUID, + target: NativeEditorNestedContentTarget, + content: [ProseMirrorNode], + serverURLString: String?, + presenceProjection: NativeEditorRemotePresenceProjection? = nil, + presenceScope: [NativeEditorRemotePresenceScope] = [], + update: @escaping ([ProseMirrorNode]) -> Void + ) { + let model = NativeRichEditorViewModel(pageID: "nested-\(blockID.uuidString)") + model.document = NativeEditorDocument( + proseMirrorDocument: ProseMirrorDocument(content: content) + ) + model.lastSavedDocument = model.document + self.target = target + self.content = content + self.serverURLString = serverURLString + self.presenceProjection = presenceProjection + self.presenceScope = presenceScope + self.update = update + _viewModel = State(initialValue: model) + } + + var body: some View { + NativeEditorBodyView( + viewModel: viewModel, + focusedField: $focusedField, + serverURLString: serverURLString, + slashCommandFilter: permitsCommand, + showsTitle: false, + showsCollaborationStatus: false, + presenceProjection: presenceProjection, + presenceScope: presenceScope + ) + .onChange(of: viewModel.document) { previousDocument, updatedDocument in + guard previousDocument != updatedDocument, + updatedDocument.proseMirrorDocument.content != content else { + return + } + update(updatedDocument.proseMirrorDocument.content) + } + .onChange(of: content) { _, updatedContent in + guard viewModel.document.proseMirrorDocument.content != updatedContent else { return } + let updatedDocument = NativeEditorDocument( + proseMirrorDocument: ProseMirrorDocument(content: updatedContent) + ) + viewModel.document = updatedDocument + viewModel.lastSavedDocument = updatedDocument + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Nested block content") + } + + private func permitsCommand(_ command: NativeEditorCommand) -> Bool { + guard target.permitsSyncedBlocks == false else { return true } + return switch command { + case .syncedBlock, .baseInline, .kanban: + false + default: + true + } + } +} + +struct NativeEditorNestedDocumentPreview: View { + let content: [ProseMirrorNode] + let pageID: String + let spaceID: String? + let serverURLString: String? + + var body: some View { + let document = NativeEditorDocument( + proseMirrorDocument: ProseMirrorDocument(content: content) + ) + + VStack(alignment: .leading, spacing: 6) { + ForEach(document.blocks) { block in + NativeEditorRichBlockPreviewView( + block: block, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/docmostly/Features/Editor/NativeEditorRemoteCursor.swift b/docmostly/Features/Editor/NativeEditorRemoteCursor.swift index 43aeb94..25fd958 100644 --- a/docmostly/Features/Editor/NativeEditorRemoteCursor.swift +++ b/docmostly/Features/Editor/NativeEditorRemoteCursor.swift @@ -1,13 +1,21 @@ import Foundation -struct NativeEditorRemoteCursor: Equatable, Identifiable, Sendable { +nonisolated struct NativeEditorRemoteCursor: Equatable, Identifiable, Sendable { let id: String + let collaboratorID: String let name: String let colorName: String let cursor: NativeEditorAwarenessCursor - init(id: String, name: String, colorName: String, cursor: NativeEditorAwarenessCursor) { + init( + id: String, + collaboratorID: String? = nil, + name: String, + colorName: String, + cursor: NativeEditorAwarenessCursor + ) { self.id = id + self.collaboratorID = collaboratorID ?? id self.name = name self.colorName = colorName self.cursor = cursor @@ -17,10 +25,11 @@ struct NativeEditorRemoteCursor: Equatable, Identifiable, Sendable { guard let cursor = awarenessState.cursor else { return nil } guard cursor.targetsDocmostDefaultFragment else { return nil } - let collaborator = NativeEditorCollaborator(awarenessState: awarenessState) - id = collaborator.id - name = collaborator.name - colorName = collaborator.colorName + let user = awarenessState.payload?.user + id = "client-\(awarenessState.clientID)" + collaboratorID = user?.id ?? id + name = user?.name ?? "Someone" + colorName = user?.color ?? NativeEditorPresenceColor.color(for: collaboratorID) self.cursor = cursor } } diff --git a/docmostly/Features/Editor/NativeEditorRemotePresenceProjection.swift b/docmostly/Features/Editor/NativeEditorRemotePresenceProjection.swift new file mode 100644 index 0000000..482577b --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorRemotePresenceProjection.swift @@ -0,0 +1,295 @@ +import Foundation + +nonisolated struct NativeEditorRemotePresenceScope: Equatable, Hashable, Sendable { + let containerBlockIndex: Int + let target: NativeEditorNestedContentTarget +} + +nonisolated struct NativeEditorRemotePresenceRoute: Equatable, Hashable, Sendable { + let scope: [NativeEditorRemotePresenceScope] + let blockIndex: Int +} + +nonisolated struct NativeEditorRemotePresenceSegment: Equatable, Identifiable, Sendable { + let cursorID: String + let collaboratorID: String + let name: String + let colorName: String + let characterRange: Range + let caretOffset: Int? + + var id: String { + "\(cursorID):\(characterRange.lowerBound):\(characterRange.upperBound):\(caretOffset ?? -1)" + } + + var accessibilityDescription: String { + if characterRange.isEmpty { + return "\(name) caret" + } + return "\(name) selection" + } +} + +nonisolated struct NativeEditorRemotePresenceProjection: Equatable, Sendable { + fileprivate struct TextBlockRoute: Equatable, Sendable { + let route: NativeEditorRemotePresenceRoute + let characterCount: Int + } + + private let segmentsByRoute: [NativeEditorRemotePresenceRoute: [NativeEditorRemotePresenceSegment]] + private let revealRouteByCollaboratorID: [String: NativeEditorRemotePresenceRoute] + + init(document: NativeEditorDocument, cursors: [NativeEditorResolvedRemoteCursor]) { + let routes = Self.textBlockRoutes(in: document) + var nextSegments: [NativeEditorRemotePresenceRoute: [NativeEditorRemotePresenceSegment]] = [:] + var nextRevealRoutes: [String: NativeEditorRemotePresenceRoute] = [:] + + for cursor in cursors { + let orderedStart = min(cursor.anchor, cursor.head) + let orderedEnd = max(cursor.anchor, cursor.head) + + guard orderedStart.blockIndex <= orderedEnd.blockIndex else { continue } + + for textBlockIndex in orderedStart.blockIndex...orderedEnd.blockIndex { + guard let routedBlock = routes[textBlockIndex] else { continue } + let lowerBound = textBlockIndex == orderedStart.blockIndex + ? min(max(orderedStart.characterOffset, 0), routedBlock.characterCount) + : 0 + let upperBound = textBlockIndex == orderedEnd.blockIndex + ? min(max(orderedEnd.characterOffset, 0), routedBlock.characterCount) + : routedBlock.characterCount + let caretOffset = textBlockIndex == cursor.head.blockIndex + ? min(max(cursor.head.characterOffset, 0), routedBlock.characterCount) + : nil + + guard lowerBound < upperBound || caretOffset != nil else { continue } + + nextSegments[routedBlock.route, default: []].append( + NativeEditorRemotePresenceSegment( + cursorID: cursor.id, + collaboratorID: cursor.collaboratorID, + name: cursor.name, + colorName: cursor.colorName, + characterRange: lowerBound.. [NativeEditorRemotePresenceSegment] { + segmentsByRoute[NativeEditorRemotePresenceRoute(scope: scope, blockIndex: blockIndex)] ?? [] + } + + func rootBlockIndex(for collaboratorID: String) -> Int? { + guard let route = revealRouteByCollaboratorID[collaboratorID] else { return nil } + return route.scope.first?.containerBlockIndex ?? route.blockIndex + } + + private static func textBlockRoutes(in document: NativeEditorDocument) -> [Int: TextBlockRoute] { + var builder = RouteBuilder() + builder.mapBody( + nodes: document.proseMirrorDocument.content, + scope: [] + ) + return builder.mappedRoutes + } +} + +nonisolated extension NativeEditorRemoteTextPosition: Comparable { + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.blockIndex != rhs.blockIndex { + return lhs.blockIndex < rhs.blockIndex + } + return lhs.characterOffset < rhs.characterOffset + } +} + +nonisolated private extension NativeEditorRemotePresenceProjection { + struct RouteBuilder { + private var routes: [Int: TextBlockRoute] = [:] + private var globalTextBlockIndex = 0 + + fileprivate var mappedRoutes: [Int: TextBlockRoute] { + routes + } + + mutating func mapBody( + nodes: [ProseMirrorNode], + scope: [NativeEditorRemotePresenceScope] + ) { + var localBlockIndex = 0 + + for node in nodes { + let representedBlockCount = NativeEditorDocument.blocks(from: node).count + mapTopLevelNode(node, localBlockIndex: localBlockIndex, scope: scope) + localBlockIndex += representedBlockCount + } + } + + private mutating func mapTopLevelNode( + _ node: ProseMirrorNode, + localBlockIndex: Int, + scope: [NativeEditorRemotePresenceScope] + ) { + switch node.type { + case "paragraph", "heading", "codeBlock": + mapTextBlock(node, route: NativeEditorRemotePresenceRoute(scope: scope, blockIndex: localBlockIndex)) + case "blockquote": + mapFirstTextBlock( + in: node, + route: NativeEditorRemotePresenceRoute(scope: scope, blockIndex: localBlockIndex) + ) + case "bulletList", "orderedList", "taskList": + var nextListBlockIndex = localBlockIndex + mapList(node, localBlockIndex: &nextListBlockIndex, scope: scope) + case "callout": + mapNestedBody( + node.content ?? [], + containerBlockIndex: localBlockIndex, + target: .callout, + scope: scope + ) + case "details": + if let detailsContent = node.content?.first(where: { $0.type == "detailsContent" }) { + mapNestedBody( + detailsContent.content ?? [], + containerBlockIndex: localBlockIndex, + target: .detailsContent, + scope: scope + ) + } + case "columns": + let columns = (node.content ?? []).filter { $0.type == "column" } + for (columnIndex, column) in columns.enumerated() { + mapNestedBody( + column.content ?? [], + containerBlockIndex: localBlockIndex, + target: .column(index: columnIndex), + scope: scope + ) + } + case "transclusionSource": + mapNestedBody( + node.content ?? [], + containerBlockIndex: localBlockIndex, + target: .transclusionSource, + scope: scope + ) + default: + countUnmappedTextBlocks(in: node) + } + } + + private mutating func mapNestedBody( + _ nodes: [ProseMirrorNode], + containerBlockIndex: Int, + target: NativeEditorNestedContentTarget, + scope: [NativeEditorRemotePresenceScope] + ) { + mapBody( + nodes: nodes, + scope: scope + [NativeEditorRemotePresenceScope( + containerBlockIndex: containerBlockIndex, + target: target + )] + ) + } + + private mutating func mapList( + _ list: ProseMirrorNode, + localBlockIndex: inout Int, + scope: [NativeEditorRemotePresenceScope] + ) { + for item in list.content ?? [] { + var mappedPrimaryTextBlock = false + + for child in item.content ?? [] { + if child.isListContainer { + mapList(child, localBlockIndex: &localBlockIndex, scope: scope) + } else if mappedPrimaryTextBlock == false, Self.isTextBlock(child) { + mapTextBlock( + child, + route: NativeEditorRemotePresenceRoute(scope: scope, blockIndex: localBlockIndex) + ) + localBlockIndex += 1 + mappedPrimaryTextBlock = true + } else { + countUnmappedTextBlocks(in: child) + } + } + } + } + + private mutating func mapFirstTextBlock( + in node: ProseMirrorNode, + route: NativeEditorRemotePresenceRoute + ) { + var mapped = false + for textBlock in Self.textBlocks(in: node) { + if mapped == false { + routes[globalTextBlockIndex] = TextBlockRoute( + route: route, + characterCount: textBlock.plainTextContent.utf16.count + ) + mapped = true + } + globalTextBlockIndex += 1 + } + } + + private mutating func mapTextBlock( + _ node: ProseMirrorNode, + route: NativeEditorRemotePresenceRoute + ) { + routes[globalTextBlockIndex] = TextBlockRoute( + route: route, + characterCount: node.plainTextContent.utf16.count + ) + globalTextBlockIndex += 1 + } + + private mutating func countUnmappedTextBlocks(in node: ProseMirrorNode) { + globalTextBlockIndex += Self.textBlocks(in: node).count + } + + private static func textBlocks(in node: ProseMirrorNode) -> [ProseMirrorNode] { + if Self.isTextBlock(node) { + return [node] + } + + return (node.content ?? []).flatMap(textBlocks(in:)) + } + + private static func isTextBlock(_ node: ProseMirrorNode) -> Bool { + node.type == "paragraph" || node.type == "heading" || node.type == "codeBlock" + } + } +} + +nonisolated private extension ProseMirrorNode { + var plainTextContent: String { + if type == "text" { + return text ?? "" + } + if type == "hardBreak" { + return "\n" + } + return (content ?? []).map(\.plainTextContent).joined() + } +} diff --git a/docmostly/Features/Editor/NativeEditorResolvedRemoteCursor.swift b/docmostly/Features/Editor/NativeEditorResolvedRemoteCursor.swift index ecd6610..1f44996 100644 --- a/docmostly/Features/Editor/NativeEditorResolvedRemoteCursor.swift +++ b/docmostly/Features/Editor/NativeEditorResolvedRemoteCursor.swift @@ -1,17 +1,34 @@ import Foundation -struct NativeEditorRemoteTextPosition: Equatable, Sendable { +nonisolated struct NativeEditorRemoteTextPosition: Equatable, Sendable { let blockIndex: Int let characterOffset: Int } -struct NativeEditorResolvedRemoteCursor: Equatable, Identifiable, Sendable { +nonisolated struct NativeEditorResolvedRemoteCursor: Equatable, Identifiable, Sendable { let id: String + let collaboratorID: String let name: String let colorName: String let anchor: NativeEditorRemoteTextPosition let head: NativeEditorRemoteTextPosition + init( + id: String, + collaboratorID: String? = nil, + name: String, + colorName: String, + anchor: NativeEditorRemoteTextPosition, + head: NativeEditorRemoteTextPosition + ) { + self.id = id + self.collaboratorID = collaboratorID ?? id + self.name = name + self.colorName = colorName + self.anchor = anchor + self.head = head + } + var isCollapsed: Bool { anchor == head } diff --git a/docmostly/Features/Editor/NativeEditorRichBlockEditingActions.swift b/docmostly/Features/Editor/NativeEditorRichBlockEditingActions.swift index 12e96b9..ac13a5f 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockEditingActions.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockEditingActions.swift @@ -4,6 +4,9 @@ struct NativeEditorRichBlockEditingActions { let updateCallout: (UUID, String, String?, String) -> Void let updateDetails: (UUID, String, String, Bool) -> Void let updateColumns: (UUID, String, String, [String]) -> Void + let updateNestedContent: (UUID, NativeEditorNestedContentTarget, [ProseMirrorNode]) -> Void + let setColumnCount: (UUID, Int) -> Void + let updateColumnWidth: (UUID, Int, Double?) -> Void let updateTransclusionSource: (UUID, String, String) -> Void let updateTransclusionReference: (UUID, String, String) -> Void let updateMediaBlock: (UUID, NativeEditorMediaBlockUpdate) -> Void diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index 32e85f5..7ddf3ca 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -7,6 +7,9 @@ struct NativeEditorRichBlockPreviewView: View { let pageID: String let spaceID: String? var serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var presenceScope: [NativeEditorRemotePresenceScope] = [] + var presenceBlockIndex: Int? var body: some View { switch block.kind { @@ -28,11 +31,12 @@ struct NativeEditorRichBlockPreviewView: View { NativeEditorTablePreview(table: table) } case .image(let media): - previewShell(systemImage: "photo", title: "Image", subtitle: media.alternativeText ?? media.source) { - if let richBlockActions { - NativeEditorMediaBlockEditor(blockID: block.id, media: media, actions: richBlockActions) - } - } + NativeEditorImageBlockView( + blockID: block.id, + media: media, + serverURLString: serverURLString, + actions: richBlockActions + ) case .video(let media): previewShell( systemImage: "play.rectangle", @@ -74,39 +78,65 @@ struct NativeEditorRichBlockPreviewView: View { } } case .callout(let callout): - NativeEditorCalloutBlockView(blockID: block.id, callout: callout, actions: richBlockActions) + NativeEditorCalloutBlockView( + blockID: block.id, + callout: callout, + content: calloutContent(for: callout), + actions: richBlockActions, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: nestedPresenceScope(for: .callout) + ) case .details(let details): - NativeEditorDetailsBlockView(blockID: block.id, details: details, actions: richBlockActions) + NativeEditorDetailsBlockView( + blockID: block.id, + details: details, + bodyContent: detailsBodyContent(for: details), + actions: richBlockActions, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: nestedPresenceScope(for: .detailsContent) + ) case .columns(let columns): - NativeEditorColumnsBlockView(blockID: block.id, columns: columns, actions: richBlockActions) + NativeEditorColumnsBlockView( + blockID: block.id, + columns: columns, + columnNodes: columnNodes(for: columns), + actions: richBlockActions, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + parentPresenceScope: presenceScope, + presenceBlockIndex: presenceBlockIndex + ) case .subpages: previewShell(systemImage: "doc.on.doc", title: "Subpages", subtitle: nil) { NativeEditorSubpagesView(pageID: pageID, spaceID: spaceID) } case .transclusionSource(let source): - previewShell( - systemImage: "arrow.trianglehead.2.clockwise", - title: "Synced block", - subtitle: source.previewText - ) { - if let richBlockActions { - NativeEditorTransclusionSourceEditor(blockID: block.id, source: source, actions: richBlockActions) - } - } + NativeEditorTransclusionSourceBlockView( + blockID: block.id, + source: source, + content: block.rawNode?.content ?? [], + actions: richBlockActions, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: nestedPresenceScope(for: .transclusionSource) + ) case .transclusionReference(let reference): - previewShell( - systemImage: "arrow.trianglehead.2.clockwise.rotate.90", - title: "Synced block reference", - subtitle: reference.transclusionID ?? reference.sourcePageID - ) { - if let richBlockActions { - NativeEditorTransclusionReferenceEditor( - blockID: block.id, - reference: reference, - actions: richBlockActions - ) - } - } + NativeEditorSyncedReferenceView( + reference: reference, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) case .base(let base): previewShell( systemImage: "tablecells", @@ -191,4 +221,28 @@ struct NativeEditorRichBlockPreviewView: View { return ByteCountFormatStyle(style: .file).format(Int64(size)) } + private func detailsBodyContent(for details: NativeEditorDetailsBlock) -> [ProseMirrorNode] { + let node = block.rawNode ?? NativeEditorRichBlockNodeFactory.detailsNode(from: details) + return node.content?.first(where: { $0.type == "detailsContent" })?.content ?? [] + } + + private func calloutContent(for callout: NativeEditorCalloutBlock) -> [ProseMirrorNode] { + block.rawNode?.content ?? NativeEditorRichBlockNodeFactory.calloutNode(from: callout).content ?? [] + } + + private func columnNodes(for columns: NativeEditorColumnsBlock) -> [ProseMirrorNode] { + let node = block.rawNode ?? NativeEditorRichBlockNodeFactory.columnsNode(from: columns) + return (node.content ?? []).filter { $0.type == "column" } + } + + private func nestedPresenceScope( + for target: NativeEditorNestedContentTarget + ) -> [NativeEditorRemotePresenceScope] { + guard let presenceBlockIndex else { return presenceScope } + return presenceScope + [NativeEditorRemotePresenceScope( + containerBlockIndex: presenceBlockIndex, + target: target + )] + } + } diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift b/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift index 69acdf1..bfaa901 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift @@ -20,12 +20,9 @@ struct NativeEditorCalloutEditor: View { TextField("Icon", text: iconBinding) .textFieldStyle(.roundedBorder) - .frame(width: 140) + .frame(maxWidth: 140) + .accessibilityLabel("Callout icon") } - - TextField("Callout", text: textBinding, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...4) } } @@ -37,13 +34,6 @@ struct NativeEditorCalloutEditor: View { } } - private var textBinding: Binding { - Binding { - callout.previewText - } set: { text in - actions.updateCallout(blockID, callout.style, callout.icon, text) - } - } } struct NativeEditorDetailsEditor: View { @@ -57,9 +47,6 @@ struct NativeEditorDetailsEditor: View { .textFieldStyle(.roundedBorder) .lineLimit(1...3) - TextField("Details", text: bodyBinding, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(2...5) } } @@ -71,13 +58,6 @@ struct NativeEditorDetailsEditor: View { } } - private var bodyBinding: Binding { - Binding { - details.previewText - } set: { body in - actions.updateDetails(blockID, details.summary, body, details.isOpen) - } - } } struct NativeEditorColumnsEditor: View { @@ -87,13 +67,13 @@ struct NativeEditorColumnsEditor: View { private let layouts = NativeEditorColumnsBlock.supportedLayouts private let widthModes = NativeEditorColumnsBlock.supportedWidthModes - private let columnCountRange = 1...NativeEditorColumnsBlock.maximumColumnCount + private let columnCountRange = 2...NativeEditorColumnsBlock.maximumColumnCount var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Menu(layoutTitle, systemImage: "rectangle.split.2x1") { - ForEach(layouts, id: \.self) { layout in + ForEach(supportedLayouts, id: \.self) { layout in Button(layout.replacing("_", with: " ").capitalized) { actions.updateColumns(blockID, layout, columns.widthMode, columnTexts) } @@ -112,10 +92,21 @@ struct NativeEditorColumnsEditor: View { Stepper("Columns: \(columnTexts.count)", value: columnCountBinding, in: columnCountRange) ForEach(columnTexts.indices, id: \.self) { index in - TextField("Column \(index + 1)", text: columnTextBinding(index: index), axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(1...3) + HStack { + Text("Column \(index + 1) width") + .font(.caption) + .foregroundStyle(.secondary) + Slider(value: columnWidthBinding(index: index), in: 0.25...4, step: 0.05) + .accessibilityLabel("Column \(index + 1) width") + } + } + + Button("Reset Column Widths", systemImage: "arrow.counterclockwise") { + for index in columnTexts.indices { + actions.updateColumnWidth(blockID, index, nil) + } } + .buttonStyle(.borderless) } } @@ -134,24 +125,26 @@ struct NativeEditorColumnsEditor: View { Binding { columnTexts.count } set: { count in - var updatedTexts = columnTexts - if count > updatedTexts.count { - updatedTexts.append(contentsOf: repeatElement("", count: count - updatedTexts.count)) - } else if count < updatedTexts.count { - updatedTexts.removeLast(updatedTexts.count - count) - } - actions.updateColumns(blockID, columns.layout, columns.widthMode, updatedTexts) + actions.setColumnCount(blockID, count) + } + } + + private var supportedLayouts: [String] { + let prefix = switch columnTexts.count { + case 2: "two_" + case 3: "three_" + case 4: "four_" + default: "five_" } + return layouts.filter { $0.hasPrefix(prefix) } } - private func columnTextBinding(index: Int) -> Binding { + private func columnWidthBinding(index: Int) -> Binding { Binding { - columnTexts[index] - } set: { text in - var updatedTexts = columnTexts - guard updatedTexts.indices.contains(index) else { return } - updatedTexts[index] = text - actions.updateColumns(blockID, columns.layout, columns.widthMode, updatedTexts) + guard columns.normalizedColumnWidths.indices.contains(index) else { return 1 } + return columns.normalizedColumnWidths[index] ?? 1 + } set: { width in + actions.updateColumnWidth(blockID, index, width) } } } @@ -163,29 +156,12 @@ struct NativeEditorTransclusionSourceEditor: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - TextField("Synced block ID", text: identifierBinding) - .docmostlyTextInputAutocapitalization(.never) - .textFieldStyle(.roundedBorder) - - TextField("Content", text: textBinding, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(1...4) - } - } - - private var identifierBinding: Binding { - Binding { - source.identifier ?? "" - } set: { identifier in - actions.updateTransclusionSource(blockID, identifier, source.previewText) - } - } - - private var textBinding: Binding { - Binding { - source.previewText - } set: { text in - actions.updateTransclusionSource(blockID, source.identifier ?? "", text) + LabeledContent("Synced block ID") { + Text(source.identifier ?? "Pending") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } } } } diff --git a/docmostly/Features/Editor/NativeEditorSimpleContentSafety.swift b/docmostly/Features/Editor/NativeEditorSimpleContentSafety.swift new file mode 100644 index 0000000..390d971 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorSimpleContentSafety.swift @@ -0,0 +1,44 @@ +import Foundation + +nonisolated enum NativeEditorSimpleContentSafety { + private static let supportedParagraphAttributeNames: Set = ["id", "indent", "textAlign"] + + static func plainBlockText(in nodes: [ProseMirrorNode]) -> String? { + guard nodes.isEmpty == false else { return "" } + guard nodes.count == 1, let paragraph = nodes.first else { return nil } + guard paragraph.type == "paragraph", hasSafeParagraphMetadata(paragraph) else { return nil } + guard paragraph.text == nil else { return nil } + return plainInlineText(in: paragraph.content ?? []) + } + + static func plainInlineText(in nodes: [ProseMirrorNode]) -> String? { + var text = "" + + for node in nodes { + guard hasNoSemanticMetadata(node), node.content?.isEmpty != false else { return nil } + + switch node.type { + case "text": + text += node.text ?? "" + case "hardBreak": + guard node.text == nil else { return nil } + text += "\n" + default: + return nil + } + } + + return text + } + + private static func hasNoSemanticMetadata(_ node: ProseMirrorNode) -> Bool { + (node.attrs?.isEmpty ?? true) && (node.marks?.isEmpty ?? true) + } + + private static func hasSafeParagraphMetadata(_ node: ProseMirrorNode) -> Bool { + let hasOnlySupportedAttributes = node.attrs?.keys.allSatisfy { attributeName in + supportedParagraphAttributeNames.contains(attributeName) + } ?? true + return hasOnlySupportedAttributes && (node.marks?.isEmpty ?? true) + } +} diff --git a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift index 827982f..d0ad001 100644 --- a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift +++ b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift @@ -4,9 +4,10 @@ struct NativeEditorSlashCommandMenu: View { @Bindable var viewModel: NativeRichEditorViewModel var importAttachment: (NativeEditorAttachmentImportKind) -> Void = { _ in } var applyCommand: ((NativeEditorCommand) -> Void)? + var commandFilter: (NativeEditorCommand) -> Bool = { _ in true } var body: some View { - let commands = viewModel.filteredSlashCommands + let commands = viewModel.filteredSlashCommands.filter(commandFilter) DocmostlyGlassPanel(cornerRadius: 18, isInteractive: true) { VStack(alignment: .leading, spacing: 0) { diff --git a/docmostly/Features/Editor/NativeEditorTableFocusNavigation.swift b/docmostly/Features/Editor/NativeEditorTableFocusNavigation.swift new file mode 100644 index 0000000..2be998b --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTableFocusNavigation.swift @@ -0,0 +1,48 @@ +import Foundation + +nonisolated enum NativeEditorTableFocusDirection: Sendable { + case forward + case backward +} + +nonisolated enum NativeEditorTableFocusDestination: Equatable, Sendable { + case cell(NativeEditorTableCellCoordinate) + case appendRowAndFocus(NativeEditorTableCellCoordinate) +} + +nonisolated enum NativeEditorTableFocusNavigation { + static func destination( + from coordinate: NativeEditorTableCellCoordinate, + direction: NativeEditorTableFocusDirection, + rowCount: Int, + columnCount: Int + ) -> NativeEditorTableFocusDestination? { + guard rowCount > 0, columnCount > 0 else { return nil } + let currentIndex = coordinate.rowIndex * columnCount + coordinate.columnIndex + + switch direction { + case .forward: + let nextIndex = currentIndex + 1 + if nextIndex >= rowCount * columnCount { + return .appendRowAndFocus( + NativeEditorTableCellCoordinate(rowIndex: rowCount, columnIndex: 0) + ) + } + return .cell( + NativeEditorTableCellCoordinate( + rowIndex: nextIndex / columnCount, + columnIndex: nextIndex % columnCount + ) + ) + case .backward: + guard currentIndex > 0 else { return nil } + let previousIndex = currentIndex - 1 + return .cell( + NativeEditorTableCellCoordinate( + rowIndex: previousIndex / columnCount, + columnIndex: previousIndex % columnCount + ) + ) + } + } +} diff --git a/docmostly/Features/Editor/NativeEditorTableGridView.swift b/docmostly/Features/Editor/NativeEditorTableGridView.swift index c6f6c79..91316e4 100644 --- a/docmostly/Features/Editor/NativeEditorTableGridView.swift +++ b/docmostly/Features/Editor/NativeEditorTableGridView.swift @@ -65,6 +65,7 @@ struct NativeEditorTableEditableGrid: View { @Binding var dragStartWidths: [Int: CGFloat] let focusedCell: FocusState.Binding let isCompactWidth: Bool + let moveFocus: (NativeEditorTableCellCoordinate, NativeEditorTableFocusDirection) -> Void var body: some View { if table.rows.isEmpty || table.columnCount == 0 { @@ -103,7 +104,8 @@ struct NativeEditorTableEditableGrid: View { actions: actions, selection: $selection, dragStartWidths: $dragStartWidths, - focusedCell: focusedCell + focusedCell: focusedCell, + moveFocus: moveFocus ) } else { NativeEditorTableMissingCell( @@ -260,6 +262,7 @@ private struct NativeEditorTableEditableCell: View { @Binding var selection: NativeEditorTableSelection? @Binding var dragStartWidths: [Int: CGFloat] let focusedCell: FocusState.Binding + let moveFocus: (NativeEditorTableCellCoordinate, NativeEditorTableFocusDirection) -> Void var body: some View { TextField("Cell", text: cellBinding, axis: .vertical) @@ -268,6 +271,13 @@ private struct NativeEditorTableEditableCell: View { .foregroundStyle(cell.isHeader ? .primary : NativeEditorTableLayout.bodyForeground) .lineLimit(4) .focused(focusedCell, equals: NativeEditorTableCellCoordinate(rowIndex: rowIndex, columnIndex: columnIndex)) + .onKeyPress(.tab, phases: .down) { keyPress in + moveFocus( + NativeEditorTableCellCoordinate(rowIndex: rowIndex, columnIndex: columnIndex), + keyPress.modifiers.contains(.shift) ? .backward : .forward + ) + return .handled + } .padding(.horizontal, NativeEditorTableLayout.cellHorizontalPadding) .padding(.vertical, NativeEditorTableLayout.cellVerticalPadding) .frame( @@ -569,30 +579,3 @@ private struct NativeEditorTableSelectionEdge: View { } } } - -private struct NativeEditorTableSelectionFill: View { - let selection: NativeEditorTableSelection? - let rowIndex: Int - let columnIndex: Int - - var body: some View { - if selection?.contains(rowIndex: rowIndex, columnIndex: columnIndex) == true { - switch selection?.kind { - case .row where columnIndex == 0: - indicator(width: 4, height: nil, alignment: .leading) - case .column where rowIndex == 0: - indicator(width: nil, height: 4, alignment: .top) - default: - EmptyView() - } - } - } - - private func indicator(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> some View { - Rectangle() - .fill(NativeEditorTableLayout.selectionAccent) - .frame(width: width, height: height) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) - .allowsHitTesting(false) - } -} diff --git a/docmostly/Features/Editor/NativeEditorTableSelection.swift b/docmostly/Features/Editor/NativeEditorTableSelection.swift index df8002e..d88dbcc 100644 --- a/docmostly/Features/Editor/NativeEditorTableSelection.swift +++ b/docmostly/Features/Editor/NativeEditorTableSelection.swift @@ -1,6 +1,6 @@ import Foundation -struct NativeEditorTableCellCoordinate: Hashable, Sendable { +nonisolated struct NativeEditorTableCellCoordinate: Hashable, Sendable { let rowIndex: Int let columnIndex: Int } diff --git a/docmostly/Features/Editor/NativeEditorTableSelectionFill.swift b/docmostly/Features/Editor/NativeEditorTableSelectionFill.swift new file mode 100644 index 0000000..93b4b9c --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTableSelectionFill.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct NativeEditorTableSelectionFill: View { + let selection: NativeEditorTableSelection? + let rowIndex: Int + let columnIndex: Int + + var body: some View { + if selection?.contains(rowIndex: rowIndex, columnIndex: columnIndex) == true { + switch selection?.kind { + case .row where columnIndex == 0: + indicator(width: 4, height: nil, alignment: .leading) + case .column where rowIndex == 0: + indicator(width: nil, height: 4, alignment: .top) + default: + EmptyView() + } + } + } + + private func indicator(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> some View { + Rectangle() + .fill(NativeEditorTableLayout.selectionAccent) + .frame(width: width, height: height) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + .allowsHitTesting(false) + } +} diff --git a/docmostly/Features/Editor/NativeEditorTableViews.swift b/docmostly/Features/Editor/NativeEditorTableViews.swift index 14fbb9a..3692756 100644 --- a/docmostly/Features/Editor/NativeEditorTableViews.swift +++ b/docmostly/Features/Editor/NativeEditorTableViews.swift @@ -16,6 +16,7 @@ struct NativeEditorTableEditor: View { @State private var selection: NativeEditorTableSelection? @State private var dragStartWidths: [Int: CGFloat] = [:] + @State private var pendingFocus: NativeEditorTableCellCoordinate? @FocusState private var focusedCell: NativeEditorTableCellCoordinate? #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -30,7 +31,8 @@ struct NativeEditorTableEditor: View { selection: $selection, dragStartWidths: $dragStartWidths, focusedCell: $focusedCell, - isCompactWidth: isCompactWidth + isCompactWidth: isCompactWidth, + moveFocus: moveFocus ) } .onChange(of: focusedCell) { _, coordinate in @@ -38,6 +40,13 @@ struct NativeEditorTableEditor: View { selection = .cell(rowIndex: coordinate.rowIndex, columnIndex: coordinate.columnIndex) } .onChange(of: table) { _, updatedTable in + if let pendingFocus, + updatedTable.rows.indices.contains(pendingFocus.rowIndex), + updatedTable.rows[pendingFocus.rowIndex].cells.indices.contains(pendingFocus.columnIndex) { + focusedCell = pendingFocus + selection = .cell(rowIndex: pendingFocus.rowIndex, columnIndex: pendingFocus.columnIndex) + self.pendingFocus = nil + } guard let selection, updatedTable.contains(selection) == false else { return } self.selection = nil } @@ -50,4 +59,26 @@ struct NativeEditorTableEditor: View { false #endif } + + private func moveFocus( + _ coordinate: NativeEditorTableCellCoordinate, + _ direction: NativeEditorTableFocusDirection + ) { + guard let destination = NativeEditorTableFocusNavigation.destination( + from: coordinate, + direction: direction, + rowCount: table.rows.count, + columnCount: table.columnCount + ) else { + return + } + + switch destination { + case .cell(let coordinate): + focusedCell = coordinate + case .appendRowAndFocus(let coordinate): + pendingFocus = coordinate + actions.insertRowBelow(blockID, max(table.rows.count - 1, 0)) + } + } } diff --git a/docmostly/Features/Editor/NativeEditorTextInputActions.swift b/docmostly/Features/Editor/NativeEditorTextInputActions.swift new file mode 100644 index 0000000..716cd97 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTextInputActions.swift @@ -0,0 +1,25 @@ +import Foundation + +struct NativeEditorTextInputActions { + let handleReturn: (Range) -> Bool + let insertHardBreak: (Range) -> Bool + let mergeBlockBackward: () -> Bool +} + +nonisolated enum NativeEditorReturnKeyBehavior: Equatable { + case splitBlock + case insertHardBreak + + static func resolve( + kind: NativeEditorBlockKind, + text: AttributedString, + selection: Range + ) -> Self { + guard case .codeBlock = kind else { return .splitBlock } + let safeSelection = NativeEditorCharacterRange.clamped(selection, count: text.characters.count) + let exitsCodeBlock = safeSelection.isEmpty && + safeSelection.lowerBound == text.characters.count && + String(text.characters.suffix(2)) == "\n\n" + return exitsCodeBlock ? .splitBlock : .insertHardBreak + } +} diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift new file mode 100644 index 0000000..ede814e --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift @@ -0,0 +1,503 @@ +#if os(iOS) +import SwiftUI +import UIKit + +struct NativeEditorTextInputView: UIViewRepresentable { + @Binding var block: NativeEditorBlock + @Binding var isFocused: Bool + + let accessibilityLabel: String + let actions: NativeEditorTextInputActions + var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] + + func makeCoordinator() -> NativeEditorTextInputCoordinator { + NativeEditorTextInputCoordinator(parent: self) + } + + func makeUIView(context: Context) -> NativeEditorUITextView { + let textView = NativeEditorUITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.adjustsFontForContentSizeCategory = true + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.accessibilityLabel = accessibilityLabel + textView.shiftReturnAction = { [weak coordinator = context.coordinator, weak textView] in + guard let coordinator, let textView else { return } + coordinator.insertHardBreak(in: textView) + } + textView.backspaceAtStartAction = { [weak coordinator = context.coordinator] in + coordinator?.mergeBlockBackward() ?? false + } + context.coordinator.applySource(to: textView) + context.coordinator.updateFocus(textView) + textView.updateRemotePresence(remotePresenceSegments) + return textView + } + + func updateUIView(_ textView: NativeEditorUITextView, context: Context) { + context.coordinator.parent = self + context.coordinator.configure(textView) + + context.coordinator.updateFromBoundBlock(textView) + context.coordinator.updateFocus(textView) + textView.updateRemotePresence(remotePresenceSegments) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: NativeEditorUITextView, + context: Context + ) -> CGSize? { + guard let width = proposal.width, width.isFinite, width > 0 else { return nil } + let fittingSize = uiView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + return CGSize(width: width, height: ceil(fittingSize.height)) + } +} + +@MainActor +final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { + var parent: NativeEditorTextInputView + private(set) var sourceText: AttributedString + + private var renderedPlainText: String + private var isApplyingSource = false + private var pendingTextDelta: NativeEditorTextDelta? + private var pendingSelectionCorrection: Range? + private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() + private var focusBindingEchoReconciler = NativeEditorFocusBindingEchoReconciler() + + init(parent: NativeEditorTextInputView) { + self.parent = parent + sourceText = parent.block.text + renderedPlainText = String(parent.block.text.characters) + super.init() + } + + func configure(_ textView: UITextView) { + let font = platformFont(for: parent.block.kind) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = platformAlignment(parent.block.alignment) + + textView.font = font + textView.textAlignment = paragraphStyle.alignment + textView.typingAttributes = [ + .font: font, + .foregroundColor: UIColor.label, + .paragraphStyle: paragraphStyle + ] + textView.accessibilityLabel = parent.accessibilityLabel + } + + func updateFromBoundBlock(_ textView: UITextView) { + let boundText = parent.block.text + if textView.markedTextRange != nil, boundText != sourceText { + return + } + + switch bindingEchoReconciler.disposition( + for: boundText, + authoritativeText: sourceText + ) { + case .current: + applyBoundSelectionIfNeeded(to: textView) + case .staleLocalEcho: + return + case .external: + applySource(to: textView) + } + } + + func applySource(to textView: UITextView) { + isApplyingSource = true + defer { isApplyingSource = false } + + sourceText = parent.block.text + bindingEchoReconciler.reset() + renderedPlainText = String(sourceText.characters) + textView.attributedText = renderedText( + sourceText, + font: platformFont(for: parent.block.kind), + alignment: platformAlignment(parent.block.alignment) + ) + (textView as? NativeEditorUITextView)?.invalidateRemotePresenceRendering() + applyBoundSelectionIfNeeded(to: textView) + } + + func applyBoundSelectionIfNeeded(to textView: UITextView) { + guard + let characterRange = NativeEditorCharacterRange.characterRange( + for: parent.block.selection, + in: parent.block.text + ) + else { + return + } + + let safeRange = NativeEditorAtomicTextRange.selectionRange( + for: characterRange, + in: parent.block.text + ) + let requestedSelection = NativeEditorCharacterRange.nsRange( + for: safeRange, + in: textView.text + ) + if safeRange != characterRange { + scheduleBoundSelectionCorrection(safeRange) + } + guard textView.selectedRange != requestedSelection else { return } + + isApplyingSource = true + textView.selectedRange = requestedSelection + isApplyingSource = false + } + + func updateFocus(_ textView: NativeEditorUITextView) { + switch focusBindingEchoReconciler.disposition( + for: parent.isFocused, + platformIsFocused: textView.isFirstResponder + ) { + case .activate: + textView.requestsFirstResponder = true + textView.requestFirstResponderIfPossible() + case .preserveLocalActivation: + textView.requestsFirstResponder = true + return + case .deactivate: + textView.requestsFirstResponder = false + guard textView.isFirstResponder else { return } + textView.resignFirstResponder() + } + } + + func insertHardBreak(in textView: UITextView) { + guard let range = selectedCharacterRange(in: textView) else { return } + _ = parent.actions.insertHardBreak(range) + } + + func mergeBlockBackward() -> Bool { + parent.actions.mergeBlockBackward() + } + + func textViewDidBeginEditing(_ textView: UITextView) { + focusBindingEchoReconciler.recordLocalActivation() + parent.isFocused = true + } + + func textViewDidEndEditing(_ textView: UITextView) { + focusBindingEchoReconciler.recordLocalDeactivation() + parent.isFocused = false + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard let characterRange = NativeEditorCharacterRange.characterRange(for: range, in: textView.text) else { + return true + } + if text == "\n", textView.markedTextRange == nil { + pendingTextDelta = nil + return parent.actions.handleReturn(characterRange) == false + } + pendingTextDelta = NativeEditorTextDelta( + replacedCharacterRange: characterRange, + replacement: text + ) + return true + } + + func textViewDidChange(_ textView: UITextView) { + guard isApplyingSource == false else { return } + let updatedPlainText = textView.text ?? "" + guard let delta = resolvedTextDelta(for: updatedPlainText) else { + synchronizeSelection(from: textView) + return + } + + let safeDelta = delta.adjustedForAtomicInlineContent(in: sourceText) + let updatedSource = safeDelta.applying(to: sourceText) + let updatedSourcePlainText = String(updatedSource.characters) + bindingEchoReconciler.recordLocalTransition(from: sourceText, to: updatedSource) + sourceText = updatedSource + + if updatedSourcePlainText == updatedPlainText { + renderedPlainText = updatedPlainText + synchronizeSelection(from: textView) + } else { + let insertionOffset = min(safeDelta.insertionCharacterOffset, updatedSource.characters.count) + let insertionRange = insertionOffset.. Range? { + guard let range = NativeEditorCharacterRange.characterRange( + for: textView.selectedRange, + in: textView.text + ) else { + return nil + } + return NativeEditorAtomicTextRange.selectionRange(for: range, in: sourceText) + } + + private func reconcilePlatformText( + _ source: AttributedString, + selection: Range, + in textView: UITextView + ) { + isApplyingSource = true + defer { isApplyingSource = false } + + renderedPlainText = String(source.characters) + textView.attributedText = renderedText( + source, + font: platformFont(for: parent.block.kind), + alignment: platformAlignment(parent.block.alignment) + ) + (textView as? NativeEditorUITextView)?.invalidateRemotePresenceRendering() + textView.selectedRange = NativeEditorCharacterRange.nsRange( + for: selection, + in: renderedPlainText + ) + pendingTextDelta = nil + } + + private func resolvedTextDelta(for updatedPlainText: String) -> NativeEditorTextDelta? { + defer { pendingTextDelta = nil } + if let pendingTextDelta, + pendingTextDelta.applying(to: renderedPlainText) == updatedPlainText { + return pendingTextDelta + } + return NativeEditorTextDelta( + previousText: renderedPlainText, + updatedText: updatedPlainText + ) + } + + private func scheduleBoundSelectionCorrection(_ safeRange: Range) { + guard pendingSelectionCorrection != safeRange else { return } + pendingSelectionCorrection = safeRange + Task { @MainActor [weak self] in + await Task.yield() + guard let self, pendingSelectionCorrection == safeRange else { return } + defer { + if pendingSelectionCorrection == safeRange { + pendingSelectionCorrection = nil + } + } + let currentRange = NativeEditorCharacterRange.characterRange( + for: parent.block.selection, + in: parent.block.text + ) ?? safeRange + guard NativeEditorAtomicTextRange.selectionRange( + for: currentRange, + in: parent.block.text + ) == safeRange else { + return + } + parent.block = NativeEditorTextBlockMutation.updating( + parent.block, + authoritativeText: sourceText, + characterSelection: safeRange + ) + } + } + + private func renderedText( + _ text: AttributedString, + font: UIFont, + alignment: NSTextAlignment + ) -> NSAttributedString { + let rendered = NSMutableAttributedString( + attributedString: NSAttributedString(text.nativeEditorPlatformRenderableText) + ) + let fullRange = NSRange(location: 0, length: rendered.length) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + + rendered.addAttribute(.font, value: font, range: fullRange) + applyInlinePresentationFonts(from: text, baseFont: font, to: rendered) + var rangesMissingForegroundColor: [NSRange] = [] + rendered.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in + if value == nil { + rangesMissingForegroundColor.append(range) + } + } + for range in rangesMissingForegroundColor { + rendered.addAttribute(.foregroundColor, value: UIColor.label, range: range) + } + rendered.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + return rendered + } + + private func applyInlinePresentationFonts( + from text: AttributedString, + baseFont: UIFont, + to rendered: NSMutableAttributedString + ) { + let plainText = String(text.characters) + for run in text.runs { + guard let intent = run.inlinePresentationIntent else { continue } + let lowerBound = text.characters.distance(from: text.startIndex, to: run.range.lowerBound) + let upperBound = text.characters.distance(from: text.startIndex, to: run.range.upperBound) + let characterRange = lowerBound.. UIFont { + switch kind { + case .heading(let level): + return UIFont.preferredFont(forTextStyle: level == 1 ? .title1 : .title2) + case .codeBlock: + let bodyFont = UIFont.preferredFont(forTextStyle: .body) + let baseFont = UIFont.monospacedSystemFont(ofSize: bodyFont.pointSize, weight: .regular) + return UIFontMetrics(forTextStyle: .body).scaledFont(for: baseFont) + default: + return UIFont.preferredFont(forTextStyle: .body) + } + } + + private func platformAlignment(_ alignment: NativeEditorTextAlignment) -> NSTextAlignment { + switch alignment { + case .left: + .natural + case .center: + .center + case .right: + .right + case .justify: + .justified + } + } +} + +@MainActor +final class NativeEditorUITextView: UITextView { + var requestsFirstResponder = false + var shiftReturnAction: (() -> Void)? + var backspaceAtStartAction: (() -> Bool)? + var renderedRemotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] + var remotePresenceOverlayViews: [UIView] = [] + var remotePresenceRenderingIsInvalid = true + + override func layoutSubviews() { + super.layoutSubviews() + layoutRemotePresenceOverlays() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + requestFirstResponderIfPossible() + } + + func requestFirstResponderIfPossible() { + guard requestsFirstResponder, window != nil, isFirstResponder == false else { return } + becomeFirstResponder() + } + + override var keyCommands: [UIKeyCommand]? { + var commands = super.keyCommands ?? [] + let shiftReturn = UIKeyCommand( + input: "\r", + modifierFlags: .shift, + action: #selector(handleShiftReturn) + ) + shiftReturn.wantsPriorityOverSystemBehavior = true + commands.append(shiftReturn) + return commands + } + + override func deleteBackward() { + if markedTextRange == nil, + selectedRange.location == 0, + selectedRange.length == 0, + backspaceAtStartAction?() == true { + return + } + super.deleteBackward() + } + + @objc private func handleShiftReturn() { + shiftReturnAction?() + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift new file mode 100644 index 0000000..d71286b --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift @@ -0,0 +1,522 @@ +#if os(macOS) +import AppKit +import SwiftUI + +struct NativeEditorTextInputView: NSViewRepresentable { + @Binding var block: NativeEditorBlock + @Binding var isFocused: Bool + + let accessibilityLabel: String + let actions: NativeEditorTextInputActions + var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] + + func makeCoordinator() -> NativeEditorTextInputCoordinator { + NativeEditorTextInputCoordinator(parent: self) + } + + func makeNSView(context: Context) -> NativeEditorNSTextView { + let textView = NativeEditorNSTextView(frame: .zero) + textView.delegate = context.coordinator + textView.drawsBackground = false + textView.isRichText = true + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.setAccessibilityLabel(accessibilityLabel) + context.coordinator.applySource(to: textView) + textView.updateRemotePresence(remotePresenceSegments) + return textView + } + + func updateNSView(_ textView: NativeEditorNSTextView, context: Context) { + context.coordinator.parent = self + context.coordinator.configure(textView) + + context.coordinator.updateFromBoundBlock(textView) + context.coordinator.updateFocus(textView) + textView.updateRemotePresence(remotePresenceSegments) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: NativeEditorNSTextView, + context: Context + ) -> CGSize? { + guard + let width = proposal.width, + width.isFinite, + width > 0, + let textContainer = nsView.textContainer, + let layoutManager = nsView.layoutManager + else { + return nil + } + + textContainer.containerSize = CGSize(width: width, height: .greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + let usedHeight = layoutManager.usedRect(for: textContainer).height + let lineHeight = nsView.font?.boundingRectForFont.height ?? 0 + return CGSize(width: width, height: ceil(max(usedHeight, lineHeight))) + } +} + +@MainActor +final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { + var parent: NativeEditorTextInputView + private(set) var sourceText: AttributedString + + private var renderedPlainText: String + private var isApplyingSource = false + private var pendingTextDelta: NativeEditorTextDelta? + private var pendingSelectionCorrection: Range? + private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() + private var focusBindingEchoReconciler = NativeEditorFocusBindingEchoReconciler() + + init(parent: NativeEditorTextInputView) { + self.parent = parent + sourceText = parent.block.text + renderedPlainText = String(parent.block.text.characters) + super.init() + } + + func configure(_ textView: NSTextView) { + let font = platformFont(for: parent.block.kind) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = platformAlignment(parent.block.alignment) + + textView.font = font + textView.alignment = paragraphStyle.alignment + textView.typingAttributes = [ + .font: font, + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraphStyle + ] + textView.setAccessibilityLabel(parent.accessibilityLabel) + } + + func updateFromBoundBlock(_ textView: NSTextView) { + let boundText = parent.block.text + if textView.hasMarkedText(), boundText != sourceText { + return + } + + switch bindingEchoReconciler.disposition( + for: boundText, + authoritativeText: sourceText + ) { + case .current: + applyBoundSelectionIfNeeded(to: textView) + case .staleLocalEcho: + return + case .external: + applySource(to: textView) + } + } + + func applySource(to textView: NSTextView) { + isApplyingSource = true + defer { isApplyingSource = false } + + sourceText = parent.block.text + bindingEchoReconciler.reset() + renderedPlainText = String(sourceText.characters) + textView.textStorage?.setAttributedString( + renderedText( + sourceText, + font: platformFont(for: parent.block.kind), + alignment: platformAlignment(parent.block.alignment) + ) + ) + (textView as? NativeEditorNSTextView)?.invalidateRemotePresenceRendering() + applyBoundSelectionIfNeeded(to: textView) + } + + func applyBoundSelectionIfNeeded(to textView: NSTextView) { + guard + let requestedRange = NativeEditorCharacterRange.characterRange( + for: parent.block.selection, + in: parent.block.text + ) + else { + return + } + + let safeRange = NativeEditorAtomicTextRange.selectionRange( + for: requestedRange, + in: parent.block.text + ) + let requestedSelection = NativeEditorCharacterRange.nsRange( + for: safeRange, + in: textView.string + ) + if safeRange != requestedRange { + scheduleBoundSelectionCorrection(safeRange) + } + guard textView.selectedRange() != requestedSelection else { return } + + isApplyingSource = true + textView.setSelectedRange(requestedSelection) + isApplyingSource = false + } + + func updateFocus(_ textView: NativeEditorNSTextView) { + switch focusBindingEchoReconciler.disposition( + for: parent.isFocused, + platformIsFocused: textView.window?.firstResponder === textView + ) { + case .activate: + textView.requestsFirstResponder = true + textView.requestFirstResponderIfPossible() + case .preserveLocalActivation: + textView.requestsFirstResponder = true + case .deactivate: + textView.requestsFirstResponder = false + guard textView.window?.firstResponder === textView else { return } + textView.window?.makeFirstResponder(nil) + } + } + + func textDidBeginEditing(_ notification: Notification) { + focusBindingEchoReconciler.recordLocalActivation() + parent.isFocused = true + } + + func textDidEndEditing(_ notification: Notification) { + focusBindingEchoReconciler.recordLocalDeactivation() + parent.isFocused = false + } + + func textDidChange(_ notification: Notification) { + guard + isApplyingSource == false, + let textView = notification.object as? NSTextView + else { + return + } + + let updatedPlainText = textView.string + guard let delta = resolvedTextDelta(for: updatedPlainText) else { + synchronizeSelection(from: textView) + return + } + + let safeDelta = delta.adjustedForAtomicInlineContent(in: sourceText) + let updatedSource = safeDelta.applying(to: sourceText) + let updatedSourcePlainText = String(updatedSource.characters) + bindingEchoReconciler.recordLocalTransition(from: sourceText, to: updatedSource) + sourceText = updatedSource + + if updatedSourcePlainText == updatedPlainText { + renderedPlainText = updatedPlainText + synchronizeSelection(from: textView) + } else { + let insertionOffset = min(safeDelta.insertionCharacterOffset, updatedSource.characters.count) + let insertionRange = insertionOffset.. Bool { + guard + isApplyingSource == false, + let replacementString, + let characterRange = NativeEditorCharacterRange.characterRange( + for: affectedCharRange, + in: textView.string + ) + else { + return true + } + + pendingTextDelta = NativeEditorTextDelta( + replacedCharacterRange: characterRange, + replacement: replacementString + ) + return true + } + + func textViewDidChangeSelection(_ notification: Notification) { + guard + isApplyingSource == false, + let textView = notification.object as? NSTextView, + textView.string == renderedPlainText + else { + return + } + synchronizeSelection(from: textView) + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard textView.hasMarkedText() == false else { return false } + + switch NSStringFromSelector(commandSelector) { + case "insertLineBreak:": + return performHardBreak(in: textView) + case "insertNewline:", "insertNewlineIgnoringFieldEditor:": + if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { + return performHardBreak(in: textView) + } + guard let range = selectedCharacterRange(in: textView) else { return false } + return parent.actions.handleReturn(range) + case "deleteBackward:": + guard + textView.selectedRange().location == 0, + textView.selectedRange().length == 0 + else { + return false + } + return parent.actions.mergeBlockBackward() + default: + return false + } + } + + private func performHardBreak(in textView: NSTextView) -> Bool { + guard let range = selectedCharacterRange(in: textView) else { return false } + return parent.actions.insertHardBreak(range) + } + + private func synchronizeSelection(from textView: NSTextView) { + pendingSelectionCorrection = nil + guard + let requestedRange = NativeEditorCharacterRange.characterRange( + for: textView.selectedRange(), + in: textView.string + ) + else { + return + } + + let safeRange = NativeEditorAtomicTextRange.selectionRange(for: requestedRange, in: sourceText) + if safeRange != requestedRange { + isApplyingSource = true + textView.setSelectedRange( + NativeEditorCharacterRange.nsRange(for: safeRange, in: textView.string) + ) + isApplyingSource = false + } + + let currentRange = NativeEditorCharacterRange.characterRange( + for: parent.block.selection, + in: parent.block.text + ) + guard currentRange != safeRange || parent.block.text != sourceText else { return } + parent.block = NativeEditorTextBlockMutation.updating( + parent.block, + authoritativeText: sourceText, + characterSelection: safeRange + ) + } + + private func selectedCharacterRange(in textView: NSTextView) -> Range? { + guard let range = NativeEditorCharacterRange.characterRange( + for: textView.selectedRange(), + in: textView.string + ) else { + return nil + } + return NativeEditorAtomicTextRange.selectionRange(for: range, in: sourceText) + } + + private func reconcilePlatformText( + _ source: AttributedString, + selection: Range, + in textView: NSTextView + ) { + isApplyingSource = true + defer { isApplyingSource = false } + + renderedPlainText = String(source.characters) + textView.textStorage?.setAttributedString( + renderedText( + source, + font: platformFont(for: parent.block.kind), + alignment: platformAlignment(parent.block.alignment) + ) + ) + (textView as? NativeEditorNSTextView)?.invalidateRemotePresenceRendering() + textView.setSelectedRange( + NativeEditorCharacterRange.nsRange(for: selection, in: renderedPlainText) + ) + pendingTextDelta = nil + } + + private func resolvedTextDelta(for updatedPlainText: String) -> NativeEditorTextDelta? { + defer { pendingTextDelta = nil } + if let pendingTextDelta, + pendingTextDelta.applying(to: renderedPlainText) == updatedPlainText { + return pendingTextDelta + } + return NativeEditorTextDelta( + previousText: renderedPlainText, + updatedText: updatedPlainText + ) + } + + private func scheduleBoundSelectionCorrection(_ safeRange: Range) { + guard pendingSelectionCorrection != safeRange else { return } + pendingSelectionCorrection = safeRange + Task { @MainActor [weak self] in + await Task.yield() + guard let self, pendingSelectionCorrection == safeRange else { return } + defer { + if pendingSelectionCorrection == safeRange { + pendingSelectionCorrection = nil + } + } + let currentRange = NativeEditorCharacterRange.characterRange( + for: parent.block.selection, + in: parent.block.text + ) ?? safeRange + guard NativeEditorAtomicTextRange.selectionRange( + for: currentRange, + in: parent.block.text + ) == safeRange else { + return + } + parent.block = NativeEditorTextBlockMutation.updating( + parent.block, + authoritativeText: sourceText, + characterSelection: safeRange + ) + } + } + + private func renderedText( + _ text: AttributedString, + font: NSFont, + alignment: NSTextAlignment + ) -> NSAttributedString { + let rendered = NSMutableAttributedString( + attributedString: NSAttributedString(text.nativeEditorPlatformRenderableText) + ) + let fullRange = NSRange(location: 0, length: rendered.length) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + + rendered.addAttribute(.font, value: font, range: fullRange) + applyInlinePresentationFonts(from: text, baseFont: font, to: rendered) + var rangesMissingForegroundColor: [NSRange] = [] + rendered.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in + if value == nil { + rangesMissingForegroundColor.append(range) + } + } + for range in rangesMissingForegroundColor { + rendered.addAttribute(.foregroundColor, value: NSColor.labelColor, range: range) + } + rendered.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + return rendered + } + + private func applyInlinePresentationFonts( + from text: AttributedString, + baseFont: NSFont, + to rendered: NSMutableAttributedString + ) { + let plainText = String(text.characters) + for run in text.runs { + guard let intent = run.inlinePresentationIntent else { continue } + let lowerBound = text.characters.distance(from: text.startIndex, to: run.range.lowerBound) + let upperBound = text.characters.distance(from: text.startIndex, to: run.range.upperBound) + let characterRange = lowerBound.. NSFont { + switch kind { + case .heading(let level): + return NSFont.preferredFont( + forTextStyle: level == 1 ? .title1 : .title2, + options: [:] + ) + case .codeBlock: + let bodyFont = NSFont.preferredFont(forTextStyle: .body, options: [:]) + return NSFont.monospacedSystemFont(ofSize: bodyFont.pointSize, weight: .regular) + default: + return NSFont.preferredFont(forTextStyle: .body, options: [:]) + } + } + + private func platformAlignment(_ alignment: NativeEditorTextAlignment) -> NSTextAlignment { + switch alignment { + case .left: + .natural + case .center: + .center + case .right: + .right + case .justify: + .justified + } + } +} + +@MainActor +final class NativeEditorNSTextView: NSTextView { + var requestsFirstResponder = false + var renderedRemotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] + var remotePresenceHighlightRanges: [NSRange] = [] + var remotePresenceOverlayViews: [NSView] = [] + var remotePresenceRenderingIsInvalid = true + + override func layout() { + super.layout() + layoutRemotePresenceOverlays() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + requestFirstResponderIfPossible() + } + + func requestFirstResponderIfPossible() { + guard + requestsFirstResponder, + let window, + window.firstResponder !== self + else { + return + } + window.makeFirstResponder(self) + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorTextMutation.swift b/docmostly/Features/Editor/NativeEditorTextMutation.swift new file mode 100644 index 0000000..cfb2832 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTextMutation.swift @@ -0,0 +1,355 @@ +import Foundation +import SwiftUI + +nonisolated struct NativeEditorTextDelta: Equatable, Sendable { + let replacedCharacterRange: Range + let replacement: String + + var insertionCharacterOffset: Int { + replacedCharacterRange.lowerBound + replacement.count + } + + init(replacedCharacterRange: Range, replacement: String) { + self.replacedCharacterRange = replacedCharacterRange + self.replacement = replacement + } + + init?(previousText: String, updatedText: String) { + guard previousText != updatedText else { return nil } + + let previousCharacters = Array(previousText) + let updatedCharacters = Array(updatedText) + let sharedPrefixCount = Self.sharedPrefixCount(previousCharacters, updatedCharacters) + let sharedSuffixCount = Self.sharedSuffixCount( + previousCharacters, + updatedCharacters, + afterSharedPrefix: sharedPrefixCount + ) + + replacedCharacterRange = sharedPrefixCount..<(previousCharacters.count - sharedSuffixCount) + replacement = String( + updatedCharacters[sharedPrefixCount..<(updatedCharacters.count - sharedSuffixCount)] + ) + } + + func applying(to source: AttributedString) -> AttributedString { + let safeDelta = adjustedForAtomicInlineContent(in: source) + var result = source + let clampedRange = NativeEditorCharacterRange.clamped( + safeDelta.replacedCharacterRange, + count: source.characters.count + ) + let attributedRange = NativeEditorCharacterRange.attributedRange(for: clampedRange, in: source) + var replacementText = AttributedString(safeDelta.replacement) + + if replacementText.characters.isEmpty == false, + var inheritedAttributes = Self.inheritedAttributes( + at: attributedRange.lowerBound, + replacedRange: attributedRange, + in: source + ) { + inheritedAttributes[NativeEditorMentionAttribute.self] = nil + inheritedAttributes[NativeEditorStatusAttribute.self] = nil + inheritedAttributes[NativeEditorMathInlineAttribute.self] = nil + replacementText.mergeAttributes(inheritedAttributes) + } + + result.replaceSubrange(attributedRange, with: replacementText) + return result + } + + func adjustedForAtomicInlineContent(in source: AttributedString) -> NativeEditorTextDelta { + let safeRange = NativeEditorAtomicTextRange.editingRange( + for: replacedCharacterRange, + in: source + ) + guard safeRange != replacedCharacterRange else { return self } + return NativeEditorTextDelta(replacedCharacterRange: safeRange, replacement: replacement) + } + + func applying(to source: String) -> String { + let range = NativeEditorCharacterRange.clamped(replacedCharacterRange, count: source.count) + let lowerBound = source.index(source.startIndex, offsetBy: range.lowerBound) + let upperBound = source.index(source.startIndex, offsetBy: range.upperBound) + var result = source + result.replaceSubrange(lowerBound.. Int { + var count = 0 + while count < lhs.count, count < rhs.count, lhs[count] == rhs[count] { + count += 1 + } + return count + } + + private static func sharedSuffixCount( + _ lhs: [Character], + _ rhs: [Character], + afterSharedPrefix sharedPrefixCount: Int + ) -> Int { + let maximumSuffixCount = min(lhs.count, rhs.count) - sharedPrefixCount + var count = 0 + while count < maximumSuffixCount, + lhs[lhs.count - count - 1] == rhs[rhs.count - count - 1] { + count += 1 + } + return count + } + + private static func inheritedAttributes( + at insertionIndex: AttributedString.Index, + replacedRange: Range, + in source: AttributedString + ) -> AttributeContainer? { + if replacedRange.isEmpty == false, insertionIndex < source.endIndex { + let attributes = source[ + insertionIndex.. source.startIndex { + let previousIndex = source.characters.index(before: insertionIndex) + if let previousAttributes = source[previousIndex.., count: Int) -> Range { + let lowerBound = min(max(range.lowerBound, 0), count) + let upperBound = min(max(range.upperBound, lowerBound), count) + return lowerBound.., + in text: AttributedString + ) -> Range { + let range = clamped(characterRange, count: text.characters.count) + let lowerBound = text.characters.index(text.startIndex, offsetBy: range.lowerBound) + let upperBound = text.characters.index(text.startIndex, offsetBy: range.upperBound) + return lowerBound.. Range? { + switch attributedSelection.indices(in: text) { + case .insertionPoint(let index): + let offset = text.characters.distance(from: text.startIndex, to: index) + return offset.., + in text: AttributedString + ) -> AttributedTextSelection { + let range = attributedRange(for: characterRange, in: text) + if range.isEmpty { + return AttributedTextSelection(insertionPoint: range.lowerBound) + } + return AttributedTextSelection(range: range) + } + + static func characterRange(for nsRange: NSRange, in text: String) -> Range? { + guard let stringRange = Range(nsRange, in: text) else { return nil } + let lowerBound = text.distance(from: text.startIndex, to: stringRange.lowerBound) + let upperBound = text.distance(from: text.startIndex, to: stringRange.upperBound) + return lowerBound.., in text: String) -> NSRange { + let range = clamped(characterRange, count: text.count) + let lowerBound = text.index(text.startIndex, offsetBy: range.lowerBound) + let upperBound = text.index(text.startIndex, offsetBy: range.upperBound) + return NSRange(lowerBound.. + ) -> NativeEditorBlock { + var updatedBlock = block + updatedBlock.text = authoritativeText + updatedBlock.selection = NativeEditorCharacterRange.attributedSelection( + for: characterSelection, + in: authoritativeText + ) + return updatedBlock + } +} + +nonisolated struct NativeEditorTextBindingEchoReconciler { + enum Disposition: Equatable { + case current + case staleLocalEcho + case external + } + + private var pendingLocalTexts: [AttributedString] = [] + + mutating func recordLocalTransition( + from previousText: AttributedString, + to updatedText: AttributedString + ) { + appendIfNeeded(previousText) + appendIfNeeded(updatedText) + if pendingLocalTexts.count > 64 { + pendingLocalTexts.removeFirst(pendingLocalTexts.count - 64) + } + } + + mutating func disposition( + for boundText: AttributedString, + authoritativeText: AttributedString + ) -> Disposition { + if boundText == authoritativeText { + pendingLocalTexts.removeAll() + return .current + } + if let echoIndex = pendingLocalTexts.firstIndex(of: boundText) { + pendingLocalTexts.removeFirst(echoIndex + 1) + return .staleLocalEcho + } + + pendingLocalTexts.removeAll() + return .external + } + + mutating func reset() { + pendingLocalTexts.removeAll() + } + + private mutating func appendIfNeeded(_ text: AttributedString) { + guard pendingLocalTexts.last != text else { return } + pendingLocalTexts.append(text) + } +} + +nonisolated struct NativeEditorFocusBindingEchoReconciler { + enum Disposition: Equatable { + case activate + case preserveLocalActivation + case deactivate + } + + private var isAwaitingLocalActivationEcho = false + + mutating func recordLocalActivation() { + isAwaitingLocalActivationEcho = true + } + + mutating func recordLocalDeactivation() { + isAwaitingLocalActivationEcho = false + } + + mutating func disposition( + for boundIsFocused: Bool, + platformIsFocused: Bool + ) -> Disposition { + if boundIsFocused { + isAwaitingLocalActivationEcho = false + return .activate + } + + if isAwaitingLocalActivationEcho, platformIsFocused { + return .preserveLocalActivation + } + + isAwaitingLocalActivationEcho = false + return .deactivate + } +} + +nonisolated enum NativeEditorAtomicTextRange { + static func selectionRange(for requestedRange: Range, in text: AttributedString) -> Range { + let clampedRange = NativeEditorCharacterRange.clamped(requestedRange, count: text.characters.count) + let atomicRanges = ranges(in: text) + + if clampedRange.isEmpty { + guard let atomicRange = atomicRanges.first(where: { + clampedRange.lowerBound > $0.lowerBound && clampedRange.lowerBound < $0.upperBound + }) else { + return clampedRange + } + + let distanceToStart = clampedRange.lowerBound - atomicRange.lowerBound + let distanceToEnd = atomicRange.upperBound - clampedRange.lowerBound + let insertionOffset = distanceToStart <= distanceToEnd ? atomicRange.lowerBound : atomicRange.upperBound + return insertionOffset.., in text: AttributedString) -> Range { + selectionRange(for: requestedRange, in: text) + } + + private static func ranges(in text: AttributedString) -> [Range] { + text.runs.compactMap { run in + guard run.attributes.hasNativeEditorAtomicInlineAttribute else { return nil } + let lowerBound = text.characters.distance(from: text.startIndex, to: run.range.lowerBound) + let upperBound = text.characters.distance(from: text.startIndex, to: run.range.upperBound) + return lowerBound.. CGRect { + let safeOffset = min(max(utf16Offset, 0), textStorage.length) + guard let position = position(from: beginningOfDocument, offset: safeOffset) else { + return CGRect( + origin: CGPoint(x: textContainerInset.left, y: textContainerInset.top), + size: CGSize(width: 2, height: font?.lineHeight ?? 20) + ) + } + var rect = caretRect(for: position) + if rect.height <= 0 { + rect.size.height = font?.lineHeight ?? 20 + } + return rect + } + + private func remoteSelectionRects(for characterRange: Range) -> [CGRect] { + let textLength = textStorage.length + let lowerBound = min(max(characterRange.lowerBound, 0), textLength) + let upperBound = min(max(characterRange.upperBound, lowerBound), textLength) + guard lowerBound < upperBound else { return [] } + guard + let start = position(from: beginningOfDocument, offset: lowerBound), + let end = position(from: beginningOfDocument, offset: upperBound), + let range = textRange(from: start, to: end) + else { + return [] + } + + return selectionRects(for: range) + .map(\.rect) + .filter { $0.isNull == false && $0.isInfinite == false && $0.width > 0 && $0.height > 0 } + } +} + +@MainActor +private final class NativeEditorRemotePresenceUIView: UIView { + private let caretView = UIView() + private let label = UILabel() + private var selectionViews: [UIView] = [] + private let selectionColor: UIColor + + init(segment: NativeEditorRemotePresenceSegment) { + let color = Color(docmostlyHex: segment.colorName).map(UIColor.init) ?? .secondaryLabel + selectionColor = color.withAlphaComponent(UIAccessibility.isDarkerSystemColorsEnabled ? 0.32 : 0.20) + super.init(frame: .zero) + isUserInteractionEnabled = false + isAccessibilityElement = false + backgroundColor = .clear + + caretView.backgroundColor = color + caretView.layer.cornerRadius = 1 + label.text = segment.name + label.font = .preferredFont(forTextStyle: .caption2) + label.adjustsFontForContentSizeCategory = true + label.textColor = Self.readableForegroundColor(for: color) + label.backgroundColor = color + label.layer.cornerRadius = 4 + label.layer.masksToBounds = true + label.layer.borderColor = UIColor.label.withAlphaComponent(0.45).cgColor + label.layer.borderWidth = UIAccessibility.isDarkerSystemColorsEnabled ? 1 : 0.5 + label.textAlignment = .center + + addSubview(caretView) + addSubview(label) + } + + private static func readableForegroundColor(for backgroundColor: UIColor) -> UIColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return .label + } + + let luminance = (0.2126 * red) + (0.7152 * green) + (0.0722 * blue) + return luminance > 0.55 ? .black : .white + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + func place(selectionRects: [CGRect], caretRect: CGRect?, in bounds: CGRect) { + frame = bounds + selectionViews.forEach { $0.removeFromSuperview() } + selectionViews = selectionRects.map { selectionRect in + let view = UIView(frame: selectionRect.offsetBy(dx: -bounds.minX, dy: -bounds.minY)) + view.backgroundColor = selectionColor + view.isUserInteractionEnabled = false + insertSubview(view, belowSubview: caretView) + return view + } + + guard let caretRect else { + caretView.isHidden = true + label.isHidden = true + return + } + + caretView.isHidden = false + label.isHidden = false + let localCaretRect = caretRect.offsetBy(dx: -bounds.minX, dy: -bounds.minY) + let localBounds = CGRect(origin: .zero, size: bounds.size) + let labelSize = label.sizeThatFits(CGSize(width: min(localBounds.width * 0.6, 180), height: 40)) + let paddedLabelSize = CGSize(width: labelSize.width + 10, height: labelSize.height + 4) + let labelY = localCaretRect.minY >= paddedLabelSize.height + 2 + ? localCaretRect.minY - paddedLabelSize.height - 2 + : localCaretRect.maxY + 2 + let labelX = min( + max(localCaretRect.minX, localBounds.minX), + max(localBounds.maxX - paddedLabelSize.width, localBounds.minX) + ) + caretView.frame = CGRect( + x: localCaretRect.minX, + y: localCaretRect.minY, + width: 2, + height: max(localCaretRect.height, 1) + ) + label.frame = CGRect( + x: labelX, + y: labelY, + width: paddedLabelSize.width, + height: paddedLabelSize.height + ) + } +} + +private extension Sequence where Element: Hashable { + func uniqued() -> [Element] { + var seen: Set = [] + return filter { seen.insert($0).inserted } + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorTextPresenceOverlay+macOS.swift b/docmostly/Features/Editor/NativeEditorTextPresenceOverlay+macOS.swift new file mode 100644 index 0000000..d25d96f --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTextPresenceOverlay+macOS.swift @@ -0,0 +1,194 @@ +#if os(macOS) +import AppKit +import SwiftUI + +@MainActor +extension NativeEditorNSTextView { + func updateRemotePresence(_ segments: [NativeEditorRemotePresenceSegment]) { + guard renderedRemotePresenceSegments != segments || remotePresenceRenderingIsInvalid else { return } + + removeRemotePresenceHighlights() + remotePresenceOverlayViews.forEach { $0.removeFromSuperview() } + remotePresenceOverlayViews = [] + renderedRemotePresenceSegments = segments + remotePresenceRenderingIsInvalid = false + + let textLength = textStorage?.length ?? 0 + for segment in segments { + let safeLocation = min(max(segment.characterRange.lowerBound, 0), textLength) + let safeRange = NSRange( + location: safeLocation, + length: min(max(segment.characterRange.count, 0), max(textLength - safeLocation, 0)) + ) + if safeRange.length > 0, let layoutManager { + let color = platformColor(for: segment.colorName) + let opacity: CGFloat = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast ? 0.34 : 0.20 + layoutManager.addTemporaryAttribute( + .backgroundColor, + value: color.withAlphaComponent(opacity), + forCharacterRange: safeRange + ) + remotePresenceHighlightRanges.append(safeRange) + } + + if segment.caretOffset != nil { + let overlay = NativeEditorRemotePresenceNSView(segment: segment) + addSubview(overlay) + remotePresenceOverlayViews.append(overlay) + } + } + + setAccessibilityHelp(segments.isEmpty + ? nil + : "Remote presence: \(segments.map(\.accessibilityDescription).uniqued().joined(separator: ", "))") + layoutRemotePresenceOverlays() + needsDisplay = true + } + + func invalidateRemotePresenceRendering() { + remotePresenceRenderingIsInvalid = true + } + + func layoutRemotePresenceOverlays() { + let caretSegments = renderedRemotePresenceSegments.filter { $0.caretOffset != nil } + for (view, segment) in zip(remotePresenceOverlayViews, caretSegments) { + guard let overlay = view as? NativeEditorRemotePresenceNSView else { continue } + guard let caretOffset = segment.caretOffset else { continue } + overlay.place(at: remoteCaretRect(utf16Offset: caretOffset), in: bounds) + } + } + + private func removeRemotePresenceHighlights() { + guard let layoutManager else { return } + let textLength = textStorage?.length ?? 0 + for range in remotePresenceHighlightRanges where NSMaxRange(range) <= textLength { + layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range) + } + remotePresenceHighlightRanges = [] + } + + private func remoteCaretRect(utf16Offset: Int) -> CGRect { + guard let layoutManager, let textContainer else { + return CGRect( + origin: textContainerOrigin, + size: CGSize(width: 2, height: font?.boundingRectForFont.height ?? 20) + ) + } + + let textLength = textStorage?.length ?? 0 + let safeOffset = min(max(utf16Offset, 0), textLength) + let lineHeight = font?.boundingRectForFont.height ?? 20 + guard textLength > 0 else { + return CGRect(origin: textContainerOrigin, size: CGSize(width: 2, height: lineHeight)) + } + + let characterIndex = min(safeOffset, textLength - 1) + let glyphIndex = layoutManager.glyphIndexForCharacter(at: characterIndex) + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyphIndex, length: 1), + in: textContainer + ) + let lineRect = layoutManager.lineFragmentUsedRect( + forGlyphAt: glyphIndex, + effectiveRange: nil + ) + let caretX = safeOffset == textLength ? glyphRect.maxX : glyphRect.minX + return CGRect( + x: textContainerOrigin.x + caretX, + y: textContainerOrigin.y + lineRect.minY, + width: 2, + height: max(lineRect.height, lineHeight) + ) + } + + private func platformColor(for colorName: String) -> NSColor { + guard let color = Color(docmostlyHex: colorName) else { return .secondaryLabelColor } + return NSColor(color) + } +} + +@MainActor +private final class NativeEditorRemotePresenceNSView: NSView { + private let caretView = NSView() + private let label = NSTextField(labelWithString: "") + + init(segment: NativeEditorRemotePresenceSegment) { + super.init(frame: .zero) + wantsLayer = true + + let color = Color(docmostlyHex: segment.colorName).map(NSColor.init) ?? .secondaryLabelColor + caretView.wantsLayer = true + caretView.layer?.backgroundColor = color.cgColor + caretView.layer?.cornerRadius = 1 + label.stringValue = segment.name + label.font = .preferredFont(forTextStyle: .caption2, options: [:]) + label.textColor = Self.readableForegroundColor(for: color) + label.backgroundColor = color + label.drawsBackground = true + label.isBezeled = false + label.isEditable = false + label.isSelectable = false + label.alignment = .center + label.wantsLayer = true + label.layer?.cornerRadius = 4 + label.layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.45).cgColor + label.layer?.borderWidth = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast ? 1 : 0.5 + + addSubview(caretView) + addSubview(label) + setAccessibilityElement(false) + } + + private static func readableForegroundColor(for backgroundColor: NSColor) -> NSColor { + guard let color = backgroundColor.usingColorSpace(.sRGB) else { return .labelColor } + let luminance = (0.2126 * color.redComponent) + + (0.7152 * color.greenComponent) + + (0.0722 * color.blueComponent) + return luminance > 0.55 ? .black : .white + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func place(at caretRect: CGRect, in bounds: CGRect) { + let intrinsic = label.intrinsicContentSize + let labelSize = CGSize( + width: min(intrinsic.width + 10, max(bounds.width * 0.6, 40)), + height: intrinsic.height + 4 + ) + let labelY = caretRect.minY >= labelSize.height + 2 + ? caretRect.minY - labelSize.height - 2 + : caretRect.maxY + 2 + let labelX = min(max(caretRect.minX, bounds.minX), max(bounds.maxX - labelSize.width, bounds.minX)) + let labelRect = CGRect(origin: CGPoint(x: labelX, y: labelY), size: labelSize) + let union = caretRect.union(labelRect) + + frame = union.integral + caretView.frame = CGRect( + x: caretRect.minX - frame.minX, + y: caretRect.minY - frame.minY, + width: 2, + height: max(caretRect.height, 1) + ) + label.frame = CGRect( + x: labelRect.minX - frame.minX, + y: labelRect.minY - frame.minY, + width: labelRect.width, + height: labelRect.height + ) + } +} + +private extension Sequence where Element: Hashable { + func uniqued() -> [Element] { + var seen: Set = [] + return filter { seen.insert($0).inserted } + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorTransclusionBlockViews.swift b/docmostly/Features/Editor/NativeEditorTransclusionBlockViews.swift new file mode 100644 index 0000000..a97e55d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorTransclusionBlockViews.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct NativeEditorTransclusionSourceBlockView: View { + let blockID: UUID + let source: NativeEditorTransclusionSourceBlock + let content: [ProseMirrorNode] + let actions: NativeEditorRichBlockEditingActions? + let pageID: String + let spaceID: String? + let serverURLString: String? + var presenceProjection: NativeEditorRemotePresenceProjection? + var presenceScope: [NativeEditorRemotePresenceScope] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Editing original synced block", systemImage: "arrow.trianglehead.2.clockwise") + .font(.caption) + .foregroundStyle(.secondary) + + if let actions { + NativeEditorTransclusionSourceEditor(blockID: blockID, source: source, actions: actions) + NativeEditorNestedDocumentView( + blockID: blockID, + target: .transclusionSource, + content: content, + serverURLString: serverURLString, + presenceProjection: presenceProjection, + presenceScope: presenceScope + ) { updatedContent in + actions.updateNestedContent(blockID, .transclusionSource, updatedContent) + } + } else { + NativeEditorNestedDocumentPreview( + content: content, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.blue.opacity(0.07), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.blue.opacity(0.25), lineWidth: 1) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Synced block source") + } +} + +struct NativeEditorSyncedReferenceView: View { + @Environment(AppState.self) private var appState + @State private var phase = Phase.idle + @State private var refreshGeneration = 0 + + let reference: NativeEditorTransclusionReferenceBlock + let pageID: String + let spaceID: String? + let serverURLString: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Synced block reference", systemImage: "arrow.trianglehead.2.clockwise.rotate.90") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + Button("Refresh synced block", systemImage: "arrow.clockwise") { + refreshGeneration += 1 + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help("Refresh synced block") + .disabled(canLoad == false || phase == .loading) + } + + referenceContent + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.12), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + .task(id: taskID) { + await load() + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Read-only synced block reference") + } + + @ViewBuilder + private var referenceContent: some View { + switch phase { + case .idle, .loading: + ProgressView("Loading synced block") + case .resolved(let document): + NativeEditorNestedDocumentPreview( + content: document.content, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + case .notFound: + Label("The original synced block no longer exists", systemImage: "questionmark.square.dashed") + .foregroundStyle(.secondary) + case .noAccess: + Label("You do not have access to this synced block", systemImage: "eye.slash") + .foregroundStyle(.secondary) + case .failed(let message): + VStack(alignment: .leading, spacing: 6) { + Label("Failed to load this synced block", systemImage: "exclamationmark.triangle") + .foregroundStyle(DocmostlyTheme.destructive) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var canLoad: Bool { + reference.sourcePageID?.isEmpty == false && reference.transclusionID?.isEmpty == false + } + + private var taskID: String { + "\(reference.sourcePageID ?? "missing")::\(reference.transclusionID ?? "missing")::\(refreshGeneration)" + } + + private func load() async { + guard let sourcePageID = reference.sourcePageID, + let transclusionID = reference.transclusionID, + sourcePageID.isEmpty == false, + transclusionID.isEmpty == false else { + phase = .notFound + return + } + + phase = .loading + do { + switch try await appState.loadTransclusion( + sourcePageId: sourcePageID, + transclusionId: transclusionID + ) { + case .resolved(_, let content, _): + phase = .resolved(content) + case .notFound: + phase = .notFound + case .noAccess: + phase = .noAccess + } + } catch is CancellationError { + return + } catch { + phase = .failed(error.localizedDescription) + } + } +} + +private extension NativeEditorSyncedReferenceView { + enum Phase: Equatable { + case idle + case loading + case resolved(ProseMirrorDocument) + case notFound + case noAccess + case failed(String) + } +} diff --git a/docmostly/Features/Editor/NativeEditorYjsRelativePosition.swift b/docmostly/Features/Editor/NativeEditorYjsRelativePosition.swift index 862c2a2..0e43bb5 100644 --- a/docmostly/Features/Editor/NativeEditorYjsRelativePosition.swift +++ b/docmostly/Features/Editor/NativeEditorYjsRelativePosition.swift @@ -50,7 +50,41 @@ nonisolated struct NativeEditorYjsRelativePosition: Codable, Equatable, Hashable let assoc: Int? var targetsDocmostDefaultFragment: Bool { - targetName == Self.docmostFragmentName + if let targetName { + return targetName == Self.docmostFragmentName + } + if case .id = type { + return true + } + return false + } + + init( + type: NativeEditorYjsRelativePositionType?, + targetName: String?, + item: NativeEditorYjsID?, + assoc: Int? + ) { + self.type = type + self.targetName = targetName + self.item = item + self.assoc = assoc + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeIfPresent(NativeEditorYjsRelativePositionType.self, forKey: .type) + targetName = try container.decodeIfPresent(String.self, forKey: .targetName) + item = try container.decodeIfPresent(NativeEditorYjsID.self, forKey: .item) + assoc = try container.decodeIfPresent(Int.self, forKey: .assoc) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeExplicitlyNullable(type, forKey: .type) + try container.encodeExplicitlyNullable(targetName, forKey: .targetName) + try container.encodeExplicitlyNullable(item, forKey: .item) + try container.encodeExplicitlyNullable(assoc, forKey: .assoc) } private enum CodingKeys: String, CodingKey { @@ -61,6 +95,19 @@ nonisolated struct NativeEditorYjsRelativePosition: Codable, Equatable, Hashable } } +nonisolated private extension KeyedEncodingContainer { + mutating func encodeExplicitlyNullable( + _ value: Value?, + forKey key: Key + ) throws { + if let value { + try encode(value, forKey: key) + } else { + try encodeNil(forKey: key) + } + } +} + nonisolated struct NativeEditorYjsSelection: Codable, Equatable, Sendable { let anchor: NativeEditorYjsSelectionPosition let head: NativeEditorYjsSelectionPosition diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index 6bfb00a..878d7bd 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -291,6 +291,8 @@ extension NativeRichEditorViewModel { func deleteBlock(_ blockID: UUID) { performUndoableEdit { guard let index = document.blocks.firstIndex(where: { $0.id == blockID }) else { return } + let deletedBlockWasActive = activeBlockID == blockID + let deletedBlockWasSelected = selectedBlockID == blockID if document.blocks.count == 1 { document.blocks[0].text = AttributedString("") @@ -301,22 +303,41 @@ extension NativeRichEditorViewModel { document.blocks.remove(at: index) } - if activeBlockID == blockID { - activeBlockID = document.blocks.indices.contains(index) ? - document.blocks[index].id : - document.blocks.last?.id - } - - if selectedBlockID == blockID { + if deletedBlockWasSelected { selectedBlockID = nil visibleBlockControlsID = nil - activeBlockID = document.blocks.indices.contains(index) ? - document.blocks[index].id : - document.blocks.last?.id + } + + if deletedBlockWasActive || deletedBlockWasSelected { + focusEditableBlockAfterDeletion(at: index) } } } + private func focusEditableBlockAfterDeletion(at deletedIndex: Int) { + guard document.blocks.isEmpty == false else { + activeBlockID = nil + return + } + + let precedingIndices = document.blocks.indices.prefix(min(deletedIndex, document.blocks.count)).reversed() + let followingIndices = document.blocks.indices.dropFirst(min(deletedIndex, document.blocks.count)) + let focusIndex = precedingIndices.first(where: { document.blocks[$0].isEditable }) ?? + followingIndices.first(where: { document.blocks[$0].isEditable }) + guard let focusIndex else { + activeBlockID = nil + return + } + + let insertionOffset = focusIndex < deletedIndex ? document.blocks[focusIndex].text.characters.count : 0 + let insertionIndex = document.blocks[focusIndex].text.characters.index( + document.blocks[focusIndex].text.startIndex, + offsetBy: insertionOffset + ) + document.blocks[focusIndex].selection = AttributedTextSelection(insertionPoint: insertionIndex) + activeBlockID = document.blocks[focusIndex].id + } + func deleteSelectedBlock() { guard let selectedBlockID else { return } deleteBlock(selectedBlockID) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockMechanics.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockMechanics.swift new file mode 100644 index 0000000..9646c70 --- /dev/null +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockMechanics.swift @@ -0,0 +1,378 @@ +import Foundation +import SwiftUI + +extension NativeRichEditorViewModel { + @discardableResult + func splitBlock(_ blockID: UUID, at characterOffset: Int) -> UUID? { + splitBlock(blockID, replacing: characterOffset..) -> UUID? { + guard let index = editableBlockIndex(for: blockID) else { return nil } + if document.blocks[index].kind.isMechanicsListItem { + return continueListItem(blockID, replacing: characterRange) + } + + var resultingBlockID: UUID? + performUndoableEdit { + guard let index = editableBlockIndex(for: blockID) else { return } + + var block = document.blocks[index] + // ProseMirror handles an empty quote with liftEmptyBlock before it attempts a split. + if block.kind.exitsEmptyContainerOnReturn, + block.text.characters.isEmpty, + characterRange.isEmpty { + guard Self.canLiftEmptyBlockquote(block) else { return } + + let liftedRawNode = Self.rawNodeAfterLiftingEmptyBlockquote(block) + block.kind = .paragraph + block.alignment = .left + block.indentLevel = 0 + block.rawNode = liftedRawNode + Self.setInsertionPoint(at: 0, in: &block) + document.blocks[index] = block + activateBlockAfterMechanicsEdit(block.id) + resultingBlockID = block.id + return + } + + let split = Self.splitText(block.text, replacing: characterRange) + let continuationKind = block.kind.mechanicsContinuationKind( + hasTrailingText: split.trailing.characters.isEmpty == false + ) + let preservesBlockStyle = continuationKind == block.kind + let leadingText = if block.kind.isMechanicsCodeBlock, + characterRange.isEmpty, + split.trailing.characters.isEmpty { + Self.removingTrailingCodeExitNewlines(from: split.leading) + } else { + split.leading + } + + block.text = leadingText + Self.setInsertionPoint(at: block.text.characters.count, in: &block) + document.blocks[index] = block + + var continuation = NativeEditorBlock( + kind: continuationKind, + text: split.trailing, + alignment: preservesBlockStyle ? block.alignment : .left, + indentLevel: preservesBlockStyle ? block.indentLevel : 0 + ) + Self.setInsertionPoint(at: 0, in: &continuation) + + let insertionIndex = document.blocks.index(after: index) + document.blocks.insert(continuation, at: insertionIndex) + activateBlockAfterMechanicsEdit(continuation.id) + resultingBlockID = continuation.id + } + return resultingBlockID + } + + @discardableResult + func continueListItem(_ blockID: UUID, at characterOffset: Int) -> UUID? { + continueListItem(blockID, replacing: characterOffset..) -> UUID? { + var continuationBlockID: UUID? + performUndoableEdit { + guard + let index = editableBlockIndex(for: blockID), + document.blocks[index].kind.isMechanicsListItem + else { + return + } + + var block = document.blocks[index] + let split = Self.splitText(block.text, replacing: characterRange) + + if split.leading.characters.isEmpty, split.trailing.characters.isEmpty { + guard block.indentLevel > 0 || Self.canDiscardContainerRawNode(from: block) else { return } + + block.text = AttributedString("") + if block.indentLevel > 0 { + block.indentLevel -= 1 + } else { + block.kind = .paragraph + block.alignment = .left + block.rawNode = nil + } + Self.setInsertionPoint(at: 0, in: &block) + document.blocks[index] = block + activateBlockAfterMechanicsEdit(block.id) + continuationBlockID = block.id + return + } + + let continuationKind = block.kind.mechanicsListContinuationKind + block.text = split.leading + block.rawNode = Self.rawListItemNodeWithoutNestedLists(block.rawNode) + Self.setInsertionPoint(at: block.text.characters.count, in: &block) + document.blocks[index] = block + + var continuation = NativeEditorBlock( + kind: continuationKind, + text: split.trailing, + alignment: block.alignment, + indentLevel: block.indentLevel + ) + Self.setInsertionPoint(at: 0, in: &continuation) + + let insertionIndex = document.blocks.index(after: index) + document.blocks.insert(continuation, at: insertionIndex) + renumberOrderedListItems(after: insertionIndex) + activateBlockAfterMechanicsEdit(continuation.id) + continuationBlockID = continuation.id + } + return continuationBlockID + } + + @discardableResult + func insertSoftBreak(in blockID: UUID, at characterOffset: Int) -> Bool { + insertSoftBreak(in: blockID, replacing: characterOffset..) -> Bool { + var didInsert = false + performUndoableEdit { + guard let index = editableBlockIndex(for: blockID) else { return } + + var block = document.blocks[index] + let range = Self.attributedRange(for: characterRange, in: block.text) + let insertionOffset = block.text.characters.distance(from: block.text.startIndex, to: range.lowerBound) + block.text.replaceSubrange(range, with: AttributedString("\n")) + Self.setInsertionPoint(at: insertionOffset + 1, in: &block) + document.blocks[index] = block + activateBlockAfterMechanicsEdit(block.id) + didInsert = true + } + return didInsert + } + + @discardableResult + func mergeBlockBackward(_ blockID: UUID) -> Bool { + var didMerge = false + performUndoableEdit { + guard + let index = editableBlockIndex(for: blockID), + index > document.blocks.startIndex + else { + return + } + + let previousIndex = document.blocks.index(before: index) + guard document.blocks[previousIndex].isEditable else { return } + + var previousBlock = document.blocks[previousIndex] + let currentBlock = document.blocks[index] + guard Self.canMerge(currentBlock, into: previousBlock) else { return } + + let joinOffset = previousBlock.text.characters.count + previousBlock.text += currentBlock.text + Self.setInsertionPoint(at: joinOffset, in: &previousBlock) + document.blocks[previousIndex] = previousBlock + document.blocks.remove(at: index) + + if previousBlock.kind.isMechanicsOrderedListItem, + currentBlock.kind.isMechanicsOrderedListItem, + previousBlock.indentLevel == currentBlock.indentLevel { + renumberOrderedListItems(after: previousIndex) + } + + activateBlockAfterMechanicsEdit(previousBlock.id) + didMerge = true + } + return didMerge + } + + private func editableBlockIndex(for blockID: UUID) -> Array.Index? { + document.blocks.firstIndex { $0.id == blockID && $0.isEditable } + } + + private func activateBlockAfterMechanicsEdit(_ blockID: UUID) { + isTitleFocused = false + activeBlockID = blockID + selectedBlockID = nil + visibleBlockControlsID = nil + } + + private func renumberOrderedListItems(after index: Array.Index) { + guard + document.blocks.indices.contains(index), + case .orderedListItem(let ordinal) = document.blocks[index].kind + else { + return + } + + let baseIndentLevel = document.blocks[index].indentLevel + var nextOrdinal = incrementedEditorOrdinal(ordinal) + var nextIndex = document.blocks.index(after: index) + + while nextIndex < document.blocks.endIndex { + let block = document.blocks[nextIndex] + guard block.kind.isMechanicsListItem, block.indentLevel >= baseIndentLevel else { break } + + if block.indentLevel == baseIndentLevel { + guard block.kind.isMechanicsOrderedListItem else { break } + document.blocks[nextIndex].kind = .orderedListItem(ordinal: nextOrdinal) + nextOrdinal = incrementedEditorOrdinal(nextOrdinal) + } + nextIndex = document.blocks.index(after: nextIndex) + } + } + + private static func splitText( + _ text: AttributedString, + replacing characterRange: Range + ) -> (leading: AttributedString, trailing: AttributedString) { + let range = attributedRange(for: characterRange, in: text) + return ( + AttributedString(text[.., + in text: AttributedString + ) -> Range { + let characterCount = text.characters.count + let lowerOffset = min(max(characterRange.lowerBound, 0), characterCount) + let upperOffset = min(max(characterRange.upperBound, lowerOffset), characterCount) + let lowerBound = text.characters.index(text.startIndex, offsetBy: lowerOffset) + let upperBound = text.characters.index(text.startIndex, offsetBy: upperOffset) + return lowerBound.. AttributedString { + // Docmost's Tiptap code block removes two blank sentinels when the third trailing Return exits. + guard text.characters.count >= 2 else { return text } + + let lastIndex = text.characters.index(before: text.endIndex) + let penultimateIndex = text.characters.index(before: lastIndex) + guard text.characters[penultimateIndex] == "\n", text.characters[lastIndex] == "\n" else { return text } + return AttributedString(text[.. Bool { + if case .codeBlock = destination.kind, case .codeBlock = source.kind { + return canDiscardContainerRawNode(from: source) + } + if case .codeBlock = destination.kind { + return false + } + return canDiscardContainerRawNode(from: source) + } + + private static func canDiscardContainerRawNode(from block: NativeEditorBlock) -> Bool { + guard let rawNode = block.rawNode else { return true } + + switch block.kind { + case .paragraph, .heading, .codeBlock: + return true + case .blockquote: + return rawNode.type != "blockquote" || (rawNode.content ?? []).count <= 1 + case .bulletListItem, .orderedListItem, .taskListItem: + guard rawNode.type == "listItem" || rawNode.type == "taskItem" else { return true } + return (rawNode.content ?? []).filter { $0.isListContainer == false }.count <= 1 + default: + return false + } + } + + private static func rawListItemNodeWithoutNestedLists(_ rawNode: ProseMirrorNode?) -> ProseMirrorNode? { + guard var rawNode, rawNode.type == "listItem" || rawNode.type == "taskItem" else { + return rawNode + } + rawNode.content = rawNode.content?.filter { $0.isListContainer == false } + return rawNode + } + + private static func canLiftEmptyBlockquote(_ block: NativeEditorBlock) -> Bool { + guard canDiscardContainerRawNode(from: block) else { return false } + guard let rawNode = block.rawNode else { return true } + if rawNode.type == "paragraph" { + return true + } + guard rawNode.type == "blockquote" else { return false } + guard let child = rawNode.content?.first else { return true } + return child.type == "paragraph" + } + + private static func rawNodeAfterLiftingEmptyBlockquote(_ block: NativeEditorBlock) -> ProseMirrorNode? { + guard let rawNode = block.rawNode else { return nil } + if rawNode.type == "paragraph" { + return rawNode + } + return rawNode.content?.first + } +} + +private extension NativeEditorBlockKind { + var exitsEmptyContainerOnReturn: Bool { + if case .blockquote = self { + return true + } + return false + } + + var isMechanicsListItem: Bool { + switch self { + case .bulletListItem, .orderedListItem, .taskListItem: + true + default: + false + } + } + + var isMechanicsCodeBlock: Bool { + if case .codeBlock = self { + return true + } + return false + } + + var isMechanicsOrderedListItem: Bool { + if case .orderedListItem = self { + return true + } + return false + } + + var mechanicsListContinuationKind: NativeEditorBlockKind { + switch self { + case .taskListItem: + .taskListItem(isChecked: false) + case .orderedListItem(let ordinal): + .orderedListItem(ordinal: incrementedEditorOrdinal(ordinal)) + default: + self + } + } + + func mechanicsContinuationKind(hasTrailingText: Bool) -> NativeEditorBlockKind { + switch self { + case .heading where hasTrailingText == false: + .paragraph + case .codeBlock where hasTrailingText == false: + .paragraph + default: + self + } + } +} + +nonisolated private func incrementedEditorOrdinal(_ ordinal: Int) -> Int { + let result = ordinal.addingReportingOverflow(1) + return result.overflow ? Int.max : result.partialValue +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift index 1856e2a..34c75b8 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift @@ -1,5 +1,7 @@ import Foundation +// swiftlint:disable file_length + extension NativeRichEditorViewModel { func markRemoteBaseline(updatedAt: Date?) { self.updatedAt = updatedAt ?? self.updatedAt @@ -48,6 +50,11 @@ extension NativeRichEditorViewModel { } func acceptPendingRemoteUpdate() { + if let pendingRemoteCRDTSnapshot { + applyPendingRemoteCRDTSnapshot(pendingRemoteCRDTSnapshot) + return + } + if let pendingRemotePage { applyRemotePageSnapshot(pendingRemotePage, lastUpdatedBy: pendingRemoteUpdate?.lastUpdatedBy) return @@ -57,16 +64,70 @@ extension NativeRichEditorViewModel { applyPendingRemoteTitleUpdate(pendingRemoteUpdate) } + @discardableResult + func acceptPendingRemoteUpdate( + matching expectedSnapshot: NativeEditorCRDTDocumentSnapshot, + remoteUpdate expectedRemoteUpdate: NativeEditorRemoteUpdate? + ) -> Bool { + guard pendingRemoteCRDTSnapshot == expectedSnapshot, + pendingRemoteUpdate == expectedRemoteUpdate else { + return false + } + acceptPendingRemoteUpdate() + return true + } + func rejectPendingRemoteUpdate() { + let rejectedDeferredCRDTSnapshot = pendingRemoteCRDTSnapshot + let rejectedRemoteTitle = pendingRemoteUpdate?.title ?? + rejectedDeferredCRDTSnapshot?.title ?? + title + let rejectedRemoteUpdatedAt = rejectedDeferredCRDTSnapshot?.updatedAt ?? + pendingRemoteUpdate?.updatedAt ?? + lastRemoteUpdatedAt pendingRemotePage = nil pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false realtimeStatus = .connected + + if let rejectedDeferredCRDTSnapshot { + lastSavedTitle = rejectedRemoteTitle + lastSavedDocument = rejectedDeferredCRDTSnapshot.document + markRemoteBaseline(updatedAt: rejectedRemoteUpdatedAt) + isDirty = true + lastKnownSnapshot = makeHistorySnapshot() + publishKeptLocalDraft( + over: rejectedDeferredCRDTSnapshot, + remoteTitle: rejectedRemoteTitle + ) + } + } + + @discardableResult + func rejectPendingRemoteUpdate( + matching expectedSnapshot: NativeEditorCRDTDocumentSnapshot, + remoteUpdate expectedRemoteUpdate: NativeEditorRemoteUpdate? + ) -> Bool { + guard pendingRemoteCRDTSnapshot == expectedSnapshot, + pendingRemoteUpdate == expectedRemoteUpdate else { + return false + } + rejectPendingRemoteUpdate() + return true } func clearCollaborationPresence() { - activeCollaborators.removeAll { $0.source == .presence } - remoteCursors = [] - resolvedRemoteCursors = [] + let retainedCollaborators = activeCollaborators.filter { $0.source != .presence } + if activeCollaborators != retainedCollaborators { + activeCollaborators = retainedCollaborators + } + if remoteCursors.isEmpty == false { + remoteCursors = [] + } + if resolvedRemoteCursors.isEmpty == false { + resolvedRemoteCursors = [] + } } func applyCollaborationSyncStatus(isSynced: Bool) { @@ -119,6 +180,17 @@ extension NativeRichEditorViewModel { recordRecentEditor(from: lastUpdatedBy) + if let pendingRemoteCRDTSnapshot { + let pendingUpdate = pendingRemoteUpdate + self.pendingRemoteUpdate = NativeEditorRemoteUpdate( + updatedAt: updatedAt ?? pendingUpdate?.updatedAt ?? pendingRemoteCRDTSnapshot.updatedAt, + title: remoteTitle ?? pendingUpdate?.title ?? pendingRemoteCRDTSnapshot.title ?? title, + lastUpdatedBy: lastUpdatedBy ?? pendingUpdate?.lastUpdatedBy + ) + realtimeStatus = .conflict + return true + } + if let remoteTitle { applyCRDTBackedRemoteTitle( remoteTitle, @@ -143,28 +215,57 @@ extension NativeRichEditorViewModel { } func applyCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { - let wasDirty = isDirty + guard isCRDTEngineReadyForLocalChanges else { + applyInitialCRDTDocumentSnapshot(snapshot) + return + } - if let title = snapshot.title { - self.title = title + guard isDirty else { + applyCleanCRDTDocumentSnapshot(snapshot) + return } - document = snapshot.document - pendingRemotePage = nil - pendingRemoteUpdate = nil - resolvedRemoteCursors = [] - markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) - resetEditingHistory() - if wasDirty { + let hasEarlierDeferredConflict = pendingRemotePage != nil || + pendingRemoteUpdate != nil || + pendingRemoteCRDTSnapshot != nil || + realtimeStatus == .conflict + let titleMatches = snapshot.title == nil || snapshot.title == title + let documentMatches = snapshot.document.proseMirrorDocument.isCollaborationEquivalent( + to: document.proseMirrorDocument + ) + + if hasEarlierDeferredConflict == false, titleMatches, documentMatches { + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) + lastKnownSnapshot = makeHistorySnapshot() isDirty = true - } else { - lastSavedTitle = self.title - lastSavedDocument = document - isDirty = false + return + } + + deferCRDTDocumentSnapshot(snapshot) + } + + @discardableResult + func applyAwarenessStates(_ states: [NativeEditorAwarenessState], localClientID: Int) -> Bool { + let nextActiveCollaborators = activeCollaborators(from: states, localClientID: localClientID) + if activeCollaborators != nextActiveCollaborators { + activeCollaborators = nextActiveCollaborators } + + let remoteCursorsChanged = applyRemoteCursorsIfNeeded( + remoteCursors(from: states, localClientID: localClientID) + ) + markConnectedAfterAwarenessIfNeeded() + return remoteCursorsChanged } - func applyAwarenessStates(_ states: [NativeEditorAwarenessState], localClientID: Int) { + private func activeCollaborators( + from states: [NativeEditorAwarenessState], + localClientID: Int + ) -> [NativeEditorCollaborator] { var seenIDs: Set = [] let presenceCollaborators: [NativeEditorCollaborator] = states.compactMap { state in guard state.clientID != localClientID, state.payload?.user != nil else { return nil } @@ -177,28 +278,77 @@ extension NativeRichEditorViewModel { let recentEditors = activeCollaborators.filter { collaborator in collaborator.source == .recentEditor && presenceIDs.contains(collaborator.id) == false } - activeCollaborators = presenceCollaborators + recentEditors + return presenceCollaborators + recentEditors + } + private func remoteCursors( + from states: [NativeEditorAwarenessState], + localClientID: Int + ) -> [NativeEditorRemoteCursor] { var seenCursorIDs: Set = [] - remoteCursors = states.compactMap { state in + return states.compactMap { state in guard state.clientID != localClientID else { return nil } guard let remoteCursor = NativeEditorRemoteCursor(awarenessState: state) else { return nil } guard seenCursorIDs.insert(remoteCursor.id).inserted else { return nil } return remoteCursor } - resolvedRemoteCursors = [] + } + + private func applyRemoteCursorsIfNeeded(_ nextRemoteCursors: [NativeEditorRemoteCursor]) -> Bool { + let remoteCursorsChanged = remoteCursors != nextRemoteCursors + if remoteCursorsChanged { + remoteCursors = nextRemoteCursors + if resolvedRemoteCursors.isEmpty == false { + resolvedRemoteCursors = [] + } + } + return remoteCursorsChanged + } + private func markConnectedAfterAwarenessIfNeeded() { switch realtimeStatus { case .conflict, .authenticationFailed, .failed: break - case .connected, .connecting, .disconnected: + case .connected: + break + case .connecting, .disconnected: realtimeStatus = .connected } } func configureCRDTDocumentEngine(_ engine: any NativeEditorCRDTDocumentEngine) { crdtDocumentEngine = engine - crdtSyncCoordinator = NativeEditorCRDTSyncCoordinator(documentEngine: engine) + crdtSyncCoordinator = makeCRDTSyncCoordinator(for: engine) + isCRDTEngineReadyForLocalChanges = engine.requiresInitialRemoteSnapshot == false + } + + func makeCRDTSyncCoordinator( + for engine: any NativeEditorCRDTDocumentEngine + ) -> NativeEditorCRDTSyncCoordinator { + NativeEditorCRDTSyncCoordinator( + documentEngine: engine, + remoteUpdateHandler: { [weak self] update in + guard let self else { return } + try await self.applyOrderedCRDTRemoteUpdate(update) + } + ) + } + + private func applyOrderedCRDTRemoteUpdate(_ update: Data) async throws { + await waitForStableCRDTLocalChangeBarrier() + try Task.checkCancellation() + guard let crdtDocumentEngine else { return } + + let snapshot: NativeEditorCRDTDocumentSnapshot? + if let javaScriptEngine = crdtDocumentEngine as? NativeEditorJSCRDTDocumentEngine { + snapshot = try javaScriptEngine.applyRemoteUpdateAndCaptureSnapshotSynchronously(update) + } else { + snapshot = try await crdtDocumentEngine.applyRemoteUpdateCapturingSnapshot(update) + } + + guard let snapshot else { return } + applyCRDTDocumentSnapshot(snapshot) + await refreshResolvedRemoteCursors() } var usesCRDTDocumentEngine: Bool { @@ -218,7 +368,9 @@ extension NativeRichEditorViewModel { notifyLocalAwarenessChanged() } - func collaborationSession() -> NativeEditorCollaborationSession { + func collaborationSession( + participation: NativeEditorCollaborationParticipation = .interactive + ) -> NativeEditorCollaborationSession { let collaborationDocument = NativeEditorCollaborationDocument(pageID: currentPageID) let syncDriver = crdtSyncCoordinator.map { coordinator in NativeEditorCollaborationSyncDriver( @@ -226,19 +378,32 @@ extension NativeRichEditorViewModel { coordinator: coordinator ) } + let localAwarenessCursor: (@Sendable () async -> NativeEditorAwarenessCursor?)? + let localAwarenessUpdates: AsyncStream? + if participation.allowsLocalAwarenessUpdates { + localAwarenessCursor = { [weak self] in + await self?.localAwarenessCursor() + } + localAwarenessUpdates = self.localAwarenessUpdates() + } else { + localAwarenessCursor = nil + localAwarenessUpdates = nil + } + return NativeEditorCollaborationSession( document: collaborationDocument, + participation: participation, syncDriver: syncDriver, - localAwarenessCursor: { [weak self] in - await self?.localAwarenessCursor() - }, - localAwarenessUpdates: localAwarenessUpdates() + localAwarenessCursor: localAwarenessCursor, + localAwarenessUpdates: localAwarenessUpdates ) } func refreshResolvedRemoteCursors() async { guard let crdtDocumentEngine else { - resolvedRemoteCursors = [] + if resolvedRemoteCursors.isEmpty == false { + resolvedRemoteCursors = [] + } return } @@ -251,7 +416,9 @@ extension NativeRichEditorViewModel { } } - resolvedRemoteCursors = resolvedCursors + if resolvedRemoteCursors != resolvedCursors { + resolvedRemoteCursors = resolvedCursors + } } func currentLocalTextSelection() -> NativeEditorLocalTextSelection? { @@ -287,7 +454,12 @@ extension NativeRichEditorViewModel { } func rebuildResolvedRemoteCursorIndex() { - guard resolvedRemoteCursors.isEmpty == false else { + remotePresenceProjection = NativeEditorRemotePresenceProjection( + document: document, + cursors: resolvedRemoteCursors + ) + + guard resolvedRemoteCursors.isEmpty == false, document.blocks.isEmpty == false else { resolvedRemoteCursorsByBlockID = [:] return } @@ -315,6 +487,16 @@ extension NativeRichEditorViewModel { resolvedRemoteCursorsByBlockID = cursorsByBlockID } + func rootBlockID(forCollaboratorID collaboratorID: String) -> UUID? { + guard + let blockIndex = remotePresenceProjection.rootBlockIndex(for: collaboratorID), + document.blocks.indices.contains(blockIndex) + else { + return nil + } + return document.blocks[blockIndex].id + } + private func isRemotePageNewer(_ page: DocmostEditablePage) -> Bool { guard let remoteUpdatedAt = page.updatedAt else { return false } guard let lastRemoteUpdatedAt else { return true } @@ -327,11 +509,119 @@ extension NativeRichEditorViewModel { return updatedAt > lastRemoteUpdatedAt } + private func applyInitialCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + isCRDTEngineReadyForLocalChanges = true + + guard isDirty else { + applyCleanCRDTDocumentSnapshot(snapshot) + return + } + + let snapshotMatchesRESTBaseline = (snapshot.title == nil || snapshot.title == lastSavedTitle) && + snapshot.document.proseMirrorDocument.isCollaborationEquivalent( + to: lastSavedDocument.proseMirrorDocument + ) + let snapshotMatchesCurrentDraft = (snapshot.title == nil || snapshot.title == title) && + snapshot.document.proseMirrorDocument.isCollaborationEquivalent( + to: document.proseMirrorDocument + ) + + if snapshotMatchesRESTBaseline { + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) + lastKnownSnapshot = makeHistorySnapshot() + isDirty = true + publishKeptLocalDraft( + over: snapshot, + remoteTitle: snapshot.title ?? lastSavedTitle + ) + return + } + + if snapshotMatchesCurrentDraft { + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) + lastKnownSnapshot = makeHistorySnapshot() + isDirty = true + recordCRDTProjectionReadyForAutosave() + return + } + + deferCRDTDocumentSnapshot(snapshot) + } + + private func applyCleanCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + if let snapshotTitle = snapshot.title { + title = snapshotTitle + } + document = snapshot.document + lastSavedTitle = title + lastSavedDocument = document + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + retainedReadOnlyDraftSnapshot = nil + resolvedRemoteCursors = [] + markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) + resetEditingHistory() + isDirty = false + } + + private func deferCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + let pendingUpdate = pendingRemoteUpdate + pendingRemotePage = nil + pendingRemoteCRDTSnapshot = snapshot + hasDurablyPersistedLocalCRDTDraft = false + pendingRemoteUpdate = NativeEditorRemoteUpdate( + updatedAt: snapshot.updatedAt ?? pendingUpdate?.updatedAt, + title: snapshot.title ?? pendingUpdate?.title ?? title, + lastUpdatedBy: pendingUpdate?.lastUpdatedBy + ) + realtimeStatus = .conflict + } + + private func applyPendingRemoteCRDTSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + let pendingUpdate = pendingRemoteUpdate + crdtLocalChangeTask?.cancel() + crdtLocalChangeTask = nil + + isApplyingHistory = true + title = pendingUpdate?.title ?? snapshot.title ?? title + document = snapshot.document + clearAuthoringState() + isApplyingHistory = false + + lastSavedTitle = title + lastSavedDocument = document + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + retainedReadOnlyDraftSnapshot = nil + resolvedRemoteCursors = [] + markRemoteBaseline( + updatedAt: snapshot.updatedAt ?? pendingUpdate?.updatedAt ?? lastRemoteUpdatedAt + ) + resetEditingHistory() + isDirty = false + notifyLocalAwarenessChanged() + } + private func applyRemotePageSnapshot(_ page: DocmostEditablePage, lastUpdatedBy: DocmostPagePerson?) { title = page.title document = NativeEditorDocument(proseMirrorDocument: page.content ?? ProseMirrorDocument()) lastSavedTitle = title lastSavedDocument = document + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + retainedReadOnlyDraftSnapshot = nil applyPageDetails(page, fallbackLastUpdatedBy: lastUpdatedBy) applyPagePermissions(page.permissions) activeCollaborators = collaborators(from: lastUpdatedBy) @@ -390,6 +680,8 @@ extension NativeRichEditorViewModel { lastSavedTitle = update.title rebaseEditingHistoryTitle(to: update.title) pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false markRemoteBaseline(updatedAt: update.updatedAt ?? lastRemoteUpdatedAt) recalculateDirty() } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift index a0017b1..804494d 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift @@ -18,6 +18,7 @@ extension NativeRichEditorViewModel { isApplyingHistory = false resetEditingHistory() recalculateDirty() + recordLocalEditForAutosave() notifyLocalAwarenessChanged() } @@ -27,7 +28,7 @@ extension NativeRichEditorViewModel { func handleDocumentChanged() { guard canEdit else { - restoreReadOnlyBaseline() + restoreRetainedReadOnlyDraft() return } @@ -37,7 +38,7 @@ extension NativeRichEditorViewModel { func handleTitleChanged() { guard canEdit else { - restoreReadOnlyBaseline() + restoreRetainedReadOnlyDraft() return } @@ -52,6 +53,7 @@ extension NativeRichEditorViewModel { redoStack.append(currentSnapshot) applyHistorySnapshot(previousSnapshot) queueCRDTLocalChange(before: currentSnapshot, after: previousSnapshot) + recordLocalEditForAutosave() } func redo() { @@ -61,6 +63,7 @@ extension NativeRichEditorViewModel { undoStack.append(currentSnapshot) applyHistorySnapshot(nextSnapshot) queueCRDTLocalChange(before: currentSnapshot, after: nextSnapshot) + recordLocalEditForAutosave() } func performUndoableEdit(_ edit: () -> Void) { @@ -77,6 +80,7 @@ extension NativeRichEditorViewModel { updateHistoryAvailability() recalculateDirty() queueCRDTLocalChange(before: before, after: after) + recordLocalEditForAutosave() notifyLocalAwarenessChanged() } @@ -97,6 +101,7 @@ extension NativeRichEditorViewModel { guard let before = lastKnownSnapshot else { lastKnownSnapshot = makeHistorySnapshot() isDirty = true + recordLocalEditForAutosave() return } @@ -118,6 +123,7 @@ extension NativeRichEditorViewModel { updateHistoryAvailability() isDirty = true queueCRDTLocalChange(before: before, after: after) + recordLocalEditForAutosave() } private func appendUndoSnapshot(_ snapshot: NativeEditorHistorySnapshot) { @@ -150,13 +156,24 @@ extension NativeRichEditorViewModel { canRedo = redoStack.isEmpty == false } - private func restoreReadOnlyBaseline() { + private func recordLocalEditForAutosave() { + localEditRevision += 1 + hasDurablyPersistedLocalCRDTDraft = false + } + + func recordCRDTProjectionReadyForAutosave() { + recordLocalEditForAutosave() + } + + private func restoreRetainedReadOnlyDraft() { guard isApplyingHistory == false else { return } - title = lastSavedTitle - document = lastSavedDocument + let retainedSnapshot = retainedReadOnlyDraftSnapshot + title = retainedSnapshot?.title ?? lastSavedTitle + document = retainedSnapshot?.document ?? lastSavedDocument + clearAuthoringState() lastKnownSnapshot = makeHistorySnapshot() - isDirty = false + recalculateDirty() updateHistoryAvailability() } @@ -164,26 +181,100 @@ extension NativeRichEditorViewModel { try await crdtLocalChangeTask?.value } + func waitForStableCRDTLocalChangeBarrier() async { + var stableGeneration = crdtOperationGeneration + + while Task.isCancelled == false { + do { + try await crdtLocalChangeTask?.value + } catch { + return + } + + guard stableGeneration != crdtOperationGeneration else { return } + stableGeneration = crdtOperationGeneration + } + } + + func enqueueCRDTSnapshotFlush( + title: String, + document: NativeEditorDocument + ) -> Task? { + guard canEdit else { return nil } + guard isCRDTEngineReadyForLocalChanges else { return nil } + guard let crdtSyncCoordinator else { return nil } + + crdtOperationGeneration += 1 + let previousTask = crdtLocalChangeTask + let flushTask = Task { [crdtSyncCoordinator, previousTask] in + do { + try await previousTask?.value + } catch is CancellationError { + try Task.checkCancellation() + } catch { + // A full snapshot flush can repair a failed predecessor in the local chain. + } + try Task.checkCancellation() + return try await crdtSyncCoordinator.flushPendingLocalChanges( + title: title, + document: document + ) + } + crdtLocalChangeTask = Task { [flushTask] in + _ = try await flushTask.value + } + return flushTask + } + private func queueCRDTLocalChange( before: NativeEditorHistorySnapshot, after: NativeEditorHistorySnapshot ) { - guard let crdtDocumentEngine else { return } + guard canEdit else { return } + guard isCRDTEngineReadyForLocalChanges else { return } + guard pendingRemoteCRDTSnapshot == nil else { return } + guard let crdtSyncCoordinator else { return } + crdtOperationGeneration += 1 let previousTask = crdtLocalChangeTask let change = NativeEditorCRDTLocalChange(before: before, after: after) - crdtLocalChangeTask = Task { [weak self, crdtDocumentEngine, change, previousTask] in - try await previousTask?.value + crdtLocalChangeTask = Task { [weak self, crdtSyncCoordinator, change, previousTask] in + do { + try await previousTask?.value + } catch is CancellationError { + try Task.checkCancellation() + } catch { + // The latest full snapshot can repair a failed predecessor in the local chain. + } try Task.checkCancellation() + guard let self, self.pendingRemoteCRDTSnapshot == nil else { return } do { - try await crdtDocumentEngine.integrateLocalChange(change) + try await crdtSyncCoordinator.integrateLocalChange(change) } catch is CancellationError { throw CancellationError() } catch { - self?.realtimeStatus = .failed(error.localizedDescription) + realtimeStatus = .failed(error.localizedDescription) throw error } } } + + func publishKeptLocalDraft( + over snapshot: NativeEditorCRDTDocumentSnapshot, + remoteTitle: String + ) { + let remoteSnapshot = NativeEditorHistorySnapshot( + title: remoteTitle, + document: snapshot.document, + activeBlockID: nil, + selectedBlockID: nil, + visibleBlockControlsID: nil, + isTitleFocused: false + ) + let localSnapshot = makeHistorySnapshot() + queueCRDTLocalChange(before: remoteSnapshot, after: localSnapshot) + recordLocalEditForAutosave() + notifyLocalAwarenessChanged() + } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift index 4bd75f7..2792a16 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift @@ -20,7 +20,21 @@ extension NativeRichEditorViewModel { ) block.kind = block.kind.replacingMediaBlock(with: media) block.text = AttributedString(NativeEditorDocument.previewText(for: block.kind)) - block.rawNode = NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: block.kind.mediaNodeType) + var node = block.rawNode?.type == block.kind.mediaNodeType ? + block.rawNode ?? NativeEditorRichBlockNodeFactory.mediaNode( + from: currentMedia, + type: block.kind.mediaNodeType + ) : + NativeEditorRichBlockNodeFactory.mediaNode(from: currentMedia, type: block.kind.mediaNodeType) + var attrs = node.attrs ?? [:] + Self.setOptionalString(media.source, key: "src", attrs: &attrs) + Self.setOptionalString(media.alternativeText, key: "alt", attrs: &attrs) + Self.setOptionalDimension(media.width, key: "width", attrs: &attrs) + Self.setOptionalDimension(media.height, key: "height", attrs: &attrs) + Self.setOptionalDimension(media.aspectRatio, key: "aspectRatio", attrs: &attrs) + Self.setOptionalString(media.alignment, key: "align", attrs: &attrs) + node.attrs = attrs.isEmpty ? nil : attrs + block.rawNode = node } } @@ -37,7 +51,16 @@ extension NativeRichEditorViewModel { ) block.kind = .pdf(pdf) block.text = AttributedString(NativeEditorDocument.previewText(for: block.kind)) - block.rawNode = NativeEditorRichBlockNodeFactory.pdfNode(from: pdf) + var node = block.rawNode?.type == "pdf" ? + block.rawNode ?? NativeEditorRichBlockNodeFactory.pdfNode(from: currentPDF) : + NativeEditorRichBlockNodeFactory.pdfNode(from: currentPDF) + var attrs = node.attrs ?? [:] + Self.setOptionalString(pdf.source, key: "src", attrs: &attrs) + Self.setOptionalString(pdf.name, key: "name", attrs: &attrs) + Self.setOptionalDimension(pdf.width, key: "width", attrs: &attrs) + Self.setOptionalDimension(pdf.height, key: "height", attrs: &attrs) + node.attrs = attrs.isEmpty ? nil : attrs + block.rawNode = node } } @@ -53,7 +76,15 @@ extension NativeRichEditorViewModel { ) block.kind = .attachment(attachment) block.text = AttributedString(NativeEditorDocument.previewText(for: block.kind)) - block.rawNode = NativeEditorRichBlockNodeFactory.attachmentNode(from: attachment) + var node = block.rawNode?.type == "attachment" ? + block.rawNode ?? NativeEditorRichBlockNodeFactory.attachmentNode(from: currentAttachment) : + NativeEditorRichBlockNodeFactory.attachmentNode(from: currentAttachment) + var attrs = node.attrs ?? [:] + Self.setOptionalString(attachment.url, key: "url", attrs: &attrs) + Self.setOptionalString(attachment.name, key: "name", attrs: &attrs) + Self.setOptionalString(attachment.mimeType, key: "mime", attrs: &attrs) + node.attrs = attrs.isEmpty ? nil : attrs + block.rawNode = node } } @@ -82,6 +113,36 @@ extension NativeRichEditorViewModel { } return (widthValue / heightValue).description } + + private static func setOptionalString( + _ value: String?, + key: String, + attrs: inout [String: ProseMirrorJSONValue] + ) { + if let value, value.isEmpty == false { + attrs[key] = .string(value) + } else { + attrs.removeValue(forKey: key) + } + } + + private static func setOptionalDimension( + _ value: String?, + key: String, + attrs: inout [String: ProseMirrorJSONValue] + ) { + guard let value, value.isEmpty == false else { + attrs.removeValue(forKey: key) + return + } + if let integer = Int(value) { + attrs[key] = .int(integer) + } else if let number = Double(value) { + attrs[key] = .double(number) + } else { + attrs[key] = .string(value) + } + } } private extension NativeEditorBlockKind { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+NestedBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+NestedBlocks.swift new file mode 100644 index 0000000..dcc6569 --- /dev/null +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+NestedBlocks.swift @@ -0,0 +1,205 @@ +import Foundation + +extension NativeRichEditorViewModel { + func setColumnCount(blockID: UUID, count: Int) { + updateRichBlock(blockID: blockID) { block in + guard var node = block.rawNode, node.type == "columns" else { return } + let normalizedCount = min(max(count, 2), NativeEditorColumnsBlock.maximumColumnCount) + var columns = (node.content ?? []).filter { $0.type == "column" } + guard columns.count != normalizedCount else { return } + + if columns.count < normalizedCount { + columns.append(contentsOf: (columns.count.. 0 { + attrs["width"] = .double(min(max(width, 0.25), 4)) + } else { + attrs.removeValue(forKey: "width") + } + content[nodeIndex].attrs = attrs.isEmpty ? nil : attrs + node.content = content + Self.refreshNestedBlock(&block, from: node) + } + } + + func updateNestedContent( + blockID: UUID, + target: NativeEditorNestedContentTarget, + content: [ProseMirrorNode] + ) { + updateRichBlock(blockID: blockID) { block in + guard let node = Self.updatedNestedNode(block.rawNode, target: target, content: content) else { + return + } + Self.refreshNestedBlock(&block, from: node) + } + } + + private static func updatedNestedNode( + _ existingNode: ProseMirrorNode?, + target: NativeEditorNestedContentTarget, + content: [ProseMirrorNode] + ) -> ProseMirrorNode? { + switch target { + case .callout: + updatedCallout(existingNode, content: content) + case .detailsContent: + updatedDetails(existingNode, content: content) + case .column(let columnIndex): + updatedColumns(existingNode, columnIndex: columnIndex, content: content) + case .transclusionSource: + updatedTransclusionSource(existingNode, content: content) + } + } + + private static func updatedCallout( + _ existingNode: ProseMirrorNode?, + content: [ProseMirrorNode] + ) -> ProseMirrorNode? { + guard var node = existingNode, node.type == "callout" else { return nil } + node.content = nonEmptyBlockContent(content) + return node + } + + private static func updatedDetails( + _ existingNode: ProseMirrorNode?, + content: [ProseMirrorNode] + ) -> ProseMirrorNode? { + guard var node = existingNode, node.type == "details" else { return nil } + var children = node.content ?? [] + if let index = children.firstIndex(where: { $0.type == "detailsContent" }) { + children[index].content = content + } else { + children.append(ProseMirrorNode(type: "detailsContent", content: content)) + } + node.content = children + return node + } + + private static func updatedColumns( + _ existingNode: ProseMirrorNode?, + columnIndex: Int, + content: [ProseMirrorNode] + ) -> ProseMirrorNode? { + guard var node = existingNode, node.type == "columns", var children = node.content else { return nil } + let indices = children.indices.filter { children[$0].type == "column" } + guard indices.indices.contains(columnIndex) else { return nil } + children[indices[columnIndex]].content = nonEmptyBlockContent(content) + node.content = children + return node + } + + private static func updatedTransclusionSource( + _ existingNode: ProseMirrorNode?, + content: [ProseMirrorNode] + ) -> ProseMirrorNode? { + guard var node = existingNode, + node.type == "transclusionSource", + content.allSatisfy({ transclusionSourceAllowedNodeTypes.contains($0.type) }) + else { + return nil + } + node.content = nonEmptyBlockContent(content) + return node + } + + private static func mergingRemovedColumns( + _ columns: [ProseMirrorNode], + retainedCount: Int + ) -> [ProseMirrorNode] { + var retainedColumns = Array(columns.prefix(retainedCount)) + let removedContent = columns.dropFirst(retainedCount).flatMap { column in + (column.content ?? []).filter(isNonEmptyColumnChild) + } + if removedContent.isEmpty == false { + retainedColumns[retainedCount - 1].content = + (retainedColumns[retainedCount - 1].content ?? []) + removedContent + } + return retainedColumns + } + + private static let transclusionSourceAllowedNodeTypes: Set = [ + "paragraph", "heading", "blockquote", "codeBlock", "horizontalRule", "bulletList", "orderedList", + "taskList", "image", "video", "audio", "attachment", "callout", "details", "embed", "mathBlock", + "table", "drawio", "excalidraw", "pdf", "subpages", "columns", "youtube", "pageBreak" + ] + + private static func nonEmptyBlockContent(_ content: [ProseMirrorNode]) -> [ProseMirrorNode] { + content.isEmpty ? [nestedParagraphNode("")] : content + } + + private static func isNonEmptyColumnChild(_ node: ProseMirrorNode) -> Bool { + node.type != "paragraph" || (node.content?.isEmpty == false) + } + + private static func defaultColumnsLayout(for count: Int) -> String { + switch count { + case 3: "three_equal" + case 4: "four_equal" + case 5: "five_equal" + default: "two_equal" + } + } + + private static func refreshNestedBlock(_ block: inout NativeEditorBlock, from node: ProseMirrorNode) { + switch node.type { + case "callout": + let callout = NativeEditorDocument.calloutBlock(from: node) + block.kind = .callout(callout) + block.text = AttributedString(callout.previewText) + case "details": + let details = NativeEditorDocument.detailsBlock(from: node) + block.kind = .details(details) + block.text = AttributedString(details.summary) + case "columns": + let columns = NativeEditorDocument.columnsBlock(from: node) + block.kind = .columns(columns) + block.text = AttributedString(columns.previewText) + case "transclusionSource": + let source = NativeEditorDocument.transclusionSourceBlock(from: node) + block.kind = .transclusionSource(source) + block.text = AttributedString(source.previewText) + default: + return + } + block.rawNode = node + } + + private static func nestedParagraphNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: AttributedString(text)) + ) + } + + private static func nestedColumnNode(text: String, width: Double?) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + attrs: ["width": width.map(ProseMirrorJSONValue.double) ?? .null], + content: [nestedParagraphNode(text)] + ) + } +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+ReadOnlyDraft.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+ReadOnlyDraft.swift new file mode 100644 index 0000000..bfe2f59 --- /dev/null +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+ReadOnlyDraft.swift @@ -0,0 +1,26 @@ +import Foundation + +extension NativeRichEditorViewModel { + func freezeAuthoringForReadOnlyAccess() { + crdtLocalChangeTask?.cancel() + crdtLocalChangeTask = nil + crdtOperationGeneration += 1 + clearAuthoringState() + if isDirty, retainedReadOnlyDraftSnapshot == nil { + retainedReadOnlyDraftSnapshot = makeHistorySnapshot() + } + lastKnownSnapshot = makeHistorySnapshot() + hasDurablyPersistedLocalCRDTDraft = false + notifyLocalAwarenessChanged() + } + + func resumeAuthoringAfterReadOnlyAccess() { + guard retainedReadOnlyDraftSnapshot != nil else { return } + + retainedReadOnlyDraftSnapshot = nil + if isDirty { + localEditRevision += 1 + hasDurablyPersistedLocalCRDTDraft = false + } + } +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift index 9b1cccc..e945195 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift @@ -4,59 +4,126 @@ extension NativeRichEditorViewModel { func updateCallout(blockID: UUID, style: String, icon: String?, text: String) { updateRichBlock(blockID: blockID) { block in let trimmedIcon = icon?.trimmingCharacters(in: .whitespacesAndNewlines) - let callout = NativeEditorCalloutBlock( - style: style.isEmpty ? "info" : style, - icon: trimmedIcon?.isEmpty == false ? trimmedIcon : nil, - previewText: text - ) + var node = block.rawNode?.type == "callout" ? block.rawNode ?? ProseMirrorNode(type: "callout") : + ProseMirrorNode(type: "callout") + var attrs = node.attrs ?? [:] + attrs["type"] = .string(style.isEmpty ? "info" : style) + if let trimmedIcon, trimmedIcon.isEmpty == false { + attrs["icon"] = .string(trimmedIcon) + } else { + attrs.removeValue(forKey: "icon") + } + node.attrs = attrs + + let currentText = NativeEditorDocument.plainText(in: node.content ?? []) + if currentText != text { + node.content = Self.replacingPlainBlockContent(node.content, with: text) + } + + let callout = NativeEditorDocument.calloutBlock(from: node) block.kind = .callout(callout) - block.text = AttributedString(text) - block.rawNode = NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + block.text = AttributedString(callout.previewText) + block.rawNode = node } } func updateDetails(blockID: UUID, summary: String, body: String, isOpen: Bool) { updateRichBlock(blockID: blockID) { block in - let details = NativeEditorDetailsBlock(summary: summary, previewText: body, isOpen: isOpen) + var node = block.rawNode?.type == "details" ? block.rawNode ?? ProseMirrorNode(type: "details") : + ProseMirrorNode(type: "details") + var attrs = node.attrs ?? [:] + attrs["open"] = .bool(isOpen) + node.attrs = attrs + + var content = node.content ?? [] + if let summaryIndex = content.firstIndex(where: { $0.type == "detailsSummary" }) { + let currentSummary = NativeEditorDocument.plainText(in: content[summaryIndex].content ?? []) + if currentSummary != summary { + content[summaryIndex].content = Self.replacingPlainInlineContent( + content[summaryIndex].content, + with: summary + ) + } + } else { + content.insert(Self.detailsSummaryNode(summary), at: 0) + } + + if let detailsIndex = content.firstIndex(where: { $0.type == "detailsContent" }) { + let currentBody = NativeEditorDocument.plainText(in: content[detailsIndex].content ?? []) + if currentBody != body { + content[detailsIndex].content = Self.replacingPlainBlockContent( + content[detailsIndex].content, + with: body + ) + } + } else { + content.append(Self.detailsContentNode(body)) + } + node.content = content + + let details = NativeEditorDocument.detailsBlock(from: node) block.kind = .details(details) - block.text = AttributedString(summary) - block.rawNode = NativeEditorRichBlockNodeFactory.detailsNode(from: details) + block.text = AttributedString(details.summary) + block.rawNode = node } } func updateColumns(blockID: UUID, layout: String, widthMode: String, columnTexts: [String]) { updateRichBlock(blockID: blockID) { block in let normalizedColumnTexts = Self.normalizedColumnTexts(columnTexts) - let existingColumnWidths: [Double?] - if case .columns(let existingColumns) = block.kind { - existingColumnWidths = existingColumns.columnWidths - } else { - existingColumnWidths = [] - } - let columns = NativeEditorColumnsBlock( - layout: layout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "two_equal" : layout, - widthMode: Self.normalizedColumnsWidthMode(widthMode), - columnCount: normalizedColumnTexts.count, - previewText: normalizedColumnTexts.joined(separator: " "), - columnTexts: normalizedColumnTexts, - columnWidths: Self.normalizedColumnWidths(existingColumnWidths, count: normalizedColumnTexts.count) + var node = block.rawNode?.type == "columns" ? block.rawNode ?? ProseMirrorNode(type: "columns") : + ProseMirrorNode(type: "columns") + var attrs = node.attrs ?? [:] + attrs["layout"] = .string( + layout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "two_equal" : layout ) + attrs["widthMode"] = .string(Self.normalizedColumnsWidthMode(widthMode)) + node.attrs = attrs + + let existingColumns = (node.content ?? []).filter { $0.type == "column" } + node.content = normalizedColumnTexts.enumerated().map { index, text in + guard existingColumns.indices.contains(index) else { + return Self.columnNode(text: text, width: nil) + } + + var column = existingColumns[index] + let currentText = NativeEditorDocument.plainText(in: column.content ?? []) + if currentText != text { + column.content = Self.replacingPlainBlockContent(column.content, with: text) + } + return column + } + + let columns = NativeEditorDocument.columnsBlock(from: node) block.kind = .columns(columns) block.text = AttributedString(columns.previewText) - block.rawNode = NativeEditorRichBlockNodeFactory.columnsNode(from: columns) + block.rawNode = node } } func updateTransclusionSource(blockID: UUID, identifier: String, text: String) { updateRichBlock(blockID: blockID) { block in let trimmedIdentifier = identifier.trimmingCharacters(in: .whitespacesAndNewlines) - let source = NativeEditorTransclusionSourceBlock( - identifier: trimmedIdentifier.isEmpty ? nil : trimmedIdentifier, - previewText: text - ) + var node = block.rawNode?.type == "transclusionSource" ? + block.rawNode ?? ProseMirrorNode(type: "transclusionSource") : + ProseMirrorNode(type: "transclusionSource") + var attrs = node.attrs ?? [:] + if trimmedIdentifier.isEmpty { + attrs.removeValue(forKey: "id") + } else { + attrs["id"] = .string(trimmedIdentifier) + } + node.attrs = attrs.isEmpty ? nil : attrs + + let currentText = NativeEditorDocument.plainText(in: node.content ?? []) + if currentText != text { + node.content = Self.replacingPlainBlockContent(node.content, with: text) + } + + let source = NativeEditorDocument.transclusionSourceBlock(from: node) block.kind = .transclusionSource(source) - block.text = AttributedString(text) - block.rawNode = NativeEditorRichBlockNodeFactory.transclusionSourceNode(from: source) + block.text = AttributedString(source.previewText) + block.rawNode = node } } @@ -142,7 +209,7 @@ extension NativeRichEditorViewModel { } } - private func updateRichBlock(blockID: UUID, edit: (inout NativeEditorBlock) -> Void) { + func updateRichBlock(blockID: UUID, edit: (inout NativeEditorBlock) -> Void) { performUndoableEdit { guard let index = document.blocks.firstIndex(where: { $0.id == blockID }) else { return @@ -165,10 +232,66 @@ extension NativeRichEditorViewModel { return trimmedWidthMode } - private static func normalizedColumnWidths(_ columnWidths: [Double?], count: Int) -> [Double?] { - (0.. [ProseMirrorNode] { + guard plainBlockContentCanBeReplaced(existingContent) else { + return existingContent ?? [] } + + if let existingContent, + existingContent.count == 1, + var paragraph = existingContent.first { + paragraph.content = NativeEditorDocument.inlineNodes(from: AttributedString(text)) + return [paragraph] + } + + return [paragraphNode(text)] + } + + private static func replacingPlainInlineContent( + _ existingContent: [ProseMirrorNode]?, + with text: String + ) -> [ProseMirrorNode] { + guard plainInlineContentCanBeReplaced(existingContent) else { + return existingContent ?? [] + } + return NativeEditorDocument.inlineNodes(from: AttributedString(text)) + } + + private static func plainBlockContentCanBeReplaced(_ content: [ProseMirrorNode]?) -> Bool { + NativeEditorSimpleContentSafety.plainBlockText(in: content ?? []) != nil + } + + private static func plainInlineContentCanBeReplaced(_ content: [ProseMirrorNode]?) -> Bool { + NativeEditorSimpleContentSafety.plainInlineText(in: content ?? []) != nil + } + + private static func paragraphNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode( + type: "paragraph", + content: NativeEditorDocument.inlineNodes(from: AttributedString(text)) + ) + } + + private static func detailsSummaryNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode( + type: "detailsSummary", + content: NativeEditorDocument.inlineNodes(from: AttributedString(text)) + ) + } + + private static func detailsContentNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode(type: "detailsContent", content: [paragraphNode(text)]) + } + + private static func columnNode(text: String, width: Double?) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + attrs: ["width": width.map(ProseMirrorJSONValue.double) ?? .null], + content: [paragraphNode(text)] + ) } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index bedcf90..77fd615 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -2,6 +2,8 @@ import Foundation import Observation import SwiftUI +// swiftlint:disable file_length + @MainActor @Observable final class NativeRichEditorViewModel { @@ -15,7 +17,9 @@ final class NativeRichEditorViewModel { } var isLoading = false var isSaving = false + var isResolvingConflict = false var isDirty = false + var localEditRevision: UInt = 0 var canEdit = true var hasPageRestriction = false var errorMessage: String? @@ -44,6 +48,10 @@ final class NativeRichEditorViewModel { } } var resolvedRemoteCursorsByBlockID: [UUID: [NativeEditorResolvedRemoteCursor]] = [:] + var remotePresenceProjection = NativeEditorRemotePresenceProjection( + document: NativeEditorDocument(), + cursors: [] + ) var creator: DocmostPagePerson? var lastUpdatedBy: DocmostPagePerson? var createdAt: Date? @@ -52,10 +60,24 @@ final class NativeRichEditorViewModel { @ObservationIgnored private var editablePageID: String @ObservationIgnored private var editablePageSlugID: String @ObservationIgnored private var editablePageSpaceID: String? - @ObservationIgnored var lastSavedTitle: String - @ObservationIgnored var lastSavedDocument = NativeEditorDocument() + @ObservationIgnored var savedBaselineRevision: UInt = 0 + @ObservationIgnored var lastSavedTitle: String { + didSet { + savedBaselineRevision += 1 + } + } + @ObservationIgnored var lastSavedDocument = NativeEditorDocument() { + didSet { + savedBaselineRevision += 1 + } + } @ObservationIgnored var lastRemoteUpdatedAt: Date? @ObservationIgnored var pendingRemotePage: DocmostEditablePage? + @ObservationIgnored var pendingRemoteCRDTSnapshot: NativeEditorCRDTDocumentSnapshot? + @ObservationIgnored var hasDurablyPersistedLocalCRDTDraft = false + @ObservationIgnored var retainedReadOnlyDraftSnapshot: NativeEditorHistorySnapshot? + @ObservationIgnored var isCRDTEngineReadyForLocalChanges = false + @ObservationIgnored var crdtOperationGeneration: UInt = 0 @ObservationIgnored private var pageAllowsEditing = true @ObservationIgnored private var collaborationAllowsEditing = true @ObservationIgnored var undoStack: [NativeEditorHistorySnapshot] = [] @@ -65,6 +87,8 @@ final class NativeRichEditorViewModel { @ObservationIgnored var crdtDocumentEngine: (any NativeEditorCRDTDocumentEngine)? @ObservationIgnored var crdtSyncCoordinator: NativeEditorCRDTSyncCoordinator? @ObservationIgnored var crdtLocalChangeTask: Task? + @ObservationIgnored var activeSaveTask: Task? + @ObservationIgnored let autosaveCoordinator = NativeEditorAutosaveCoordinator() @ObservationIgnored let localAwarenessUpdateStream: AsyncStream @ObservationIgnored let localAwarenessUpdateContinuation: AsyncStream.Continuation @@ -83,9 +107,9 @@ final class NativeRichEditorViewModel { localAwarenessUpdateContinuation = awarenessUpdates.continuation lastKnownSnapshot = makeHistorySnapshot() self.crdtDocumentEngine = crdtDocumentEngine - crdtSyncCoordinator = crdtDocumentEngine.map { - NativeEditorCRDTSyncCoordinator(documentEngine: $0) - } + isCRDTEngineReadyForLocalChanges = crdtDocumentEngine? + .requiresInitialRemoteSnapshot == false + crdtSyncCoordinator = crdtDocumentEngine.map(makeCRDTSyncCoordinator) } deinit { @@ -138,14 +162,24 @@ final class NativeRichEditorViewModel { canEdit && isDirty && isLoading == false && - isSaving == false && - title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + isSaving == false + } + + var hasOutgoingChangesRequiringPersistence: Bool { + isSaving || (isDirty && hasDurablyPersistedLocalCRDTDraft == false) } @discardableResult func load(appState: AppState) async -> Bool { isLoading = true errorMessage = nil + pendingRemotePage = nil + pendingRemoteUpdate = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + retainedReadOnlyDraftSnapshot = nil + isCRDTEngineReadyForLocalChanges = crdtDocumentEngine? + .requiresInitialRemoteSnapshot == false defer { isLoading = false } do { @@ -173,46 +207,66 @@ final class NativeRichEditorViewModel { } func save(appState: AppState) async -> Bool { + if let activeSaveTask { + let didSave = await activeSaveTask.value + guard canEdit, isDirty, isLoading == false else { + return didSave + } + return await save(appState: appState) + } + guard canSave else { return false } + let snapshot = makeSaveSnapshot() isSaving = true saveErrorMessage = nil - defer { isSaving = false } - do { - if let crdtDocumentEngine { - try await waitForPendingCRDTLocalChange() - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let result = try await crdtDocumentEngine.flushPendingLocalChanges( - title: trimmedTitle, - document: document - ) - title = result.title ?? trimmedTitle - lastSavedTitle = title - lastSavedDocument = document - markRemoteBaseline(updatedAt: result.updatedAt ?? lastRemoteUpdatedAt) - lastKnownSnapshot = makeHistorySnapshot() - isDirty = false - return true - } + let saveTask = Task { [self, appState] in + let didSave = await persist(snapshot: snapshot, appState: appState) + activeSaveTask = nil + isSaving = false + return didSave + } + activeSaveTask = saveTask + return await saveTask.value + } - let page = try await appState.updatePage( + func persistRetainedReadOnlyDraft(appState: AppState) async -> Bool { + if let activeSaveTask { + _ = await activeSaveTask.value + } + + guard canEdit == false, isDirty else { return true } + + let snapshotTitle = title + let snapshotDocument = document + let snapshotBaseTitle = lastSavedTitle + let snapshotBaseDocument = lastSavedDocument + let capturedAt = Date.now + saveErrorMessage = nil + + do { + let persistence = try await appState.persistDeferredCollaborativeDraft( pageId: editablePageID, - title: title.trimmingCharacters(in: .whitespacesAndNewlines), - document: document.proseMirrorDocument + title: snapshotTitle.trimmingCharacters(in: .whitespacesAndNewlines), + documentSnapshot: snapshotDocument.proseMirrorDocument, + baseTitle: snapshotBaseTitle, + baseDocument: snapshotBaseDocument.proseMirrorDocument, + snapshotCapturedAt: capturedAt ) - editablePageID = page.id - editablePageSlugID = page.slugId - title = page.title - applyPageDetails(page) - lastSavedTitle = title - lastSavedDocument = document - markRemoteBaseline(updatedAt: page.updatedAt) - lastKnownSnapshot = makeHistorySnapshot() - isDirty = false + if let page = persistence.page { + editablePageID = page.id + editablePageSlugID = page.slugId + } + + hasDurablyPersistedLocalCRDTDraft = canEdit == false && + isDirty && + title == snapshotTitle && + document == snapshotDocument return true } catch { saveErrorMessage = error.localizedDescription + hasDurablyPersistedLocalCRDTDraft = false return false } } @@ -311,8 +365,6 @@ final class NativeRichEditorViewModel { func markCollaborationUnavailable(_ message: String) { collaborationAllowsEditing = false - pendingRemotePage = nil - pendingRemoteUpdate = nil activeCollaborators = [] remoteCursors = [] resolvedRemoteCursors = [] @@ -324,8 +376,6 @@ final class NativeRichEditorViewModel { func markCollaborationAuthenticationFailed(_ message: String) { collaborationAllowsEditing = false - pendingRemotePage = nil - pendingRemoteUpdate = nil activeCollaborators = [] remoteCursors = [] resolvedRemoteCursors = [] @@ -341,13 +391,11 @@ final class NativeRichEditorViewModel { canEdit = false errorMessage = message saveErrorMessage = nil - pendingRemotePage = nil - pendingRemoteUpdate = nil realtimeStatus = .failed(message) activeCollaborators = [] remoteCursors = [] resolvedRemoteCursors = [] - discardUnsavedEditsForReadOnlyAccess() + freezeAuthoringForReadOnlyAccess() } func clearAuthoringState() { @@ -364,19 +412,196 @@ final class NativeRichEditorViewModel { canEdit = nextCanEdit if canEdit == false { - discardUnsavedEditsForReadOnlyAccess() + freezeAuthoringForReadOnlyAccess() + } else { + resumeAuthoringAfterReadOnlyAccess() } } - private func discardUnsavedEditsForReadOnlyAccess() { - crdtLocalChangeTask?.cancel() - crdtLocalChangeTask = nil - title = lastSavedTitle - document = lastSavedDocument - isDirty = false - clearAuthoringState() - resetEditingHistory() - notifyLocalAwarenessChanged() +} + +private extension NativeRichEditorViewModel { + struct SaveSnapshot { + let pageID: String + let title: String + let document: NativeEditorDocument + let baseTitle: String + let baseDocument: NativeEditorDocument + let localEditRevision: UInt + let savedBaselineRevision: UInt + let capturedAt: Date + let requiresLocalOnlyCRDTPersistence: Bool + let crdtFlushTask: Task? + + var trimmedTitle: String { + title.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + func makeSaveSnapshot() -> SaveSnapshot { + let snapshotTitle = title + let snapshotDocument = document + let requiresLocalOnlyCRDTPersistence = crdtDocumentEngine != nil && + (pendingRemoteCRDTSnapshot != nil || isCRDTEngineReadyForLocalChanges == false) + return SaveSnapshot( + pageID: editablePageID, + title: snapshotTitle, + document: snapshotDocument, + baseTitle: lastSavedTitle, + baseDocument: lastSavedDocument, + localEditRevision: localEditRevision, + savedBaselineRevision: savedBaselineRevision, + capturedAt: .now, + requiresLocalOnlyCRDTPersistence: requiresLocalOnlyCRDTPersistence, + crdtFlushTask: requiresLocalOnlyCRDTPersistence ? nil : enqueueCRDTSnapshotFlush( + title: snapshotTitle.trimmingCharacters(in: .whitespacesAndNewlines), + document: snapshotDocument + ) + ) + } + + func persist(snapshot: SaveSnapshot, appState: AppState) async -> Bool { + do { + if snapshot.requiresLocalOnlyCRDTPersistence { + let persistence = try await appState.persistDeferredCollaborativeDraft( + pageId: snapshot.pageID, + title: snapshot.trimmedTitle, + documentSnapshot: snapshot.document.proseMirrorDocument, + baseTitle: snapshot.baseTitle, + baseDocument: snapshot.baseDocument.proseMirrorDocument, + snapshotCapturedAt: snapshot.capturedAt + ) + if let page = persistence.page { + editablePageID = page.id + editablePageSlugID = page.slugId + } + completeLocalOnlyCRDTDraftPersistence(snapshot: snapshot) + return true + } + + if crdtDocumentEngine != nil { + guard let crdtFlushTask = snapshot.crdtFlushTask else { + throw APIError.connectionFailed("Collaborative save preparation failed.") + } + let result = try await crdtFlushTask.value + let flushedTitle = result.title ?? snapshot.trimmedTitle + + if appState.hasPageUpdatePersistence { + let persistence = try await appState.updateCollaborativePageTitle( + pageId: snapshot.pageID, + title: flushedTitle, + documentSnapshot: snapshot.document.proseMirrorDocument, + baseTitle: snapshot.baseTitle, + baseDocument: snapshot.baseDocument.proseMirrorDocument, + snapshotCapturedAt: snapshot.capturedAt + ) + if let page = persistence.page { + editablePageID = page.id + editablePageSlugID = page.slugId + } + completeSuccessfulSave( + snapshot: snapshot, + persistedTitle: persistence.persistedTitle, + updatedAt: persistence.updatedAt ?? result.updatedAt, + page: persistence.page + ) + } else { + completeSuccessfulSave( + snapshot: snapshot, + persistedTitle: flushedTitle, + updatedAt: result.updatedAt, + page: nil + ) + } + return true + } + + let page = try await appState.updatePage( + pageId: snapshot.pageID, + title: snapshot.trimmedTitle, + document: snapshot.document.proseMirrorDocument, + baseTitle: snapshot.baseTitle, + baseDocument: snapshot.baseDocument.proseMirrorDocument + ) + editablePageID = page.id + editablePageSlugID = page.slugId + completeSuccessfulSave( + snapshot: snapshot, + persistedTitle: page.title, + updatedAt: page.updatedAt, + page: page + ) + return true + } catch { + saveErrorMessage = error.localizedDescription + return false + } + } + + func completeLocalOnlyCRDTDraftPersistence(snapshot: SaveSnapshot) { + let stillRequiresLocalOnlyPersistence = pendingRemoteCRDTSnapshot != nil || + isCRDTEngineReadyForLocalChanges == false + guard stillRequiresLocalOnlyPersistence else { + hasDurablyPersistedLocalCRDTDraft = false + return + } + + let hasNewerLocalEdits = localEditRevision != snapshot.localEditRevision + let currentContentMatchesSnapshot = title == snapshot.title && document == snapshot.document + hasDurablyPersistedLocalCRDTDraft = hasNewerLocalEdits == false && currentContentMatchesSnapshot + lastKnownSnapshot = makeHistorySnapshot() + recalculateDirty() } + func completeSuccessfulSave( + snapshot: SaveSnapshot, + persistedTitle: String, + updatedAt: Date?, + page: DocmostEditablePage? + ) { + let baselineChangedWhileSaving = savedBaselineRevision != snapshot.savedBaselineRevision + let receivedConflictingRemoteUpdate = pendingRemotePage != nil || + pendingRemoteUpdate != nil || + pendingRemoteCRDTSnapshot != nil + + guard baselineChangedWhileSaving == false, receivedConflictingRemoteUpdate == false else { + lastKnownSnapshot = makeHistorySnapshot() + recalculateDirty() + return + } + + let hasNewerLocalEdits = localEditRevision != snapshot.localEditRevision + let currentContentMatchesSnapshot = title == snapshot.title && document == snapshot.document + + if hasNewerLocalEdits == false, currentContentMatchesSnapshot == false { + lastSavedTitle = title + lastSavedDocument = document + } else { + if title == snapshot.title { + title = persistedTitle + } + lastSavedTitle = persistedTitle + lastSavedDocument = snapshot.document + } + + if let page { + applyPageDetails(page) + } + markRemoteBaseline(updatedAt: latestRemoteUpdateDate(updatedAt)) + lastKnownSnapshot = makeHistorySnapshot() + recalculateDirty() + } + + func latestRemoteUpdateDate(_ candidate: Date?) -> Date? { + switch (lastRemoteUpdatedAt, candidate) { + case (.none, .none): + nil + case (.some(let existing), .none): + existing + case (.none, .some(let candidate)): + candidate + case (.some(let existing), .some(let candidate)): + max(existing, candidate) + } + } } diff --git a/docmostly/Features/Editor/ProseMirrorDocument+CollaborationEquivalence.swift b/docmostly/Features/Editor/ProseMirrorDocument+CollaborationEquivalence.swift new file mode 100644 index 0000000..5685ead --- /dev/null +++ b/docmostly/Features/Editor/ProseMirrorDocument+CollaborationEquivalence.swift @@ -0,0 +1,7 @@ +import Foundation + +nonisolated extension ProseMirrorDocument { + func isCollaborationEquivalent(to other: ProseMirrorDocument) -> Bool { + self == other + } +} diff --git a/docmostly/Features/Favorites/FavoriteListRow.swift b/docmostly/Features/Favorites/FavoriteListRow.swift new file mode 100644 index 0000000..2ea20c9 --- /dev/null +++ b/docmostly/Features/Favorites/FavoriteListRow.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct FavoriteListRow: View { + @Environment(AppState.self) private var appState + + let favorite: DocmostFavorite + let isMutating: Bool + let remove: () -> Void + + var body: some View { + Group { + switch favorite.type { + case .page: + if let target = PageOpenTarget(favorite: favorite) { + PageOpenLink(target: target) { + FavoriteRowView(favorite: favorite) + } + } else { + FavoriteRowView(favorite: favorite) + } + case .space: + if let targetID = favorite.targetID { + #if os(macOS) + Button { + appState.selectSpace(id: targetID) + } label: { + FavoriteRowView(favorite: favorite) + } + .buttonStyle(.plain) + #else + NavigationLink(value: favorite) { + FavoriteRowView(favorite: favorite) + } + #endif + } else { + FavoriteRowView(favorite: favorite) + } + case .template: + FavoriteRowView(favorite: favorite) + } + } + .opacity(isMutating ? 0.6 : 1) + .disabled(isMutating) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button("Remove Favorite", systemImage: "star.slash", role: .destructive, action: remove) + } + .contextMenu { + Button("Remove from Favorites", systemImage: "star.slash", role: .destructive, action: remove) + } + } +} diff --git a/docmostly/Features/Favorites/FavoritesPaginationView.swift b/docmostly/Features/Favorites/FavoritesPaginationView.swift new file mode 100644 index 0000000..20aa467 --- /dev/null +++ b/docmostly/Features/Favorites/FavoritesPaginationView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct FavoritesPaginationView: View { + let isLoading: Bool + let errorMessage: String? + let loadNextPage: () -> Void + + var body: some View { + VStack { + if isLoading { + ProgressView("Loading more") + } else if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(DocmostlyTheme.destructive) + Button("Try Again", systemImage: "arrow.clockwise", action: loadNextPage) + } else { + ProgressView() + .accessibilityLabel("Loading more favorites") + .task { + loadNextPage() + } + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/docmostly/Features/Favorites/FavoritesView.swift b/docmostly/Features/Favorites/FavoritesView.swift index 729c428..65cacd6 100644 --- a/docmostly/Features/Favorites/FavoritesView.swift +++ b/docmostly/Features/Favorites/FavoritesView.swift @@ -2,26 +2,52 @@ import SwiftUI struct FavoritesView: View { @Environment(AppState.self) private var appState + @Environment(\.scenePhase) private var scenePhase @State private var viewModel = FavoritesViewModel() var body: some View { List { - if viewModel.isLoading { + if viewModel.isLoading && viewModel.favorites.isEmpty { ProgressView("Loading favorites") - } - - Section("Favorites") { - ForEach(viewModel.favorites) { favorite in - favoriteRow(favorite) + .frame(maxWidth: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.favorites.isEmpty { + ContentUnavailableView { + Label("Couldn’t Load Favorites", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Try Again", systemImage: "arrow.clockwise", action: retry) + } + } else if viewModel.favorites.isEmpty { + ContentUnavailableView( + "No Favorites Yet", + systemImage: "star", + description: Text("Pages and spaces you star will appear here.") + ) + } else { + ForEach(viewModel.sections) { section in + Section(section.type.sectionTitle) { + ForEach(section.favorites) { favorite in + FavoriteListRow( + favorite: favorite, + isMutating: viewModel.mutatingFavoriteIDs.contains(favorite.id), + remove: { remove(favorite) } + ) + } + } } } - if viewModel.favorites.isEmpty && viewModel.isLoading == false { - ContentUnavailableView("No Favorites", systemImage: "star") + if viewModel.hasNextPage { + FavoritesPaginationView( + isLoading: viewModel.isLoadingNextPage, + errorMessage: viewModel.nextPageErrorMessage, + loadNextPage: loadNextPage + ) } - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) + if let errorMessage = viewModel.errorMessage, viewModel.favorites.isEmpty == false { + Label(errorMessage, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(DocmostlyTheme.destructive) } @@ -29,18 +55,18 @@ struct FavoritesView: View { .navigationTitle("Favorites") .toolbar { ToolbarItem(placement: .primaryAction) { - Menu("Favorites Actions", systemImage: "ellipsis.circle") { - Button("Refresh", systemImage: "arrow.clockwise") { - Task { - await viewModel.load(appState: appState) - } - } - } + Button("Refresh Favorites", systemImage: "arrow.clockwise", action: retry) + .labelStyle(.iconOnly) + .help("Refresh Favorites") } } - .task { + .task(id: appState.favoriteRevision) { await viewModel.load(appState: appState) } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + retry() + } .refreshable { await viewModel.load(appState: appState) } @@ -50,36 +76,21 @@ struct FavoritesView: View { .pageOpenDestination() } - @ViewBuilder - private func favoriteRow(_ favorite: DocmostFavorite) -> some View { - switch favorite.type { - case .page: - if let target = PageOpenTarget(favorite: favorite) { - PageOpenLink(target: target) { - FavoriteRowView(favorite: favorite) - } - } else { - FavoriteRowView(favorite: favorite) - } - case .space: - if let targetID = favorite.targetID { - #if os(macOS) - Button { - appState.selectSpace(id: targetID) - } label: { - FavoriteRowView(favorite: favorite) - } - .buttonStyle(.plain) - #else - NavigationLink(value: favorite) { - FavoriteRowView(favorite: favorite) - } - #endif - } else { - FavoriteRowView(favorite: favorite) - } - case .template: - FavoriteRowView(favorite: favorite) + private func retry() { + Task { + await viewModel.load(appState: appState) + } + } + + private func loadNextPage() { + Task { + await viewModel.loadNextPage(appState: appState) + } + } + + private func remove(_ favorite: DocmostFavorite) { + Task { + await viewModel.remove(favorite, appState: appState) } } } diff --git a/docmostly/Features/Favorites/FavoritesViewModel.swift b/docmostly/Features/Favorites/FavoritesViewModel.swift index 61c142b..93fd454 100644 --- a/docmostly/Features/Favorites/FavoritesViewModel.swift +++ b/docmostly/Features/Favorites/FavoritesViewModel.swift @@ -4,20 +4,133 @@ import Observation @MainActor @Observable final class FavoritesViewModel { - var favorites: [DocmostFavorite] = [] + private static let pageSize = 30 + + private var pages = CursorPageAccumulator() var isLoading = false + var isLoadingNextPage = false + var mutatingFavoriteIDs: Set = [] var errorMessage: String? + var nextPageErrorMessage: String? + + var favorites: [DocmostFavorite] { + pages.items + } + + var hasNextPage: Bool { + pages.hasNextPage + } + + var sections: [FavoriteSectionGroup] { + FavoriteType.displayOrder.compactMap { type in + var sectionFavorites = favorites.filter { $0.type == type } + if type == .space { + sectionFavorites.sort { lhs, rhs in + lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + } + guard sectionFavorites.isEmpty == false else { return nil } + return FavoriteSectionGroup(type: type, favorites: sectionFavorites) + } + } func load(appState: AppState) async { + guard isLoading == false else { return } isLoading = true errorMessage = nil + nextPageErrorMessage = nil defer { isLoading = false } do { - let response = try await appState.loadFavorites(limit: 50) - favorites = response.items + let response = try await appState.loadFavorites(limit: Self.pageSize) + applyInitialPage(response) + } catch is CancellationError { + return } catch { errorMessage = error.localizedDescription } } + + func loadNextPage(appState: AppState) async { + guard isLoading == false, isLoadingNextPage == false, let cursor = pages.nextCursor else { return } + isLoadingNextPage = true + nextPageErrorMessage = nil + defer { isLoadingNextPage = false } + + do { + let response = try await appState.loadFavorites(cursor: cursor, limit: Self.pageSize) + applyNextPage(response, requestedCursor: cursor) + } catch is CancellationError { + return + } catch { + nextPageErrorMessage = error.localizedDescription + } + } + + func remove(_ favorite: DocmostFavorite, appState: AppState) async { + await remove(favorite) { + try await appState.removeFavorite( + type: favorite.type, + pageId: favorite.pageId, + spaceId: favorite.spaceId, + templateId: favorite.templateId + ) + } + } + + func applyInitialPage(_ response: PaginatedResponse) { + pages.replace(with: response) + } + + func applyNextPage( + _ response: PaginatedResponse, + requestedCursor: String + ) { + pages.append(response, requestedCursor: requestedCursor) + } + + func remove( + _ favorite: DocmostFavorite, + operation: () async throws -> Void + ) async { + guard mutatingFavoriteIDs.insert(favorite.id).inserted else { return } + guard let removal = pages.remove(id: favorite.id) else { + mutatingFavoriteIDs.remove(favorite.id) + return + } + + errorMessage = nil + defer { mutatingFavoriteIDs.remove(favorite.id) } + + do { + try await operation() + } catch is CancellationError { + pages.restore(removal.item, at: removal.index) + } catch { + pages.restore(removal.item, at: removal.index) + errorMessage = error.localizedDescription + } + } +} + +nonisolated struct FavoriteSectionGroup: Identifiable, Sendable { + let type: FavoriteType + let favorites: [DocmostFavorite] + + var id: FavoriteType { type } +} + +nonisolated extension FavoriteType { + static let displayOrder: [FavoriteType] = [.space, .page, .template] + + var sectionTitle: String { + switch self { + case .space: + "Favorite Spaces" + case .page: + "Favorite Pages" + case .template: + "Favorite Templates" + } + } } diff --git a/docmostly/Features/Notifications/NotificationActorView.swift b/docmostly/Features/Notifications/NotificationActorView.swift new file mode 100644 index 0000000..daba60e --- /dev/null +++ b/docmostly/Features/Notifications/NotificationActorView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct NotificationActorView: View { + let notification: DocmostNotification + let isUnread: Bool + + var body: some View { + if let actor = notification.actor { + Text(actor.name.first.map(String.init) ?? "?") + .font(.caption) + .bold() + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .background(.quaternary, in: .circle) + .accessibilityLabel(actor.name) + } else { + Image(systemName: isUnread ? "bell.badge" : "bell") + .foregroundStyle(isUnread ? DocmostlyTheme.primary : .secondary) + } + } +} diff --git a/docmostly/Features/Notifications/NotificationFilter.swift b/docmostly/Features/Notifications/NotificationFilter.swift new file mode 100644 index 0000000..ff5cb90 --- /dev/null +++ b/docmostly/Features/Notifications/NotificationFilter.swift @@ -0,0 +1,17 @@ +import Foundation + +nonisolated enum NotificationFilter: String, CaseIterable, Identifiable, Sendable { + case all + case unread + + var id: Self { self } + + var title: String { + switch self { + case .all: + "All" + case .unread: + "Unread" + } + } +} diff --git a/docmostly/Features/Notifications/NotificationListControlsView.swift b/docmostly/Features/Notifications/NotificationListControlsView.swift new file mode 100644 index 0000000..cf77b19 --- /dev/null +++ b/docmostly/Features/Notifications/NotificationListControlsView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct NotificationListControlsView: View { + @Bindable var viewModel: NotificationListViewModel + + var body: some View { + Section { + Picker("Notification Type", selection: $viewModel.selectedType) { + ForEach(NotificationListType.displayCases, id: \.self) { type in + Text(type.title).tag(type) + } + } + .pickerStyle(.segmented) + + Picker("Show", selection: $viewModel.selectedFilter) { + ForEach(NotificationFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .pickerStyle(.segmented) + } + } +} diff --git a/docmostly/Features/Notifications/NotificationListRow.swift b/docmostly/Features/Notifications/NotificationListRow.swift new file mode 100644 index 0000000..b1194a2 --- /dev/null +++ b/docmostly/Features/Notifications/NotificationListRow.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct NotificationListRow: View { + @Environment(AppState.self) private var appState + + let notification: DocmostNotification + let isUnread: Bool + let markRead: () -> Void + let openPage: (PageOpenTarget) -> Void + + var body: some View { + Group { + if let target = PageOpenTarget(notification: notification) { + #if os(macOS) + Button { + if isUnread { + markRead() + } + appState.openPage(target) + } label: { + NotificationRowView(notification: notification, isUnread: isUnread) + } + .buttonStyle(.plain) + .accessibilityIdentifier("PageOpenLink.\(target.slugId)") + #else + Button { + openPage(target) + if isUnread { + markRead() + } + } label: { + HStack(spacing: 8) { + NotificationRowView(notification: notification, isUnread: isUnread) + + Image(systemName: "chevron.forward") + .imageScale(.small) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("PageOpenLink.\(target.slugId)") + #endif + } else { + NotificationRowView(notification: notification, isUnread: isUnread) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if isUnread { + Button("Mark as Read", systemImage: "checkmark", action: markRead) + .tint(.green) + } + } + .contextMenu { + if isUnread { + Button("Mark as Read", systemImage: "checkmark", action: markRead) + } + } + } +} diff --git a/docmostly/Features/Notifications/NotificationListView.swift b/docmostly/Features/Notifications/NotificationListView.swift index df5231a..107ec62 100644 --- a/docmostly/Features/Notifications/NotificationListView.swift +++ b/docmostly/Features/Notifications/NotificationListView.swift @@ -2,53 +2,57 @@ import SwiftUI struct NotificationListView: View { @Environment(AppState.self) private var appState + @Environment(NotificationStore.self) private var notificationStore + @Environment(\.scenePhase) private var scenePhase @State private var viewModel = NotificationListViewModel() + @State private var selectedPageTarget: PageOpenTarget? var body: some View { List { - Section { - Picker("Notification Type", selection: $viewModel.selectedType) { - ForEach(NotificationListType.displayCases, id: \.self) { type in - Text(type.title).tag(type) - } - } - .pickerStyle(.segmented) - } + NotificationListControlsView(viewModel: viewModel) - if viewModel.isLoading { + if viewModel.isLoading && viewModel.notifications.isEmpty { ProgressView("Loading notifications") - } - - Section(header: NotificationSectionHeaderView(unreadCount: viewModel.unreadCount)) { - ForEach(viewModel.notifications) { notification in - Group { - if let target = PageOpenTarget(notification: notification) { - PageOpenLink(target: target) { - NotificationRowView(notification: notification) - } - } else { - NotificationRowView(notification: notification) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if notification.isUnread { - Button("Mark Read", systemImage: "checkmark") { - Task { - await viewModel.markRead(notification, appState: appState) - } - } - .tint(.green) + .frame(maxWidth: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.notifications.isEmpty { + ContentUnavailableView { + Label("Couldn’t Load Notifications", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Try Again", systemImage: "arrow.clockwise", action: retry) + } + } else if viewModel.visibleNotifications.isEmpty { + ContentUnavailableView( + viewModel.selectedFilter == .unread ? "No Unread Notifications" : "No Notifications", + systemImage: "bell.slash", + description: Text(emptyDescription) + ) + } else { + ForEach(viewModel.sections) { group in + Section(group.section.title) { + ForEach(group.notifications) { notification in + NotificationListRow( + notification: notification, + isUnread: viewModel.isUnread(notification), + markRead: { markRead(notification) }, + openPage: { selectedPageTarget = $0 } + ) } } } } - if viewModel.notifications.isEmpty && viewModel.isLoading == false { - ContentUnavailableView("No Notifications", systemImage: "bell") + if viewModel.hasNextPage { + NotificationPaginationView( + isLoading: viewModel.isLoadingNextPage, + errorMessage: viewModel.nextPageErrorMessage, + loadNextPage: loadNextPage + ) } - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) + if let errorMessage = viewModel.errorMessage, viewModel.notifications.isEmpty == false { + Label(errorMessage, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(DocmostlyTheme.destructive) } @@ -57,27 +61,62 @@ struct NotificationListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Menu("Notification Actions", systemImage: "ellipsis.circle") { - Button("Mark All Read", systemImage: "checkmark.circle") { + Button("Mark All as Read", systemImage: "checkmark.circle") { Task { - await viewModel.markAllRead(appState: appState) + await viewModel.markAllRead(appState: appState, store: notificationStore) } } - .disabled(viewModel.unreadCount == 0 || viewModel.isMarkingAllRead) + .disabled(notificationStore.unreadCount == 0 || viewModel.isMarkingAllRead) - Button("Refresh", systemImage: "arrow.clockwise") { - Task { - await viewModel.load(appState: appState) - } - } + Button("Refresh", systemImage: "arrow.clockwise", action: retry) } } } .task(id: viewModel.selectedType) { - await viewModel.load(appState: appState) + await viewModel.load(appState: appState, store: notificationStore) + } + .task(id: notificationStore.contentRevision) { + guard notificationStore.contentRevision > 0 else { return } + await viewModel.load(appState: appState, store: notificationStore) + } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + retry() } .refreshable { - await viewModel.load(appState: appState) + await viewModel.load(appState: appState, store: notificationStore) + } + #if os(iOS) + .navigationDestination(item: $selectedPageTarget) { target in + PageOpenDestinationView(target: target) + } + #endif + } + + private var emptyDescription: String { + if viewModel.selectedFilter == .unread { + return viewModel.hasNextPage + ? "Load more to continue checking older notifications." + : "You’re all caught up." + } + return "Workspace activity and mentions will appear here." + } + + private func retry() { + Task { + await viewModel.load(appState: appState, store: notificationStore) + } + } + + private func loadNextPage() { + Task { + await viewModel.loadNextPage(appState: appState) + } + } + + private func markRead(_ notification: DocmostNotification) { + Task { + await viewModel.markRead(notification, appState: appState, store: notificationStore) } - .pageOpenDestination() } } diff --git a/docmostly/Features/Notifications/NotificationListViewModel.swift b/docmostly/Features/Notifications/NotificationListViewModel.swift index 17121c6..d52cb82 100644 --- a/docmostly/Features/Notifications/NotificationListViewModel.swift +++ b/docmostly/Features/Notifications/NotificationListViewModel.swift @@ -4,21 +4,67 @@ import Observation @MainActor @Observable final class NotificationListViewModel { - var notifications: [DocmostNotification] = [] + private static let pageSize = 30 + + private var pages = CursorPageAccumulator() + private(set) var locallyReadIDs: Set = [] + @ObservationIgnored private var loadRequestID: UUID? + var selectedType: NotificationListType = .all - var unreadCount = 0 + var selectedFilter: NotificationFilter = .all var isLoading = false + var isLoadingNextPage = false var isMarkingAllRead = false var markingReadIDs: Set = [] var errorMessage: String? + var nextPageErrorMessage: String? + + var notifications: [DocmostNotification] { + pages.items + } + + var visibleNotifications: [DocmostNotification] { + switch selectedFilter { + case .all: + notifications + case .unread: + notifications.filter(isUnread) + } + } + + var sections: [NotificationSectionGroup] { + let grouped = Dictionary(grouping: visibleNotifications) { + NotificationTimeSection.section(for: $0.createdAt) + } + return NotificationTimeSection.allCases.compactMap { section in + guard let notifications = grouped[section], notifications.isEmpty == false else { return nil } + return NotificationSectionGroup(section: section, notifications: notifications) + } + } - func load(appState: AppState) async { + var hasNextPage: Bool { + pages.hasNextPage + } + + func isUnread(_ notification: DocmostNotification) -> Bool { + notification.isUnread && locallyReadIDs.contains(notification.id) == false + } + + func load(appState: AppState, store: NotificationStore) async { + let requestID = UUID() + let requestedType = selectedType + loadRequestID = requestID isLoading = true errorMessage = nil - defer { isLoading = false } + nextPageErrorMessage = nil + defer { + if loadRequestID == requestID { + isLoading = false + } + } async let loadedNotifications = captureLoad { - try await appState.loadNotifications(type: selectedType, limit: 50) + try await appState.loadNotifications(type: requestedType, limit: Self.pageSize) } async let loadedUnreadCount = captureLoad { try await appState.loadUnreadNotificationCount() @@ -26,45 +72,124 @@ final class NotificationListViewModel { let notificationOutcome = await loadedNotifications let unreadCountOutcome = await loadedUnreadCount + guard Task.isCancelled == false, loadRequestID == requestID, selectedType == requestedType else { return } if let response = notificationOutcome.value { - notifications = response.items + applyInitialPage(response) } if let count = unreadCountOutcome.value { - unreadCount = count + store.reconcile(unreadCount: count) + } + errorMessage = notificationOutcome.errorMessage + if notificationOutcome.errorMessage == nil, let unreadError = unreadCountOutcome.errorMessage { + store.recordRefreshError(unreadError) } - errorMessage = notificationOutcome.errorMessage ?? unreadCountOutcome.errorMessage } - func markRead(_ notification: DocmostNotification, appState: AppState) async { - guard notification.isUnread else { return } - guard markingReadIDs.contains(notification.id) == false else { return } + func loadNextPage(appState: AppState) async { + guard isLoading == false, isLoadingNextPage == false, let cursor = pages.nextCursor else { return } + let requestedType = selectedType + isLoadingNextPage = true + nextPageErrorMessage = nil + defer { isLoadingNextPage = false } - markingReadIDs.insert(notification.id) - errorMessage = nil - defer { - markingReadIDs.remove(notification.id) + do { + let response = try await appState.loadNotifications( + type: requestedType, + cursor: cursor, + limit: Self.pageSize + ) + guard Task.isCancelled == false, selectedType == requestedType else { return } + applyNextPage(response, requestedCursor: cursor) + } catch is CancellationError { + return + } catch { + nextPageErrorMessage = error.localizedDescription + } + } + + func markRead( + _ notification: DocmostNotification, + appState: AppState, + store: NotificationStore + ) async { + await markRead(notification, store: store) { + try await appState.markNotificationsRead(notificationIds: [notification.id]) + } + if errorMessage == nil { + await store.refreshUnreadCount(appState: appState) + } + } + + func markAllRead(appState: AppState, store: NotificationStore) async { + await markAllRead(store: store) { + try await appState.markAllNotificationsRead() + } + if errorMessage == nil { + await load(appState: appState, store: store) } + } + + func applyInitialPage(_ response: PaginatedResponse) { + pages.replace(with: response) + locallyReadIDs = locallyReadIDs.filter { id in + response.items.contains { $0.id == id && $0.isUnread } + } + } + + func applyNextPage( + _ response: PaginatedResponse, + requestedCursor: String + ) { + pages.append(response, requestedCursor: requestedCursor) + } + + func markRead( + _ notification: DocmostNotification, + store: NotificationStore, + operation: () async throws -> Void + ) async { + guard isUnread(notification) else { return } + guard markingReadIDs.insert(notification.id).inserted else { return } + + let previousUnreadCount = store.applyOptimisticRead() + locallyReadIDs.insert(notification.id) + errorMessage = nil + defer { markingReadIDs.remove(notification.id) } do { - try await appState.markNotificationsRead(notificationIds: [notification.id]) - await load(appState: appState) + try await operation() + } catch is CancellationError { + locallyReadIDs.remove(notification.id) + store.restoreUnreadCount(previousUnreadCount) } catch { + locallyReadIDs.remove(notification.id) + store.restoreUnreadCount(previousUnreadCount) errorMessage = error.localizedDescription } } - func markAllRead(appState: AppState) async { + func markAllRead( + store: NotificationStore, + operation: () async throws -> Void + ) async { guard isMarkingAllRead == false else { return } isMarkingAllRead = true + let previousLocallyReadIDs = locallyReadIDs + let previousUnreadCount = store.applyOptimisticMarkAllRead() + locallyReadIDs.formUnion(notifications.lazy.filter(\.isUnread).map(\.id)) errorMessage = nil defer { isMarkingAllRead = false } do { - try await appState.markAllNotificationsRead() - await load(appState: appState) + try await operation() + } catch is CancellationError { + locallyReadIDs = previousLocallyReadIDs + store.restoreUnreadCount(previousUnreadCount) } catch { + locallyReadIDs = previousLocallyReadIDs + store.restoreUnreadCount(previousUnreadCount) errorMessage = error.localizedDescription } } @@ -74,12 +199,21 @@ final class NotificationListViewModel { ) async -> NotificationLoadOutcome { do { return NotificationLoadOutcome(value: try await operation(), errorMessage: nil) + } catch is CancellationError { + return NotificationLoadOutcome(value: nil, errorMessage: nil) } catch { return NotificationLoadOutcome(value: nil, errorMessage: error.localizedDescription) } } } +nonisolated struct NotificationSectionGroup: Identifiable, Sendable { + let section: NotificationTimeSection + let notifications: [DocmostNotification] + + var id: NotificationTimeSection { section } +} + private struct NotificationLoadOutcome: Sendable { let value: Value? let errorMessage: String? diff --git a/docmostly/Features/Notifications/NotificationPaginationView.swift b/docmostly/Features/Notifications/NotificationPaginationView.swift new file mode 100644 index 0000000..1f223db --- /dev/null +++ b/docmostly/Features/Notifications/NotificationPaginationView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct NotificationPaginationView: View { + let isLoading: Bool + let errorMessage: String? + let loadNextPage: () -> Void + + var body: some View { + VStack { + if isLoading { + ProgressView("Loading more") + } else if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(DocmostlyTheme.destructive) + Button("Try Again", systemImage: "arrow.clockwise", action: loadNextPage) + } else { + ProgressView() + .accessibilityLabel("Loading more notifications") + .task { + loadNextPage() + } + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/docmostly/Features/Notifications/NotificationRowView.swift b/docmostly/Features/Notifications/NotificationRowView.swift index 182734e..38f017f 100644 --- a/docmostly/Features/Notifications/NotificationRowView.swift +++ b/docmostly/Features/Notifications/NotificationRowView.swift @@ -2,11 +2,11 @@ import SwiftUI struct NotificationRowView: View { let notification: DocmostNotification + let isUnread: Bool var body: some View { HStack(alignment: .top, spacing: 10) { - Image(systemName: notification.isUnread ? "bell.badge" : "bell") - .foregroundStyle(notification.isUnread ? DocmostlyTheme.primary : .secondary) + NotificationActorView(notification: notification, isUnread: isUnread) .frame(width: 24) VStack(alignment: .leading, spacing: 4) { @@ -25,6 +25,18 @@ struct NotificationRowView: View { .foregroundStyle(.tertiary) } } + + Spacer(minLength: 0) + + if isUnread { + Image(systemName: "circle.fill") + .font(.caption2) + .foregroundStyle(DocmostlyTheme.primary) + .accessibilityHidden(true) + } } + .contentShape(.rect) + .accessibilityElement(children: .combine) + .accessibilityValue(isUnread ? "Unread" : "Read") } } diff --git a/docmostly/Features/Notifications/NotificationSocketClient.swift b/docmostly/Features/Notifications/NotificationSocketClient.swift new file mode 100644 index 0000000..119b900 --- /dev/null +++ b/docmostly/Features/Notifications/NotificationSocketClient.swift @@ -0,0 +1,155 @@ +import Foundation + +nonisolated enum NotificationSocketEvent: Equatable, Sendable { + case connected + case notification + case disconnected +} + +actor NotificationSocketClient { + private let urlSession: URLSession + private var task: URLSessionWebSocketTask? + + init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + } + + func events( + url: URL, + cookies: [StoredHTTPCookie] + ) -> AsyncThrowingStream { + let streamPair = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingNewest(10) + ) + let receiver = Task { + await connect(url: url, cookies: cookies, continuation: streamPair.continuation) + } + + streamPair.continuation.onTermination = { _ in + receiver.cancel() + Task { + await self.disconnect() + } + } + return streamPair.stream + } + + func disconnect() { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } + + private func connect( + url: URL, + cookies: [StoredHTTPCookie], + continuation: AsyncThrowingStream.Continuation + ) async { + disconnect() + let task = urlSession.webSocketTask(with: Self.request(url: url, cookies: cookies)) + self.task = task + task.resume() + + do { + try await receiveMessages(from: task, continuation: continuation) + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + disconnect() + } + + private func receiveMessages( + from task: URLSessionWebSocketTask, + continuation: AsyncThrowingStream.Continuation + ) async throws { + while Task.isCancelled == false { + let message = try await task.receive() + switch try Self.parse(Self.string(from: message)) { + case .open: + try await send("40") + case .ping: + try await send("3") + case .connected: + continuation.yield(.connected) + case .notification: + continuation.yield(.notification) + case .disconnected: + continuation.yield(.disconnected) + return + case .unauthorized: + throw APIError.connectionFailed("Notification socket unauthorized.") + case .ignored: + break + } + } + } + + private func send(_ message: String) async throws { + guard let task else { throw URLError(.notConnectedToInternet) } + try await task.send(.string(message)) + } + + private nonisolated static func request( + url: URL, + cookies: [StoredHTTPCookie] + ) -> URLRequest { + var request = URLRequest(url: url) + request.httpShouldHandleCookies = false + if cookies.isEmpty == false { + let cookieHeader = cookies + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + return request + } + + private nonisolated static func string(from message: URLSessionWebSocketTask.Message) throws -> String { + switch message { + case .string(let text): + guard text.count <= 1_000_000 else { throw URLError(.dataLengthExceedsMaximum) } + return text + case .data(let data): + guard data.count <= 1_000_000 else { throw URLError(.dataLengthExceedsMaximum) } + return String(bytes: data, encoding: .utf8) ?? "" + @unknown default: + return "" + } + } + + private nonisolated static func parse(_ text: String) throws -> NotificationSocketFrame { + let frame = text.trimmingCharacters(in: .whitespacesAndNewlines) + if frame == "2" { return .ping } + if frame == "41" { return .disconnected } + if frame.first == "0" { return .open } + if frame.hasPrefix("40") { return .connected } + guard frame.hasPrefix("42") else { return .ignored } + + let data = Data(frame.dropFirst(2).utf8) + let envelope = try JSONDecoder().decode(NotificationSocketEnvelope.self, from: data) + if envelope.name == "notification" { return .notification } + if envelope.name == "Unauthorized" { return .unauthorized } + return .ignored + } +} + +nonisolated private enum NotificationSocketFrame: Sendable { + case open + case ping + case connected + case notification + case disconnected + case unauthorized + case ignored +} + +nonisolated private struct NotificationSocketEnvelope: Decodable, Sendable { + let name: String + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + name = try container.decode(String.self) + } +} diff --git a/docmostly/Features/Notifications/NotificationStore.swift b/docmostly/Features/Notifications/NotificationStore.swift new file mode 100644 index 0000000..ae10bde --- /dev/null +++ b/docmostly/Features/Notifications/NotificationStore.swift @@ -0,0 +1,101 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class NotificationStore { + private(set) var unreadCount = 0 + private(set) var contentRevision = 0 + private(set) var isRefreshingUnreadCount = false + private(set) var errorMessage: String? + + @ObservationIgnored private let socketClient = NotificationSocketClient() + + func refreshUnreadCount(appState: AppState) async { + guard isRefreshingUnreadCount == false else { return } + isRefreshingUnreadCount = true + defer { isRefreshingUnreadCount = false } + + do { + unreadCount = try await appState.loadUnreadNotificationCount() + errorMessage = nil + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + } + } + + func reconcile(unreadCount: Int) { + self.unreadCount = max(unreadCount, 0) + errorMessage = nil + } + + @discardableResult + func applyOptimisticRead(count: Int = 1) -> Int { + let previousCount = unreadCount + unreadCount = max(unreadCount - count, 0) + return previousCount + } + + @discardableResult + func applyOptimisticMarkAllRead() -> Int { + let previousCount = unreadCount + unreadCount = 0 + return previousCount + } + + func restoreUnreadCount(_ count: Int) { + unreadCount = max(count, 0) + } + + func recordRefreshError(_ message: String) { + errorMessage = message + } + + func pollUnreadCount(appState: AppState) async { + while Task.isCancelled == false { + await refreshUnreadCount(appState: appState) + do { + try await Task.sleep(for: .seconds(60)) + } catch { + return + } + } + } + + func monitorRealtime(appState: AppState) async { + var reconnectPolicy = NativeEditorRealtimeReconnectPolicy() + + while Task.isCancelled == false { + do { + let url = try appState.realtimeEventWebSocketURL() + let cookies = await appState.activeSessionCookies(for: url) + let events = await socketClient.events(url: url, cookies: cookies) + + for try await event in events { + guard Task.isCancelled == false else { return } + switch event { + case .connected: + reconnectPolicy.reset() + case .notification: + contentRevision &+= 1 + await refreshUnreadCount(appState: appState) + case .disconnected: + break + } + } + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + } + + do { + try await Task.sleep(for: .seconds(reconnectPolicy.nextDelaySeconds())) + } catch { + return + } + } + } +} diff --git a/docmostly/Features/Notifications/NotificationTimeSection.swift b/docmostly/Features/Notifications/NotificationTimeSection.swift new file mode 100644 index 0000000..d88637b --- /dev/null +++ b/docmostly/Features/Notifications/NotificationTimeSection.swift @@ -0,0 +1,35 @@ +import Foundation + +nonisolated enum NotificationTimeSection: Int, CaseIterable, Identifiable, Sendable { + case today + case yesterday + case thisWeek + case older + + var id: Self { self } + + var title: String { + switch self { + case .today: + "Today" + case .yesterday: + "Yesterday" + case .thisWeek: + "This Week" + case .older: + "Older" + } + } + + static func section(for date: Date?, now: Date = .now, calendar: Calendar = .autoupdatingCurrent) -> Self { + guard let date else { return .older } + let startOfToday = calendar.startOfDay(for: now) + guard date < startOfToday else { return .today } + + let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday + guard date < startOfYesterday else { return .yesterday } + + let startOfWeek = calendar.date(byAdding: .day, value: -7, to: startOfToday) ?? startOfYesterday + return date >= startOfWeek ? .thisWeek : .older + } +} diff --git a/docmostly/Features/PageReader/CommentRowView.swift b/docmostly/Features/PageReader/CommentRowView.swift index 036da67..e986077 100644 --- a/docmostly/Features/PageReader/CommentRowView.swift +++ b/docmostly/Features/PageReader/CommentRowView.swift @@ -10,16 +10,21 @@ struct CommentRowView: View { let isEditing: Bool let isUpdating: Bool let isDeleting: Bool - let editDraft: Binding? + let editDraft: CommentComposerState? + let errorMessage: String? + let isFocused: Bool let toggleResolved: () -> Void let beginEditing: () -> Void let cancelEditing: () -> Void let saveEditing: () -> Void let deleteComment: () -> Void + let focusInlineComment: () -> Void var body: some View { HStack(alignment: .top) { - VStack(alignment: .leading) { + CommentAvatarView(name: comment.creator?.name) + + VStack(alignment: .leading, spacing: 6) { HStack { Text(comment.creator?.name ?? "Comment") .font(.subheadline) @@ -27,50 +32,60 @@ struct CommentRowView: View { if isReply { Label("Reply", systemImage: "arrow.turn.down.right") - .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } } - if isEditing, let editDraft { - TextField("Edit comment", text: editDraft, axis: .vertical) - .lineLimit(2...) - .textFieldStyle(.roundedBorder) - } else { - Text(comment.content ?? "") - .font(.body) - .foregroundStyle(comment.isResolved ? .secondary : .primary) - } - - HStack { - if let createdAt = comment.createdAt { - Text(createdAt.formatted(date: .abbreviated, time: .shortened)) + if isReply == false, + let selection = comment.selection?.trimmingCharacters(in: .whitespacesAndNewlines), + selection.isEmpty == false { + Button(action: focusInlineComment) { + Label { + Text(selection) + .lineLimit(4) + } icon: { + Image(systemName: "quote.opening") + } + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) + .font(.callout) + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: .rect(cornerRadius: 8)) + .accessibilityLabel("Commented text: \(selection)") + } - if comment.isResolved { - Label("Resolved", systemImage: "checkmark.seal.fill") - } + if isEditing, let editDraft { + CommentRichComposerView( + draft: editDraft, + placeholder: "Edit comment", + submitTitle: "Save", + accessibilityIdentifier: "comment-edit-field-\(comment.id)", + isEnabled: true, + isSubmitting: isUpdating, + autofocus: true, + submit: saveEditing, + cancel: cancelEditing + ) + } else { + CommentBodyView(comment: comment) } - .font(.caption) - .foregroundStyle(.secondary) - if isEditing { - HStack { - Button("Cancel", systemImage: "xmark", action: cancelEditing) - .controlSize(.small) + CommentStateMetadataView(comment: comment) - Button("Save", systemImage: "checkmark", action: saveEditing) - .controlSize(.small) - .disabled(isUpdating) - } - .buttonStyle(.bordered) + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(DocmostlyTheme.destructive) } } Spacer(minLength: 0) - Menu { + Menu("Comment Actions", systemImage: "ellipsis.circle") { if canEdit { Button("Edit Comment", systemImage: "pencil", action: beginEditing) } @@ -88,16 +103,61 @@ struct CommentRowView: View { Button("Delete Comment", systemImage: "trash", role: .destructive, action: deleteComment) .disabled(isDeleting) } - } label: { - Image(systemName: "ellipsis.circle") - .font(.title3) - .padding(.horizontal) - .contentShape(.rect) - .accessibilityLabel("Comment Actions") } + .labelStyle(.iconOnly) .menuStyle(.button) .disabled((canEdit || canToggleResolved || canDelete) == false) + .accessibilityLabel("Comment Actions") } .padding(.vertical, 8) + .padding(.horizontal, isFocused ? 8 : 0) + .background(isFocused ? DocmostlyTheme.primaryTint : .clear, in: .rect(cornerRadius: 8)) + .accessibilityAddTraits(isFocused ? .isSelected : []) + } +} + +private struct CommentAvatarView: View { + let name: String? + + var body: some View { + Text(initials) + .font(.caption) + .bold() + .foregroundStyle(DocmostlyTheme.primary) + .frame(width: 30, height: 30) + .background(DocmostlyTheme.primaryTint, in: .circle) + .accessibilityLabel(name ?? "Comment author") + } + + private var initials: String { + let parts = (name ?? "?").split(separator: " ").prefix(2) + let value = parts.compactMap(\.first).map(String.init).joined() + return value.isEmpty ? "?" : value.uppercased() + } +} + +private struct CommentStateMetadataView: View { + let comment: DocmostComment + + var body: some View { + HStack { + if let createdAt = comment.createdAt { + Text(createdAt.formatted(date: .abbreviated, time: .shortened)) + } + + if comment.editedAt != nil { + Label("Edited", systemImage: "pencil") + } + + if comment.isLocallyQueued { + Label("Queued", systemImage: "clock.arrow.circlepath") + } + + if comment.isResolved { + Label("Resolved", systemImage: "checkmark.seal.fill") + } + } + .font(.caption) + .foregroundStyle(.secondary) } } diff --git a/docmostly/Features/PageReader/PageExportSheet.swift b/docmostly/Features/PageReader/PageExportSheet.swift index 8b62629..3a11b2d 100644 --- a/docmostly/Features/PageReader/PageExportSheet.swift +++ b/docmostly/Features/PageReader/PageExportSheet.swift @@ -37,20 +37,17 @@ struct PageExportSheet: View { } Section { - GlassEffectContainer(spacing: 12) { - DocmostlyGlassPanel(cornerRadius: 16) { - Button("Export", systemImage: "square.and.arrow.down") { - exportTask = Task { @MainActor in - await viewModel.export(pageID: pageID, appState: appState) - exportTask = nil - } - } - .buttonStyle(.glassProminent) - .disabled(viewModel.isExporting) - .frame(maxWidth: .infinity) - .padding() + Button("Export", systemImage: "square.and.arrow.down") { + exportTask = Task { @MainActor in + await viewModel.export(pageID: pageID, appState: appState) + exportTask = nil } } + .labelStyle(.titleAndIcon) + .buttonStyle(.glassProminent) + .controlSize(.large) + .disabled(viewModel.isExporting) + .frame(maxWidth: .infinity) } } .navigationTitle("Export Page") diff --git a/docmostly/Features/PageReader/PageHistoryViewModel.swift b/docmostly/Features/PageReader/PageHistoryViewModel.swift index 4609193..29cec2d 100644 --- a/docmostly/Features/PageReader/PageHistoryViewModel.swift +++ b/docmostly/Features/PageReader/PageHistoryViewModel.swift @@ -105,12 +105,10 @@ final class PageHistoryViewModel { } let preRestoreSnapshot = editorViewModel.makeHistorySnapshot() - var didApplySnapshot = false do { try await editorViewModel.waitForPendingCRDTLocalChange() editorViewModel.applyServerHistorySnapshot(title: version.title, document: content) - didApplySnapshot = true if editorViewModel.canSave { guard await editorViewModel.save(appState: appState) else { restoreErrorMessage = editorViewModel.saveErrorMessage ?? "Could not restore this version." @@ -125,9 +123,6 @@ final class PageHistoryViewModel { } return true } catch { - if didApplySnapshot { - editorViewModel.restoreEditingSnapshot(preRestoreSnapshot) - } restoreErrorMessage = error.localizedDescription return false } diff --git a/docmostly/Features/PageReader/PageOpenTarget.swift b/docmostly/Features/PageReader/PageOpenTarget.swift index 5b30694..a6285a2 100644 --- a/docmostly/Features/PageReader/PageOpenTarget.swift +++ b/docmostly/Features/PageReader/PageOpenTarget.swift @@ -5,17 +5,20 @@ nonisolated struct PageOpenTarget: Identifiable, Hashable, Sendable { let slugId: String let spaceId: String? let revealSpaceInSidebar: Bool + let commentId: String? init( id: String, slugId: String, spaceId: String?, - revealSpaceInSidebar: Bool = false + revealSpaceInSidebar: Bool = false, + commentId: String? = nil ) { self.id = id self.slugId = slugId self.spaceId = spaceId self.revealSpaceInSidebar = revealSpaceInSidebar + self.commentId = commentId } init(page: DocmostPage, revealSpaceInSidebar: Bool = false) { @@ -74,7 +77,8 @@ nonisolated struct PageOpenTarget: Identifiable, Hashable, Sendable { id: page.id, slugId: page.slugId, spaceId: notification.spaceId ?? notification.space?.id, - revealSpaceInSidebar: revealSpaceInSidebar + revealSpaceInSidebar: revealSpaceInSidebar, + commentId: notification.commentId ) } } @@ -118,10 +122,13 @@ struct PageOpenDestinationView: View { let target: PageOpenTarget var body: some View { - PageReaderView(pageID: target.slugId) + PageReaderView(pageID: target.slugId, initialCommentID: target.commentId) .task(id: target.id) { appState.openPage(target) } + .onDisappear { + appState.clearSelectedPage(ifMatching: target.slugId) + } } } diff --git a/docmostly/Features/PageReader/PageReaderChromeState.swift b/docmostly/Features/PageReader/PageReaderChromeState.swift index 9aa3ec6..5b8796e 100644 --- a/docmostly/Features/PageReader/PageReaderChromeState.swift +++ b/docmostly/Features/PageReader/PageReaderChromeState.swift @@ -16,6 +16,57 @@ enum PageReaderMode: String, CaseIterable, Identifiable { } } +nonisolated struct PageReaderCollaborationPresenceTaskKey: Equatable, Sendable { + let pageID: String + let participation: NativeEditorCollaborationParticipation +} + +nonisolated enum PageReaderCollaborationTaskKeys { + static func collaborationPresence( + pageID: String, + participation: NativeEditorCollaborationParticipation + ) -> PageReaderCollaborationPresenceTaskKey { + PageReaderCollaborationPresenceTaskKey(pageID: pageID, participation: participation) + } + + static func collaborationPresence( + pageID: String?, + participation: NativeEditorCollaborationParticipation, + isVisible: Bool = true + ) -> PageReaderCollaborationPresenceTaskKey? { + guard let pageID, isVisible else { return nil } + return PageReaderCollaborationPresenceTaskKey(pageID: pageID, participation: participation) + } + + static func realtimeEvents( + pageID: String, + participation: NativeEditorCollaborationParticipation + ) -> String { + pageID + } + + static func realtimeEvents( + pageID: String?, + participation: NativeEditorCollaborationParticipation + ) -> String? { + pageID + } + + static func crdtDocumentSnapshots( + pageID: String, + participation: NativeEditorCollaborationParticipation + ) -> String { + pageID + } + + static func crdtDocumentSnapshots( + pageID: String?, + participation: NativeEditorCollaborationParticipation + ) -> String? { + pageID + } +} + enum PageReaderPanel: String, Identifiable { case details case comments diff --git a/docmostly/Features/PageReader/PageReaderCommentsPanelView.swift b/docmostly/Features/PageReader/PageReaderCommentsPanelView.swift index 88d4415..16aeb08 100644 --- a/docmostly/Features/PageReader/PageReaderCommentsPanelView.swift +++ b/docmostly/Features/PageReader/PageReaderCommentsPanelView.swift @@ -5,8 +5,13 @@ struct PageReaderCommentsPanelView: View { @Bindable var viewModel: PageReaderViewModel @State private var selectedTab = PageReaderCommentTab.open @State private var pendingDeleteComment: DocmostComment? + @State private var scrollPosition = ScrollPosition() + @AccessibilityFocusState private var accessibilityFocusedThreadID: String? let pageID: String + let canComment: Bool + let focusedCommentID: String? + let focusInlineComment: (String) -> Void let markInlineCommentResolved: (String, Bool) async -> Void let removeInlineComment: (String) async -> Void @@ -34,53 +39,49 @@ struct PageReaderCommentsPanelView: View { CommentThreadView( comment: comment, replies: viewModel.replies(for: comment.id), - isResolvingComment: viewModel.isResolvingComment, + viewModel: viewModel, + canComment: canComment, + isReplyEnabled: appState.isOffline == false, + focusedCommentID: focusedCommentID, canEditComment: canEdit, canDeleteComment: canDelete, - canToggleResolved: appState.isOffline == false, - isEditingComment: viewModel.isEditingComment, - isUpdatingComment: viewModel.isUpdatingComment, - isDeletingComment: viewModel.isDeletingComment, - editDraft: editDraftBinding, - replyDraft: replyDraftBinding(for: comment.id), - isReplyComposerEnabled: appState.isOffline == false - && comment.isLocallyQueued == false, - canPostReply: canPostReply(to: comment), - isPostingReply: viewModel.isPostingReply(to: comment.id), - toggleResolved: { _ in - toggleResolved(comment) - }, - beginEditing: { _ in - viewModel.beginEditing(comment) - }, - cancelEditing: { _ in - viewModel.cancelEditing(commentID: comment.id) - }, - saveEditing: { _ in - updateComment(comment) - }, - deleteComment: deleteComment, - postReply: { - postReply(to: comment) - } + canToggleResolved: canToggleResolved, + toggleResolved: toggleResolved, + updateComment: updateComment, + deleteComment: requestDelete, + postReply: postReply, + focusInlineComment: focusInlineComment ) + .id(comment.id) + .accessibilityFocused($accessibilityFocusedThreadID, equals: comment.id) } } - PageCommentComposerView( - draftComment: $viewModel.draftComment, - canPostComment: canPostComment, - postComment: postComment - ) + if canComment { + PageCommentComposerView( + draft: viewModel.draftComment, + isSubmitting: viewModel.isPostingComment, + isEnabled: true, + postComment: postComment + ) + } else { + Label("You can view comments on this page, but you cannot add one.", systemImage: "lock") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.vertical) + } } .frame(maxWidth: .infinity, alignment: .leading) + .scrollTargetLayout() } + .scrollPosition($scrollPosition) .scrollIndicators(.hidden) if let errorMessage = viewModel.commentErrorMessage { - Text(errorMessage) + Label(errorMessage, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(DocmostlyTheme.destructive) + .accessibilityLabel("Comment error: \(errorMessage)") } } .confirmationDialog("Delete this comment?", isPresented: deleteConfirmationBinding) { @@ -91,6 +92,9 @@ struct PageReaderCommentsPanelView: View { } message: { Text("Replies in this thread will also be removed.") } + .task(id: focusedCommentID) { + await focusRequestedThread() + } } private var visibleComments: [DocmostComment] { @@ -102,6 +106,18 @@ struct PageReaderCommentsPanelView: View { } } + private func focusRequestedThread() async { + guard let focusedCommentID, + let rootComment = viewModel.rootComment(containing: focusedCommentID) else { + return + } + + selectedTab = rootComment.isResolved ? .resolved : .open + await Task.yield() + scrollPosition.scrollTo(id: rootComment.id, anchor: .center) + accessibilityFocusedThreadID = rootComment.id + } + private var emptyTitle: String { switch selectedTab { case .open: @@ -135,11 +151,6 @@ struct PageReaderCommentsPanelView: View { } } - private var canPostComment: Bool { - viewModel.draftComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - && viewModel.isPostingComment == false - } - private func toggleResolved(_ comment: DocmostComment) { Task { await viewModel.toggleResolved( @@ -151,7 +162,7 @@ struct PageReaderCommentsPanelView: View { } } - private func postReply(to comment: DocmostComment) { + private func postReply(_ comment: DocmostComment) { Task { await viewModel.postReply(to: comment, pageID: pageID, appState: appState) } @@ -163,7 +174,7 @@ struct PageReaderCommentsPanelView: View { } } - private func deleteComment(_ comment: DocmostComment) { + private func requestDelete(_ comment: DocmostComment) { pendingDeleteComment = comment } @@ -179,40 +190,35 @@ struct PageReaderCommentsPanelView: View { } } - private func canPostReply(to comment: DocmostComment) -> Bool { - appState.isOffline == false - && viewModel.canSubmitReply(to: comment) - } - private func canEdit(_ comment: DocmostComment) -> Bool { - appState.isOffline == false - && comment.isNativelyEditable - && canMutate(comment) + CommentPermissionPolicy.canEdit( + comment, + currentUserID: appState.currentUser?.user.id, + canComment: canComment, + isOnline: appState.isOffline == false + ) } private func canDelete(_ comment: DocmostComment) -> Bool { - appState.isOffline == false - && appState.currentUserCanDeleteComment(comment) + CommentPermissionPolicy.canDelete( + comment, + currentUserID: appState.currentUser?.user.id, + isSpaceAdmin: isSpaceAdmin(for: comment), + isOnline: appState.isOffline == false + ) } - private func canMutate(_ comment: DocmostComment) -> Bool { - appState.currentUser?.user.id == comment.creatorId + private func canToggleResolved(_ comment: DocmostComment) -> Bool { + CommentPermissionPolicy.canResolve( + comment, + canComment: canComment, + isOnline: appState.isOffline == false + ) } - private func replyDraftBinding(for commentID: String) -> Binding { - Binding { - viewModel.replyDraftsByCommentID[commentID] ?? "" - } set: { newValue in - viewModel.replyDraftsByCommentID[commentID] = newValue - } - } - - private func editDraftBinding(for commentID: String) -> Binding { - Binding { - viewModel.editDraftsByCommentID[commentID] ?? "" - } set: { newValue in - viewModel.editDraftsByCommentID[commentID] = newValue - } + private func isSpaceAdmin(for comment: DocmostComment) -> Bool { + guard let spaceID = comment.spaceId else { return false } + return appState.spaces.first { $0.id == spaceID }?.membership?.role == "admin" } private var deleteConfirmationBinding: Binding { @@ -225,160 +231,3 @@ struct PageReaderCommentsPanelView: View { } } } - -private struct CommentThreadView: View { - let comment: DocmostComment - let replies: [DocmostComment] - let isResolvingComment: (String) -> Bool - let canEditComment: (DocmostComment) -> Bool - let canDeleteComment: (DocmostComment) -> Bool - let canToggleResolved: Bool - let isEditingComment: (String) -> Bool - let isUpdatingComment: (String) -> Bool - let isDeletingComment: (String) -> Bool - let editDraft: (String) -> Binding - let replyDraft: Binding - let isReplyComposerEnabled: Bool - let canPostReply: Bool - let isPostingReply: Bool - let toggleResolved: (DocmostComment) -> Void - let beginEditing: (DocmostComment) -> Void - let cancelEditing: (DocmostComment) -> Void - let saveEditing: (DocmostComment) -> Void - let deleteComment: (DocmostComment) -> Void - let postReply: () -> Void - - var body: some View { - DocmostlyGlassPanel(cornerRadius: 12) { - VStack(alignment: .leading) { - CommentRowView( - comment: comment, - isReply: false, - isResolving: isResolvingComment(comment.id), - canToggleResolved: canToggleResolved, - canEdit: canEditComment(comment), - canDelete: canDeleteComment(comment), - isEditing: isEditingComment(comment.id), - isUpdating: isUpdatingComment(comment.id), - isDeleting: isDeletingComment(comment.id), - editDraft: editDraft(comment.id), - toggleResolved: { - toggleResolved(comment) - }, - beginEditing: { - beginEditing(comment) - }, - cancelEditing: { - cancelEditing(comment) - }, - saveEditing: { - saveEditing(comment) - }, - deleteComment: { - deleteComment(comment) - } - ) - - if replies.isEmpty == false { - VStack(alignment: .leading) { - ForEach(replies) { reply in - CommentRowView( - comment: reply, - isReply: true, - isResolving: isResolvingComment(reply.id), - canToggleResolved: false, - canEdit: canEditComment(reply), - canDelete: canDeleteComment(reply), - isEditing: isEditingComment(reply.id), - isUpdating: isUpdatingComment(reply.id), - isDeleting: isDeletingComment(reply.id), - editDraft: editDraft(reply.id), - toggleResolved: {}, - beginEditing: { - beginEditing(reply) - }, - cancelEditing: { - cancelEditing(reply) - }, - saveEditing: { - saveEditing(reply) - }, - deleteComment: { - deleteComment(reply) - } - ) - .padding(.leading) - } - } - } - - if comment.isResolved == false { - CommentReplyComposerView( - commentID: comment.id, - replyDraft: replyDraft, - isEnabled: isReplyComposerEnabled, - canPostReply: canPostReply, - isPostingReply: isPostingReply, - postReply: postReply - ) - } - } - .padding() - } - } -} - -private struct CommentReplyComposerView: View { - let commentID: String - let replyDraft: Binding - let isEnabled: Bool - let canPostReply: Bool - let isPostingReply: Bool - let postReply: () -> Void - - var body: some View { - VStack(alignment: .leading) { - Divider() - - TextField("Write a reply", text: replyDraft, axis: .vertical) - .lineLimit(2...) - .textFieldStyle(.roundedBorder) - .disabled(isEnabled == false || isPostingReply) - .accessibilityIdentifier("comment-reply-field-\(commentID)") - - HStack { - Spacer(minLength: 0) - - Button("Post Reply", systemImage: "arrowshape.turn.up.left", action: postReply) - .buttonStyle(.borderedProminent) - .controlSize(.small) - .fixedSize(horizontal: true, vertical: false) - .disabled(canPostReply == false) - .accessibilityIdentifier("comment-reply-submit-\(commentID)") - } - } - .padding(.top) - } -} - -private struct PageCommentComposerView: View { - let draftComment: Binding - let canPostComment: Bool - let postComment: () -> Void - - var body: some View { - VStack(alignment: .leading) { - Divider() - - TextField("New page comment", text: draftComment, axis: .vertical) - .lineLimit(3...) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("page-comment-composer-field") - - Button("Add Comment", systemImage: "text.bubble", action: postComment) - .buttonStyle(.borderedProminent) - .disabled(canPostComment == false) - .accessibilityIdentifier("page-comment-submit-button") - } - } -} diff --git a/docmostly/Features/PageReader/PageReaderDestinationView.swift b/docmostly/Features/PageReader/PageReaderDestinationView.swift index fa58dfd..0c70a07 100644 --- a/docmostly/Features/PageReader/PageReaderDestinationView.swift +++ b/docmostly/Features/PageReader/PageReaderDestinationView.swift @@ -10,5 +10,8 @@ struct PageReaderDestinationView: View { .task(id: pageID) { appState.selectPage(id: pageID) } + .onDisappear { + appState.clearSelectedPage(ifMatching: pageID) + } } } diff --git a/docmostly/Features/PageReader/PageReaderPageSwitchHandoff.swift b/docmostly/Features/PageReader/PageReaderPageSwitchHandoff.swift new file mode 100644 index 0000000..38abad2 --- /dev/null +++ b/docmostly/Features/PageReader/PageReaderPageSwitchHandoff.swift @@ -0,0 +1,51 @@ +import Foundation + +@MainActor +enum PageReaderPageSwitchHandoff { + enum FlushResult: Equatable { + case completed + case retry + case failed + } + + enum Outcome: Equatable { + case completed + case outgoingFlushFailed + case cancelled + case outgoingChanged + } + + static func perform( + requiresInitialOutgoingFlush: Bool = false, + hasOutgoingChanges: () -> Bool, + flushOutgoing: () async -> FlushResult, + detachOutgoing: () -> Bool, + loadIncoming: () async -> Void + ) async -> Outcome { + var requiresOutgoingFlush = requiresInitialOutgoingFlush + while requiresOutgoingFlush || hasOutgoingChanges() { + let flushResult = await flushOutgoing() + if Task.isCancelled { + return .cancelled + } + switch flushResult { + case .completed: + requiresOutgoingFlush = false + case .retry: + continue + case .failed: + return .outgoingFlushFailed + } + } + + guard Task.isCancelled == false else { + return .cancelled + } + guard detachOutgoing() else { + return .outgoingChanged + } + + await loadIncoming() + return Task.isCancelled ? .cancelled : .completed + } +} diff --git a/docmostly/Features/PageReader/PageReaderSupplementaryPanelView.swift b/docmostly/Features/PageReader/PageReaderSupplementaryPanelView.swift index beefc4f..17ca587 100644 --- a/docmostly/Features/PageReader/PageReaderSupplementaryPanelView.swift +++ b/docmostly/Features/PageReader/PageReaderSupplementaryPanelView.swift @@ -1,6 +1,7 @@ import SwiftUI struct PageReaderSupplementaryPanelView: View { + @Environment(AppState.self) private var appState @Bindable var viewModel: PageReaderViewModel let panel: PageReaderPanel @@ -14,6 +15,8 @@ struct PageReaderSupplementaryPanelView: View { let serverURLString: String let tableOfContentsItems: [PageReaderTableOfContentsItem] let selectHeading: (PageReaderTableOfContentsItem) -> Void + let focusedCommentID: String? + let focusInlineComment: (String) -> Void let markInlineCommentResolved: (String, Bool) async -> Void let removeInlineComment: (String) async -> Void let loadSharingState: () async -> Void @@ -48,6 +51,9 @@ struct PageReaderSupplementaryPanelView: View { PageReaderCommentsPanelView( viewModel: viewModel, pageID: pageID, + canComment: canComment, + focusedCommentID: focusedCommentID, + focusInlineComment: focusInlineComment, markInlineCommentResolved: markInlineCommentResolved, removeInlineComment: removeInlineComment ) @@ -80,4 +86,14 @@ struct PageReaderSupplementaryPanelView: View { .padding() .frame(minWidth: 280, idealWidth: 340, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private var canComment: Bool { + let allowViewerComments = appState.spaces + .first { $0.id == editorViewModel.currentSpaceID }? + .settings?.comments?.allowViewerComments == true + return CommentPermissionPolicy.canCreate( + pageCanEdit: canEdit, + allowViewerComments: allowViewerComments + ) + } } diff --git a/docmostly/Features/PageReader/PageReaderToolbarContent.swift b/docmostly/Features/PageReader/PageReaderToolbarContent.swift index db00177..e51450d 100644 --- a/docmostly/Features/PageReader/PageReaderToolbarContent.swift +++ b/docmostly/Features/PageReader/PageReaderToolbarContent.swift @@ -19,12 +19,6 @@ extension PageReaderView { } #endif } - - if let editorViewModel, editorViewModel.isSaving { - ToolbarItem(placement: .primaryAction) { - ProgressView() - } - } } private func pageModePicker(editorViewModel: NativeRichEditorViewModel) -> some View { diff --git a/docmostly/Features/PageReader/PageReaderView+Actions.swift b/docmostly/Features/PageReader/PageReaderView+Actions.swift index f8d8985..f6a5f5d 100644 --- a/docmostly/Features/PageReader/PageReaderView+Actions.swift +++ b/docmostly/Features/PageReader/PageReaderView+Actions.swift @@ -2,82 +2,6 @@ import SwiftUI import UniformTypeIdentifiers extension PageReaderView { - func retry() { - Task { - await loadNativePage() - } - } - - func loadNativePage() async { - editorFocusedField = nil - realtimePageID = nil - editorViewModel = nil - - for attempt in 0..<2 { - let editorViewModel = NativeRichEditorViewModel(pageID: pageID, initialTitle: initialTitle ?? "") - let didLoadPage = await editorViewModel.load(appState: appState) - guard Task.isCancelled == false else { return } - - if didLoadPage { - await finishNativePageLoad(editorViewModel) - return - } - - if editorViewModel.errorMessage != nil { - self.editorViewModel = editorViewModel - return - } - - guard attempt == 0 else { - editorViewModel.errorMessage = "Page loading was interrupted." - self.editorViewModel = editorViewModel - return - } - - do { - try await Task.sleep(for: .milliseconds(250)) - } catch { - return - } - } - } - - private func finishNativePageLoad(_ editorViewModel: NativeRichEditorViewModel) async { - self.editorViewModel = editorViewModel - if let currentSpaceID = editorViewModel.currentSpaceID { - pageLoaded(editorViewModel.currentPageSlugID, currentSpaceID, editorViewModel.title) - } - if editorViewModel.canEdit == false { - readerMode = .read - } - - async let attachCRDT: Void = NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( - to: editorViewModel, - appState: appState - ) - async let loadCompanions: Void = viewModel.loadCompanions( - pageID: editorViewModel.currentPageID, - appState: appState - ) - await attachCRDT - guard Task.isCancelled == false else { - await loadCompanions - return - } - realtimePageID = editorViewModel.currentPageID - await loadCompanions - } - - func autosaveInlineEdits() { - guard let editorViewModel, editorViewModel.canSave else { return } - - Task { - if await editorViewModel.save(appState: appState) { - await viewModel.loadCompanions(pageID: editorViewModel.currentPageID, appState: appState) - } - } - } - func beginAttachmentImport(_ importKind: NativeEditorAttachmentImportKind) { attachmentImportKind = importKind attachmentUploadErrorMessage = nil @@ -498,28 +422,28 @@ extension PageReaderView { isShowingInlineCommentComposer = true } - func createInlineComment(_ text: String) async throws { + func createInlineComment(_ body: CommentBody) async throws { guard let editorViewModel, let inlineCommentContext else { throw NativeEditorInlineCommentCreationError.noSelection } let commentID: String let yjsSelection: NativeEditorYjsSelection? - if let pendingInlineCommentID, pendingInlineCommentDraft == text { + if let pendingInlineCommentID, pendingInlineCommentDraft == body { commentID = pendingInlineCommentID yjsSelection = pendingInlineCommentYjsSelection } else { yjsSelection = await editorViewModel.inlineCommentYjsSelection(for: inlineCommentContext) let comment = try await appState.addInlineComment( pageId: editorViewModel.currentPageID, - text: text, + body: body, selectedText: inlineCommentContext.selectedText, yjsSelection: yjsSelection ) viewModel.applyCreatedComment(comment) commentID = comment.id pendingInlineCommentID = comment.id - pendingInlineCommentDraft = text + pendingInlineCommentDraft = body pendingInlineCommentYjsSelection = yjsSelection } diff --git a/docmostly/Features/PageReader/PageReaderView+Autosave.swift b/docmostly/Features/PageReader/PageReaderView+Autosave.swift new file mode 100644 index 0000000..a8ea56f --- /dev/null +++ b/docmostly/Features/PageReader/PageReaderView+Autosave.swift @@ -0,0 +1,47 @@ +import SwiftUI + +extension PageReaderView { + func autosaveInlineEdits(reloadCompanions: Bool = true) { + guard let editorViewModel else { return } + guard editorViewModel.isDirty || editorViewModel.isSaving else { return } + + editorViewModel.autosaveCoordinator.flush( + editorSaveOperation(for: editorViewModel, reloadCompanions: reloadCompanions) + ) + } + + func scheduleInlineAutosave() { + guard let editorViewModel, editorViewModel.canEdit else { return } + guard editorViewModel.isLoading == false, editorViewModel.isDirty else { return } + + editorViewModel.autosaveCoordinator.schedule( + editorSaveOperation(for: editorViewModel, reloadCompanions: false) + ) + } + + private func editorSaveOperation( + for editorViewModel: NativeRichEditorViewModel, + reloadCompanions: Bool + ) -> NativeEditorAutosaveCoordinator.Operation { + let appState = appState + let pageCompanionViewModel = viewModel + + return { [editorViewModel, appState, weak pageCompanionViewModel] in + let didSave: Bool + if editorViewModel.canEdit, editorViewModel.isDirty || editorViewModel.isSaving { + didSave = await editorViewModel.save(appState: appState) + } else if editorViewModel.canEdit == false, editorViewModel.isDirty { + didSave = await editorViewModel.persistRetainedReadOnlyDraft(appState: appState) + } else { + didSave = true + } + + if didSave, reloadCompanions, let pageCompanionViewModel { + await pageCompanionViewModel.loadCompanions( + pageID: editorViewModel.currentPageID, + appState: appState + ) + } + } + } +} diff --git a/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift b/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift index 1b56836..ab39bca 100644 --- a/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift +++ b/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift @@ -1,6 +1,19 @@ import Foundation +import SwiftUI extension PageReaderView { + func revealCollaborator(_ collaboratorID: String) { + guard let blockID = editorViewModel?.rootBlockID(forCollaboratorID: collaboratorID) else { return } + + if accessibilityReduceMotion { + scrollPosition.scrollTo(id: blockID, anchor: .center) + } else { + withAnimation(.snappy) { + scrollPosition.scrollTo(id: blockID, anchor: .center) + } + } + } + func monitorCRDTDocumentSnapshots() async { guard let editorViewModel else { return } let snapshots = await editorViewModel.crdtDocumentSnapshots() @@ -12,7 +25,7 @@ extension PageReaderView { } } - func monitorCollaborationPresence() async { + func monitorCollaborationPresence(participation: NativeEditorCollaborationParticipation) async { guard let editorViewModel else { return } var reconnectPolicy = NativeEditorRealtimeReconnectPolicy() var authenticationRetry = NativeEditorCollabAuthRetry() @@ -20,6 +33,7 @@ extension PageReaderView { while Task.isCancelled == false { switch await runCollaborationPresenceConnection( editorViewModel: editorViewModel, + participation: participation, reconnectPolicy: &reconnectPolicy, authenticationRetry: &authenticationRetry ) { @@ -51,8 +65,10 @@ extension PageReaderView { markCollaborationPresenceConnected(editorViewModel) } case .awareness(let states, let localClientID): - editorViewModel.applyAwarenessStates(states, localClientID: localClientID) - await editorViewModel.refreshResolvedRemoteCursors() + let remoteCursorsChanged = editorViewModel.applyAwarenessStates(states, localClientID: localClientID) + if remoteCursorsChanged { + await editorViewModel.refreshResolvedRemoteCursors() + } case .stateless(let event) where event.type == NativeEditorCollaborationDocument.statelessPageUpdatedType: if editorViewModel.usesCRDTDocumentEngine { _ = editorViewModel.handleCRDTBackedPageUpdated(event) @@ -133,12 +149,16 @@ extension PageReaderView { private func runCollaborationPresenceConnection( editorViewModel: NativeRichEditorViewModel, + participation: NativeEditorCollaborationParticipation, reconnectPolicy: inout NativeEditorRealtimeReconnectPolicy, authenticationRetry: inout NativeEditorCollabAuthRetry ) async -> CollaborationPresenceLoopAction { do { markCollaborationPresenceConnecting(editorViewModel) - let events = try await collaborationPresenceEvents(editorViewModel: editorViewModel) + let events = try await collaborationPresenceEvents( + editorViewModel: editorViewModel, + participation: participation + ) for try await event in events { try Task.checkCancellation() @@ -163,18 +183,20 @@ extension PageReaderView { } private func collaborationPresenceEvents( - editorViewModel: NativeRichEditorViewModel + editorViewModel: NativeRichEditorViewModel, + participation: NativeEditorCollaborationParticipation ) async throws -> AsyncThrowingStream { let url = try appState.collaborationWebSocketURL() guard let token = try await appState.loadCollaborationToken().token else { throw APIError.connectionFailed("Realtime collaboration token is missing.") } - let collaborationSession = editorViewModel.collaborationSession() + let collaborationSession = editorViewModel.collaborationSession(participation: participation) return await collaborationPresenceClient.events( url: url, token: token, documentName: collaborationSession.documentName, + participation: collaborationSession.participation, user: appState.currentUser?.user, syncDriver: collaborationSession.syncDriver, localAwarenessCursor: collaborationSession.localAwarenessCursor, diff --git a/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift b/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift new file mode 100644 index 0000000..1e5b5d1 --- /dev/null +++ b/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift @@ -0,0 +1,125 @@ +import Foundation + +enum NativeEditorRemoteConflictResolution { + case applyRemote + case keepLocal +} + +extension PageReaderView { + func resolvePendingRemoteUpdate(_ resolution: NativeEditorRemoteConflictResolution) { + guard let editorViewModel else { return } + guard editorViewModel.pendingRemoteCRDTSnapshot != nil else { + resolveLegacyPendingRemoteUpdate(resolution, editorViewModel: editorViewModel) + return + } + guard editorViewModel.isResolvingConflict == false else { return } + + editorViewModel.isResolvingConflict = true + editorViewModel.clearFocus() + + Task { @MainActor in + await performPendingRemoteResolution(resolution, editorViewModel: editorViewModel) + } + } + + private func performPendingRemoteResolution( + _ resolution: NativeEditorRemoteConflictResolution, + editorViewModel: NativeRichEditorViewModel + ) async { + guard let remoteSnapshot = editorViewModel.pendingRemoteCRDTSnapshot else { return } + let remoteUpdate = editorViewModel.pendingRemoteUpdate + let remoteTitle = remoteUpdate?.title ?? + remoteSnapshot.title ?? + editorViewModel.title + + await appState.pauseOfflineReplayForCollaborativeResolution() + defer { + editorViewModel.isResolvingConflict = false + appState.scheduleOfflineQueueReconciliation() + } + + autosaveInlineEdits(reloadCompanions: false) + let didFinishPersistence = await editorViewModel.autosaveCoordinator + .waitForCurrentPersistence(timeout: .seconds(15)) + guard didFinishPersistence, editorViewModel.hasDurablyPersistedLocalCRDTDraft else { + editorViewModel.saveErrorMessage = editorViewModel.saveErrorMessage ?? + "The local draft could not be secured before resolving this conflict. Try again." + return + } + + guard editorViewModel.pendingRemoteCRDTSnapshot == remoteSnapshot, + editorViewModel.pendingRemoteUpdate == remoteUpdate else { + editorViewModel.saveErrorMessage = + "A newer remote version arrived while resolving this conflict. Review it and try again." + return + } + let cutoff = Date.now + + do { + switch resolution { + case .applyRemote: + let acknowledgement = try await appState.discardPendingCollaborativeDraft( + pageId: editorViewModel.currentPageID, + through: cutoff + ) + guard acknowledgement != .newerPendingUpdatePreserved else { + editorViewModel.saveErrorMessage = + "A newer local draft appeared while resolving this conflict. Review it and try again." + return + } + _ = try await appState.saveLocalEditableDraft( + pageId: editorViewModel.currentPageID, + title: remoteTitle, + document: remoteSnapshot.document.proseMirrorDocument + ) + guard editorViewModel.acceptPendingRemoteUpdate( + matching: remoteSnapshot, + remoteUpdate: remoteUpdate + ) else { + editorViewModel.saveErrorMessage = + "A newer remote version arrived while resolving this conflict. Review it and try again." + return + } + case .keepLocal: + let result = try await appState.keepPendingCollaborativeDraft( + pageId: editorViewModel.currentPageID, + title: editorViewModel.title, + document: editorViewModel.document.proseMirrorDocument, + remoteBaseTitle: remoteTitle, + remoteBaseDocument: remoteSnapshot.document.proseMirrorDocument, + replacingThrough: cutoff + ) + guard result != .newerPendingUpdatePreserved else { + editorViewModel.saveErrorMessage = + "A newer local draft appeared while resolving this conflict. Review it and try again." + return + } + guard editorViewModel.rejectPendingRemoteUpdate( + matching: remoteSnapshot, + remoteUpdate: remoteUpdate + ) else { + editorViewModel.saveErrorMessage = + "A newer remote version arrived while resolving this conflict. Review it and try again." + return + } + } + + editorViewModel.saveErrorMessage = nil + } catch { + editorViewModel.saveErrorMessage = + "The conflict could not be resolved safely: " + error.localizedDescription + } + } + + private func resolveLegacyPendingRemoteUpdate( + _ resolution: NativeEditorRemoteConflictResolution, + editorViewModel: NativeRichEditorViewModel + ) { + switch resolution { + case .applyRemote: + editorViewModel.acceptPendingRemoteUpdate() + case .keepLocal: + editorViewModel.rejectPendingRemoteUpdate() + } + } +} diff --git a/docmostly/Features/PageReader/PageReaderView+CommentFocus.swift b/docmostly/Features/PageReader/PageReaderView+CommentFocus.swift new file mode 100644 index 0000000..589efb6 --- /dev/null +++ b/docmostly/Features/PageReader/PageReaderView+CommentFocus.swift @@ -0,0 +1,44 @@ +import SwiftUI + +extension PageReaderView { + func focusRequestedCommentIfAvailable() { + guard let commentID = requestedCommentID, + let comment = viewModel.comments.first(where: { $0.id == commentID }), + viewModel.rootComment(containing: commentID) != nil else { + return + } + + focusedCommentID = commentID + if comment.type == DocmostCommentType.inline.rawValue { + scrollToInlineComment(commentID) + } + activePanel = .comments + } + + func focusInlineComment(_ commentID: String) { + focusedCommentID = commentID + #if !os(macOS) + closeSupplementaryPanel() + #endif + scrollToInlineComment(commentID) + } + + private var requestedCommentID: String? { + let selectedPageIDs = [ + pageID, + editorViewModel?.currentPageID, + editorViewModel?.currentPageSlugID + ].compactMap(\.self) + + if let selectedPageID = appState.selectedPageID { + guard selectedPageIDs.contains(selectedPageID) else { return nil } + return appState.selectedCommentID ?? initialCommentID + } + return initialCommentID + } + + private func scrollToInlineComment(_ commentID: String) { + guard let blockID = editorViewModel?.blockID(containingInlineComment: commentID) else { return } + scrollPosition.scrollTo(id: blockID, anchor: .center) + } +} diff --git a/docmostly/Features/PageReader/PageReaderView+Loading.swift b/docmostly/Features/PageReader/PageReaderView+Loading.swift new file mode 100644 index 0000000..de61fd8 --- /dev/null +++ b/docmostly/Features/PageReader/PageReaderView+Loading.swift @@ -0,0 +1,142 @@ +import SwiftUI + +extension PageReaderView { + func retry() { + Task { + await loadNativePage() + } + } + + func loadNativePage() async { + let requestedPageID = pageID + let requestedInitialTitle = initialTitle + let outgoingEditorViewModel = editorViewModel + var didRequestOutgoingPersistence = false + + let outcome = await PageReaderPageSwitchHandoff.perform( + requiresInitialOutgoingFlush: outgoingEditorViewModel != nil, + hasOutgoingChanges: { + guard let outgoingEditorViewModel else { return false } + return outgoingEditorViewModel.hasOutgoingChangesRequiringPersistence + }, + flushOutgoing: { + guard + let outgoingEditorViewModel, + let attachedEditorViewModel = self.editorViewModel, + attachedEditorViewModel === outgoingEditorViewModel + else { + return .failed + } + + if didRequestOutgoingPersistence == false { + autosaveInlineEdits(reloadCompanions: false) + didRequestOutgoingPersistence = true + } + let didFinishPersistence = await outgoingEditorViewModel.autosaveCoordinator + .waitForCurrentPersistence(timeout: .seconds(2)) + guard Task.isCancelled == false else { return .retry } + guard didFinishPersistence else { return .retry } + + didRequestOutgoingPersistence = false + return outgoingEditorViewModel.saveErrorMessage == nil ? .completed : .failed + }, + detachOutgoing: { + if let outgoingEditorViewModel { + guard + let attachedEditorViewModel = self.editorViewModel, + attachedEditorViewModel === outgoingEditorViewModel + else { + return false + } + } else { + guard self.editorViewModel == nil else { return false } + } + + editorFocusedField = nil + realtimePageID = nil + editorViewModel = nil + return true + }, + loadIncoming: { + await loadNativePage( + requestedPageID: requestedPageID, + initialTitle: requestedInitialTitle + ) + } + ) + + if outcome == .outgoingFlushFailed, + Task.isCancelled == false, + let outgoingEditorViewModel, + let attachedEditorViewModel = self.editorViewModel, + attachedEditorViewModel === outgoingEditorViewModel { + pageActionErrorMessage = outgoingEditorViewModel.saveErrorMessage ?? + "Could not finish saving this page. The page switch was paused to keep your edits safe." + } + } + + private func loadNativePage(requestedPageID: String, initialTitle: String?) async { + for attempt in 0..<2 { + let editorViewModel = NativeRichEditorViewModel( + pageID: requestedPageID, + initialTitle: initialTitle ?? "" + ) + let didLoadPage = await editorViewModel.load(appState: appState) + guard Task.isCancelled == false else { return } + + if didLoadPage { + await finishNativePageLoad(editorViewModel) + return + } + + if editorViewModel.errorMessage != nil { + self.editorViewModel = editorViewModel + return + } + + guard attempt == 0 else { + editorViewModel.errorMessage = "Page loading was interrupted." + self.editorViewModel = editorViewModel + return + } + + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { + return + } + } + } + + private func finishNativePageLoad(_ editorViewModel: NativeRichEditorViewModel) async { + async let loadCompanions: Void = viewModel.loadCompanions( + pageID: editorViewModel.currentPageID, + appState: appState + ) + + await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( + to: editorViewModel, + appState: appState + ) + guard Task.isCancelled == false else { + await loadCompanions + return + } + + self.editorViewModel = editorViewModel + if let currentSpaceID = editorViewModel.currentSpaceID { + pageLoaded(editorViewModel.currentPageSlugID, currentSpaceID, editorViewModel.title) + } + if editorViewModel.canEdit == false { + readerMode = .read + } + if focusesEditorOnLoad, editorViewModel.canEdit { + await Task.yield() + editorFocusedField = .title + editorViewModel.focusTitle() + } + + realtimePageID = editorViewModel.currentPageID + await loadCompanions + } +} diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index aa9dfdb..7986d92 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -7,6 +7,8 @@ struct PageReaderView: View { #endif @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss + @Environment(\.scenePhase) var scenePhase + @Environment(\.accessibilityReduceMotion) var accessibilityReduceMotion @State var viewModel = PageReaderViewModel() @State var editorViewModel: NativeRichEditorViewModel? @State var pageHistoryViewModel = PageHistoryViewModel() @@ -32,10 +34,11 @@ struct PageReaderView: View { @State var isShowingLabelEditor = false @State var isShowingMoveToSpace = false @State var pendingInlineCommentID: String? - @State var pendingInlineCommentDraft: String? + @State var pendingInlineCommentDraft: CommentBody? @State var pendingInlineCommentYjsSelection: NativeEditorYjsSelection? @State var readerMode = PageReaderMode.edit @State var activePanel: PageReaderPanel? + @State var focusedCommentID: String? @State var scrollPosition = ScrollPosition() @State var usesFullWidth = false @State var realtimePageID: String? @@ -43,15 +46,21 @@ struct PageReaderView: View { let pageID: String let initialTitle: String? + let initialCommentID: String? + let focusesEditorOnLoad: Bool let pageLoaded: @MainActor (_ pageID: String, _ spaceID: String, _ title: String) -> Void init( pageID: String, initialTitle: String? = nil, + initialCommentID: String? = nil, + focusesEditorOnLoad: Bool = false, pageLoaded: @escaping @MainActor (_ pageID: String, _ spaceID: String, _ title: String) -> Void = { _, _, _ in } ) { self.pageID = pageID self.initialTitle = initialTitle + self.initialCommentID = initialCommentID + self.focusesEditorOnLoad = focusesEditorOnLoad self.pageLoaded = pageLoaded } @@ -76,7 +85,13 @@ struct PageReaderView: View { isAuthoringEnabled: readerMode == .edit, serverURLString: appState.serverURLString, importAttachment: beginAttachmentImport, - applyCommand: applyEditorCommand + applyCommand: applyEditorCommand, + applyPendingRemoteUpdate: { + resolvePendingRemoteUpdate(.applyRemote) + }, + keepPendingLocalUpdate: { + resolvePendingRemoteUpdate(.keepLocal) + } ) AttachmentLinksView( links: viewModel.attachmentLinks, @@ -242,6 +257,8 @@ struct PageReaderView: View { serverURLString: appState.serverURLString, tableOfContentsItems: tableOfContentsItems, selectHeading: selectHeading, + focusedCommentID: focusedCommentID, + focusInlineComment: focusInlineComment, markInlineCommentResolved: markInlineCommentResolved, removeInlineComment: removeInlineComment, loadSharingState: loadSharingState, @@ -273,6 +290,8 @@ struct PageReaderView: View { serverURLString: appState.serverURLString, tableOfContentsItems: tableOfContentsItems, selectHeading: selectHeading, + focusedCommentID: focusedCommentID, + focusInlineComment: focusInlineComment, markInlineCommentResolved: markInlineCommentResolved, removeInlineComment: removeInlineComment, loadSharingState: loadSharingState, @@ -299,22 +318,36 @@ struct PageReaderView: View { #endif .task(id: pageID) { await loadNativePage() + focusRequestedCommentIfAvailable() } - .task(id: realtimePageID) { + .task(id: PageReaderCollaborationTaskKeys.realtimeEvents( + pageID: realtimePageID, + participation: collaborationParticipation + )) { guard realtimePageID != nil else { return } await monitorRealtimeEvents() } - .task(id: realtimePageID) { - guard realtimePageID != nil else { return } - await monitorCollaborationPresence() + .task(id: collaborationPresenceTaskKey) { + guard let collaborationPresenceTaskKey else { return } + await monitorCollaborationPresence(participation: collaborationPresenceTaskKey.participation) } - .task(id: realtimePageID) { + .task(id: PageReaderCollaborationTaskKeys.crdtDocumentSnapshots( + pageID: realtimePageID, + participation: collaborationParticipation + )) { guard realtimePageID != nil else { return } await monitorCRDTDocumentSnapshots() } .onChange(of: editorFocusedField) { _, newValue in updateEditorFocus(newValue) } + .onChange(of: appState.selectedCommentID) { _, _ in + focusRequestedCommentIfAvailable() + } + .onChange(of: editorViewModel?.localEditRevision) { oldRevision, newRevision in + guard let oldRevision, let newRevision, oldRevision != newRevision else { return } + scheduleInlineAutosave() + } .onChange(of: isShowingInlineCommentComposer) { _, isShowing in if isShowing == false { inlineCommentContext = nil @@ -329,8 +362,14 @@ struct PageReaderView: View { autosaveInlineEdits() } } + .onChange(of: scenePhase) { oldPhase, newPhase in + if oldPhase == .active, newPhase != .active { + autosaveInlineEdits(reloadCompanions: false) + } + } .onChange(of: editorViewModel?.canEdit) { _, canEdit in if canEdit == false { + autosaveInlineEdits(reloadCompanions: false) readerMode = .read } } @@ -340,3 +379,19 @@ struct PageReaderView: View { } } + +extension PageReaderView { + var collaborationParticipation: NativeEditorCollaborationParticipation { + guard let editorViewModel else { return .receiveOnly } + guard readerMode == .edit, editorViewModel.canEdit else { return .receiveOnly } + return .interactive + } + + var collaborationPresenceTaskKey: PageReaderCollaborationPresenceTaskKey? { + PageReaderCollaborationTaskKeys.collaborationPresence( + pageID: realtimePageID, + participation: collaborationParticipation, + isVisible: scenePhase == .active + ) + } +} diff --git a/docmostly/Features/PageReader/PageReaderViewModel.swift b/docmostly/Features/PageReader/PageReaderViewModel.swift index 9936640..03dc14e 100644 --- a/docmostly/Features/PageReader/PageReaderViewModel.swift +++ b/docmostly/Features/PageReader/PageReaderViewModel.swift @@ -21,9 +21,10 @@ final class PageReaderViewModel { var sharingErrorMessage: String? var isLoadingSharingState = false var isUpdatingShare = false - var draftComment = "" - var replyDraftsByCommentID: [String: String] = [:] - var editDraftsByCommentID: [String: String] = [:] + var draftComment = CommentComposerState() + var replyDraftsByCommentID: [String: CommentComposerState] = [:] + var editDraftsByCommentID: [String: CommentComposerState] = [:] + var commentErrorsByID: [String: String] = [:] var isPostingComment = false var postingReplyIDs: Set = [] var editingCommentIDs: Set = [] @@ -84,17 +85,18 @@ final class PageReaderViewModel { isTogglingFavorite = true engagementErrorMessage = nil + let wasFavorite = isFavoritePage + isFavoritePage.toggle() defer { isTogglingFavorite = false } do { - if isFavoritePage { + if wasFavorite { try await appState.removeFavorite(type: .page, pageId: pageID) - isFavoritePage = false } else { try await appState.addFavorite(type: .page, pageId: pageID) - isFavoritePage = true } } catch { + isFavoritePage = wasFavorite engagementErrorMessage = error.localizedDescription } } @@ -158,42 +160,44 @@ final class PageReaderViewModel { } func postComment(pageID: String, appState: AppState) async { - guard draftComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - return - } + guard draftComment.isEmpty == false else { return } + let body = draftComment.body isPostingComment = true commentErrorMessage = nil defer { isPostingComment = false } do { - let comment = try await appState.addPageComment(pageId: pageID, text: draftComment) + let comment = try await appState.addPageComment(pageId: pageID, body: body) applyCreatedComment(comment) - draftComment = "" + draftComment.reset() } catch { commentErrorMessage = error.localizedDescription } } func postReply(to parentComment: DocmostComment, pageID: String, appState: AppState) async { - let draft = replyDraftsByCommentID[parentComment.id] ?? "" + let draft = replyDraft(for: parentComment.id) guard canSubmitReply(to: parentComment, draft: draft) else { return } + let body = draft.body postingReplyIDs.insert(parentComment.id) commentErrorMessage = nil + commentErrorsByID[parentComment.id] = nil defer { postingReplyIDs.remove(parentComment.id) } do { let reply = try await appState.addCommentReply( pageId: pageID, parentCommentId: parentComment.id, - text: draft + body: body ) applyCreatedReply(reply, parentCommentID: parentComment.id) } catch { commentErrorMessage = error.localizedDescription + commentErrorsByID[parentComment.id] = error.localizedDescription } } @@ -217,22 +221,29 @@ final class PageReaderViewModel { deletingCommentIDs.contains(id) } - func canSubmitReply(to parentComment: DocmostComment, draft: String? = nil) -> Bool { - let replyDraft = draft ?? replyDraftsByCommentID[parentComment.id] ?? "" + func canSubmitReply(to parentComment: DocmostComment, draft: CommentComposerState? = nil) -> Bool { + let replyDraft = draft ?? self.replyDraft(for: parentComment.id) return parentComment.parentCommentId == nil && parentComment.isLocallyQueued == false && postingReplyIDs.contains(parentComment.id) == false - && replyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + && replyDraft.isEmpty == false } - func replies(for parentCommentID: String) -> [DocmostComment] { - comments.filter { $0.parentCommentId == parentCommentID } + func replyDraft(for commentID: String) -> CommentComposerState { + if let draft = replyDraftsByCommentID[commentID] { + return draft + } + + let draft = CommentComposerState() + replyDraftsByCommentID[commentID] = draft + return draft } func beginEditing(_ comment: DocmostComment) { guard comment.isNativelyEditable else { return } - editDraftsByCommentID[comment.id] = comment.content ?? "" + editDraftsByCommentID[comment.id] = CommentComposerState(body: comment.body) editingCommentIDs.insert(comment.id) + commentErrorsByID[comment.id] = nil } func cancelEditing(commentID: String) { @@ -242,21 +253,21 @@ final class PageReaderViewModel { func updateComment(_ comment: DocmostComment, appState: AppState) async { guard comment.isNativelyEditable else { return } - let draft = editDraftsByCommentID[comment.id] ?? "" - guard draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - return - } + guard let draft = editDraftsByCommentID[comment.id], draft.isEmpty == false else { return } + let body = draft.body guard updatingCommentIDs.contains(comment.id) == false else { return } updatingCommentIDs.insert(comment.id) commentErrorMessage = nil + commentErrorsByID[comment.id] = nil defer { updatingCommentIDs.remove(comment.id) } do { - let updatedComment = try await appState.updateComment(comment, text: draft) + let updatedComment = try await appState.updateComment(comment, body: body) applyEditedComment(updatedComment) } catch { commentErrorMessage = error.localizedDescription + commentErrorsByID[comment.id] = error.localizedDescription } } @@ -269,6 +280,7 @@ final class PageReaderViewModel { deletingCommentIDs.insert(comment.id) commentErrorMessage = nil + commentErrorsByID[comment.id] = nil defer { deletingCommentIDs.remove(comment.id) } do { @@ -279,6 +291,7 @@ final class PageReaderViewModel { } } catch { commentErrorMessage = error.localizedDescription + commentErrorsByID[comment.id] = error.localizedDescription } } @@ -291,8 +304,15 @@ final class PageReaderViewModel { guard resolvingCommentIDs.contains(comment.id) == false else { return } let targetResolvedState = comment.isResolved == false + let optimisticComment = comment.projectingResolution( + resolved: targetResolvedState, + resolvedBy: appState.currentUser?.user + ) resolvingCommentIDs.insert(comment.id) commentErrorMessage = nil + commentErrorsByID[comment.id] = nil + applyUpdatedComment(optimisticComment) + await markInlineCommentResolved?(comment.id, targetResolvedState) defer { resolvingCommentIDs.remove(comment.id) } @@ -304,9 +324,11 @@ final class PageReaderViewModel { resolved: targetResolvedState ) applyUpdatedComment(updatedComment) - await markInlineCommentResolved?(comment.id, targetResolvedState) } catch { + applyUpdatedComment(comment) + await markInlineCommentResolved?(comment.id, comment.isResolved) commentErrorMessage = error.localizedDescription + commentErrorsByID[comment.id] = error.localizedDescription } } @@ -320,7 +342,8 @@ final class PageReaderViewModel { func applyCreatedReply(_ reply: DocmostComment, parentCommentID: String) { applyCreatedComment(reply) - replyDraftsByCommentID[parentCommentID] = nil + replyDraftsByCommentID[parentCommentID]?.reset() + commentErrorsByID[parentCommentID] = nil } func applyUpdatedComment(_ comment: DocmostComment) { @@ -333,6 +356,7 @@ final class PageReaderViewModel { applyUpdatedComment(comment) editDraftsByCommentID[comment.id] = nil editingCommentIDs.remove(comment.id) + commentErrorsByID[comment.id] = nil } func removeComment(id: String) { @@ -346,6 +370,7 @@ final class PageReaderViewModel { deletingCommentIDs.remove(commentID) replyDraftsByCommentID[commentID] = nil editDraftsByCommentID[commentID] = nil + commentErrorsByID[commentID] = nil } } diff --git a/docmostly/Features/PageTree/PageBrowserMetrics.swift b/docmostly/Features/PageTree/PageBrowserMetrics.swift index 578b1a1..f5a1daf 100644 --- a/docmostly/Features/PageTree/PageBrowserMetrics.swift +++ b/docmostly/Features/PageTree/PageBrowserMetrics.swift @@ -10,6 +10,7 @@ enum PageBrowserMetrics { static let railHorizontalPadding: CGFloat = 16 static let railSectionSpacing: CGFloat = 12 static let railCardSpacing: CGFloat = 12 + static let railCardVerticalOverflowPadding: CGFloat = 12 static let railCardWidth: CGFloat = 152 static let railCardHeight: CGFloat = 154 static let railCardPadding: CGFloat = 16 diff --git a/docmostly/Features/PageTree/PageTreeViewModel.swift b/docmostly/Features/PageTree/PageTreeViewModel.swift index 5b061d0..33162e2 100644 --- a/docmostly/Features/PageTree/PageTreeViewModel.swift +++ b/docmostly/Features/PageTree/PageTreeViewModel.swift @@ -67,17 +67,18 @@ final class PageTreeViewModel { isTogglingSpaceFavorite = true spaceActionErrorMessage = nil + let wasFavorite = isFavoriteSpace + isFavoriteSpace.toggle() defer { isTogglingSpaceFavorite = false } do { - if isFavoriteSpace { + if wasFavorite { try await appState.removeFavorite(type: .space, spaceId: spaceId) - isFavoriteSpace = false } else { try await appState.addFavorite(type: .space, spaceId: spaceId) - isFavoriteSpace = true } } catch { + isFavoriteSpace = wasFavorite spaceActionErrorMessage = error.localizedDescription } } diff --git a/docmostly/Features/PageTree/RecentPagesRailView.swift b/docmostly/Features/PageTree/RecentPagesRailView.swift index bfa3225..bdb5df0 100644 --- a/docmostly/Features/PageTree/RecentPagesRailView.swift +++ b/docmostly/Features/PageTree/RecentPagesRailView.swift @@ -8,7 +8,7 @@ struct RecentPagesRailView: View { var body: some View { VStack(alignment: .leading, spacing: PageBrowserMetrics.railSectionSpacing) { - Text(isOffline ? "Recent cached pages" : "Recently updated") + Text("Recently updated") .font(.headline) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) @@ -33,8 +33,10 @@ struct RecentPagesRailView: View { } } .padding(.horizontal, PageBrowserMetrics.railHorizontalPadding) + .padding(.vertical, PageBrowserMetrics.railCardVerticalOverflowPadding) .scrollTargetLayout() } + .padding(.vertical, -PageBrowserMetrics.railCardVerticalOverflowPadding) .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) } diff --git a/docmostly/Features/Settings/SettingsView.swift b/docmostly/Features/Settings/SettingsView.swift index dc4926c..e1d222b 100644 --- a/docmostly/Features/Settings/SettingsView.swift +++ b/docmostly/Features/Settings/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { systemImage: "person.crop.circle" ) } + .accessibilityIdentifier("SettingsDestination.account") } Section("Workspace") { @@ -24,6 +25,7 @@ struct SettingsView: View { systemImage: "building.2" ) } + .accessibilityIdentifier("SettingsDestination.workspace") NavigationLink(value: SettingsDestination.members) { SettingsSectionRowView( title: "Members", @@ -31,6 +33,7 @@ struct SettingsView: View { systemImage: "person.2" ) } + .accessibilityIdentifier("SettingsDestination.members") NavigationLink(value: SettingsDestination.spaces) { SettingsSectionRowView( title: "Spaces", @@ -38,6 +41,7 @@ struct SettingsView: View { systemImage: "square.stack.3d.up" ) } + .accessibilityIdentifier("SettingsDestination.spaces") NavigationLink(value: SettingsDestination.groups) { SettingsSectionRowView( title: "Groups", @@ -45,6 +49,7 @@ struct SettingsView: View { systemImage: "person.3" ) } + .accessibilityIdentifier("SettingsDestination.groups") } Section("Server") { diff --git a/docmostly/Features/Shared/CursorPageAccumulator.swift b/docmostly/Features/Shared/CursorPageAccumulator.swift new file mode 100644 index 0000000..09ff24c --- /dev/null +++ b/docmostly/Features/Shared/CursorPageAccumulator.swift @@ -0,0 +1,46 @@ +import Foundation + +nonisolated struct CursorPageAccumulator: Sendable +where Item.ID: Hashable & Sendable { + private(set) var items: [Item] = [] + private(set) var nextCursor: String? + private(set) var hasNextPage = false + + mutating func replace(with response: PaginatedResponse) { + items = Self.deduplicated(response.items) + updatePagination(using: response.meta, requestedCursor: nil) + } + + mutating func append( + _ response: PaginatedResponse, + requestedCursor: String + ) { + var seenIDs = Set(items.map(\.id)) + items.append(contentsOf: response.items.filter { seenIDs.insert($0.id).inserted }) + updatePagination(using: response.meta, requestedCursor: requestedCursor) + } + + mutating func remove(id: Item.ID) -> (item: Item, index: Int)? { + guard let index = items.firstIndex(where: { $0.id == id }) else { return nil } + return (items.remove(at: index), index) + } + + mutating func restore(_ item: Item, at index: Int) { + guard items.contains(where: { $0.id == item.id }) == false else { return } + items.insert(item, at: min(max(index, 0), items.endIndex)) + } + + private mutating func updatePagination( + using meta: PaginationMeta, + requestedCursor: String? + ) { + let candidateCursor = meta.hasNextPage ? meta.nextCursor : nil + nextCursor = candidateCursor == requestedCursor ? nil : candidateCursor + hasNextPage = nextCursor != nil + } + + private static func deduplicated(_ items: [Item]) -> [Item] { + var seenIDs: Set = [] + return items.filter { seenIDs.insert($0.id).inserted } + } +} diff --git a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift index a34f2ac..0dae413 100644 --- a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift +++ b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift @@ -5,6 +5,7 @@ struct MacWorkspaceSidebarView: View { @Environment(\.openWindow) private var openWindow @Environment(AppState.self) private var appState @Environment(MacDesktopCommandController.self) private var commandController + @Environment(NotificationStore.self) private var notificationStore @State private var viewModel = PageTreeViewModel() @State private var creationRequest: PageCreationRequest? @State private var moveRequest: PageTreeNode? @@ -37,6 +38,23 @@ struct MacWorkspaceSidebarView: View { appState.selectSidebarUtilityDestination(.search) } + MacSidebarActionRow( + title: "Favorites", + systemImage: "star", + isSelected: selectionState.isUtilitySelected(.favorites) + ) { + appState.selectSidebarUtilityDestination(.favorites) + } + + MacSidebarActionRow( + title: "Notifications", + systemImage: "bell", + isSelected: selectionState.isUtilitySelected(.notifications), + badgeCount: notificationStore.unreadCount + ) { + appState.selectSidebarUtilityDestination(.notifications) + } + MacSidebarActionRow( title: "Space settings", systemImage: "gearshape", @@ -310,13 +328,23 @@ private struct MacSidebarActionRow: View { let title: String let systemImage: String let isSelected: Bool + var badgeCount: Int = 0 let action: () -> Void var body: some View { Button(action: action) { - Label(title, systemImage: systemImage) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) + HStack { + Label(title, systemImage: systemImage) + Spacer(minLength: 0) + if badgeCount > 0 { + Text(badgeCount > 99 ? "99+" : badgeCount.formatted()) + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel("\(badgeCount) unread") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) } .buttonStyle(.plain) .frame(maxWidth: .infinity, minHeight: MacSidebarMetrics.rowHeight, alignment: .leading) diff --git a/docmostly/Features/Spaces/MainShellContentView.swift b/docmostly/Features/Spaces/MainShellContentView.swift index 599bcc4..5b2eecf 100644 --- a/docmostly/Features/Spaces/MainShellContentView.swift +++ b/docmostly/Features/Spaces/MainShellContentView.swift @@ -2,28 +2,34 @@ import SwiftUI struct MainShellContentView: View { @Environment(AppState.self) private var appState + @State private var navigationPath = NavigationPath() var body: some View { - Group { - switch appState.selectedSidebarDestination { - case .favorites: - FavoritesView() - case .notifications: - NotificationListView() - case .search: - SearchView() - case .settings: - SettingsView() - case .space(let spaceID): - if let space = appState.spaces.first(where: { $0.id == spaceID }) { - PageTreeView(space: space) - } else { - ContentUnavailableView("Space unavailable", systemImage: "square.stack.3d.up") + NavigationStack(path: $navigationPath) { + Group { + switch appState.selectedSidebarDestination { + case .favorites: + FavoritesView() + case .notifications: + NotificationListView() + case .search: + SearchView() + case .settings: + SettingsView() + case .space(let spaceID): + if let space = appState.spaces.first(where: { $0.id == spaceID }) { + PageTreeView(space: space) + } else { + ContentUnavailableView("Space unavailable", systemImage: "square.stack.3d.up") + } + case nil: + ContentUnavailableView("Select a space", systemImage: "square.stack.3d.up") } - case nil: - ContentUnavailableView("Select a space", systemImage: "square.stack.3d.up") } } + .onChange(of: appState.selectedSidebarDestination) { + navigationPath = NavigationPath() + } .navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 460) } } diff --git a/docmostly/Features/Spaces/MainShellDebugPreviewView.swift b/docmostly/Features/Spaces/MainShellDebugPreviewView.swift index c6546ed..8d408c7 100644 --- a/docmostly/Features/Spaces/MainShellDebugPreviewView.swift +++ b/docmostly/Features/Spaces/MainShellDebugPreviewView.swift @@ -58,7 +58,11 @@ struct MainShellDebugPreviewView: View { appState.spaces = MainShellDebugPreviewFixtures.spaces appState.resetNavigationSelection() - appState.selectDefaultSpaceIfNeeded() + if CommandLine.arguments.contains("-MainShellPreviewSettings") { + appState.selectSidebarUtilityDestination(.settings) + } else { + appState.selectDefaultSpaceIfNeeded() + } } } diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index ea95507..58fb48c 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -2,52 +2,66 @@ import SwiftUI struct MainShellView: View { @Environment(AppState.self) private var appState + @Environment(\.scenePhase) private var scenePhase #if os(macOS) @Environment(MacDesktopCommandController.self) private var commandController @State private var isShowingSpaceSettings = false #endif @State private var columnVisibility = NavigationSplitViewVisibility.all + @State private var notificationStore = NotificationStore() var body: some View { - #if os(macOS) - NavigationSplitView(columnVisibility: $columnVisibility) { - MacWorkspaceSidebarView() - } detail: { - MacMainShellDetailView() + Group { + #if os(macOS) + NavigationSplitView(columnVisibility: $columnVisibility) { + MacWorkspaceSidebarView() + } detail: { + MacMainShellDetailView() + } + .navigationSplitViewStyle(.balanced) + .task(id: commandController.spaceSettingsPresentationRequestID) { + guard commandController.spaceSettingsPresentationRequestID != nil else { return } + defer { + commandController.clearSpaceSettingsPresentationRequest() + } + guard await appState.loadSpaces() else { return } + showSpaceSettings() + } + .sheet(isPresented: $isShowingSpaceSettings) { + if let selectedSpace { + SpaceSettingsDialog(space: selectedSpace) + } else { + ContentUnavailableView("No Space Selected", systemImage: "square.stack.3d.up") + .frame(minWidth: 480, minHeight: 280) + } + } + #else + NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarRootView() + } content: { + MainShellContentView() + } detail: { + MainShellDetailView() + } + .navigationSplitViewStyle(.balanced) + #endif } - .navigationSplitViewStyle(.balanced) + .environment(notificationStore) .task { await loadSpacesIfNeeded() } - .task(id: commandController.spaceSettingsPresentationRequestID) { - guard commandController.spaceSettingsPresentationRequestID != nil else { return } - defer { - commandController.clearSpaceSettingsPresentationRequest() - } - guard await appState.loadSpaces() else { return } - showSpaceSettings() - } - .sheet(isPresented: $isShowingSpaceSettings) { - if let selectedSpace { - SpaceSettingsDialog(space: selectedSpace) - } else { - ContentUnavailableView("No Space Selected", systemImage: "square.stack.3d.up") - .frame(minWidth: 480, minHeight: 280) - } - } - #else - NavigationSplitView(columnVisibility: $columnVisibility) { - SidebarRootView() - } content: { - MainShellContentView() - } detail: { - MainShellDetailView() + .task { + await notificationStore.pollUnreadCount(appState: appState) } - .navigationSplitViewStyle(.balanced) .task { - await loadSpacesIfNeeded() + await notificationStore.monitorRealtime(appState: appState) + } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + Task { + await notificationStore.refreshUnreadCount(appState: appState) + } } - #endif } private func loadSpacesIfNeeded() async { diff --git a/docmostly/Features/Spaces/SidebarRootView.swift b/docmostly/Features/Spaces/SidebarRootView.swift index 2cf7272..5b5467b 100644 --- a/docmostly/Features/Spaces/SidebarRootView.swift +++ b/docmostly/Features/Spaces/SidebarRootView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SidebarRootView: View { @Environment(AppState.self) private var appState + @Environment(NotificationStore.self) private var notificationStore var body: some View { List(selection: sidebarSelection) { @@ -10,7 +11,16 @@ struct SidebarRootView: View { Label("Favorites", systemImage: "star") } NavigationLink(value: SidebarDestination.notifications) { - Label("Notifications", systemImage: "bell") + HStack { + Label("Notifications", systemImage: "bell") + Spacer(minLength: 0) + if notificationStore.unreadCount > 0 { + Text(notificationStore.unreadCount > 99 ? "99+" : notificationStore.unreadCount.formatted()) + .foregroundStyle(.secondary) + .accessibilityLabel("\(notificationStore.unreadCount) unread") + } + } + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/docmostly/Networking/DocmostAPIClient.swift b/docmostly/Networking/DocmostAPIClient.swift index 13727cc..39f8f66 100644 --- a/docmostly/Networking/DocmostAPIClient.swift +++ b/docmostly/Networking/DocmostAPIClient.swift @@ -31,7 +31,7 @@ actor DocmostAPIClient { try validate(response: response, data: data) do { - let envelope = try decoder.decode(APIEnvelope.self, from: data) + let envelope = try DocmostJSONDecoder.decode(APIEnvelope.self, from: data, using: decoder) return envelope.data } catch { throw APIError.decodingFailed(error.localizedDescription) @@ -220,10 +220,14 @@ actor DocmostAPIClient { private func decodeUploadResponse(from data: Data) throws -> DocmostAttachment { do { - return try decoder.decode(DocmostAttachment.self, from: data) + return try DocmostJSONDecoder.decode(DocmostAttachment.self, from: data, using: decoder) } catch { do { - return try decoder.decode(APIEnvelope.self, from: data).data + return try DocmostJSONDecoder.decode( + APIEnvelope.self, + from: data, + using: decoder + ).data } catch { throw APIError.decodingFailed(error.localizedDescription) } @@ -232,10 +236,10 @@ actor DocmostAPIClient { private func decodeImportResponse(from data: Data) throws -> DocmostPage { do { - return try decoder.decode(APIEnvelope.self, from: data).data + return try DocmostJSONDecoder.decode(APIEnvelope.self, from: data, using: decoder).data } catch { do { - return try decoder.decode(DocmostPage.self, from: data) + return try DocmostJSONDecoder.decode(DocmostPage.self, from: data, using: decoder) } catch { throw APIError.decodingFailed(error.localizedDescription) } diff --git a/docmostly/Networking/DocmostComment.swift b/docmostly/Networking/DocmostComment.swift index bdf1eb4..3fbd5d2 100644 --- a/docmostly/Networking/DocmostComment.swift +++ b/docmostly/Networking/DocmostComment.swift @@ -3,6 +3,7 @@ import Foundation nonisolated struct DocmostComment: Decodable, Identifiable, Hashable, Sendable { let id: String let content: String? + let body: CommentBody? let selection: String? let type: String? let creatorId: String @@ -63,10 +64,12 @@ nonisolated struct DocmostComment: Decodable, Identifiable, Hashable, Sendable { deletedAt: Date? = nil, creator: DocmostUser? = nil, resolvedBy: DocmostUser? = nil, + body: CommentBody? = nil, isNativelyEditable: Bool = true ) { self.id = id self.content = content + self.body = body ?? content.map(CommentBody.init(plainText:)) self.selection = selection self.type = type self.creatorId = creatorId @@ -90,6 +93,7 @@ nonisolated struct DocmostComment: Decodable, Identifiable, Hashable, Sendable { id = try container.decode(String.self, forKey: .id) let decodedContent = Self.decodeContent(from: container) content = decodedContent.text + body = decodedContent.body isNativelyEditable = decodedContent.isNativelyEditable selection = try container.decodeIfPresent(String.self, forKey: .selection) type = try container.decodeIfPresent(String.self, forKey: .type) @@ -111,25 +115,52 @@ nonisolated struct DocmostComment: Decodable, Identifiable, Hashable, Sendable { from container: KeyedDecodingContainer ) -> CommentContentDecodingResult { if let content = try? container.decodeIfPresent(String.self, forKey: .content) { - return CommentContentDecodingResult(text: content, isNativelyEditable: true) + let body = CommentBody(plainText: content) + return CommentContentDecodingResult(text: content, body: body, isNativelyEditable: true) } - guard let document = try? container.decodeIfPresent(CommentContentNode.self, forKey: .content) else { - return CommentContentDecodingResult(text: nil, isNativelyEditable: false) + guard let document = try? container.decodeIfPresent(ProseMirrorDocument.self, forKey: .content), + Self.isWithinCommentContentBudget(document) else { + return CommentContentDecodingResult(text: nil, body: nil, isNativelyEditable: false) } - guard let text = try? document.plainText() else { - return CommentContentDecodingResult(text: nil, isNativelyEditable: false) - } + let body = CommentBody(document: document) + let text = body.plainText return CommentContentDecodingResult( text: text.isEmpty ? nil : text, - isNativelyEditable: document.isPlainTextDocument + body: body, + isNativelyEditable: body.isSupportedForEditing ) } + + private static func isWithinCommentContentBudget(_ document: ProseMirrorDocument) -> Bool { + var pendingNodes = document.content.map { (node: $0, depth: 1) } + var remainingNodes = CommentContentDecodingLimits.maximumNodeCount + var remainingText = CommentContentDecodingLimits.maximumAggregateTextLength + + while let entry = pendingNodes.popLast() { + remainingNodes -= 1 + guard remainingNodes >= 0, entry.depth <= CommentContentDecodingLimits.maximumDepth else { + return false + } + + if let text = entry.node.text { + guard text.count <= CommentContentDecodingLimits.maximumTextLength else { return false } + remainingText -= text.count + guard remainingText >= 0 else { return false } + } + + let children = entry.node.content ?? [] + guard children.count <= CommentContentDecodingLimits.maximumChildrenPerNode else { return false } + pendingNodes.append(contentsOf: children.map { (node: $0, depth: entry.depth + 1) }) + } + return true + } } nonisolated private struct CommentContentDecodingResult { let text: String? + let body: CommentBody? let isNativelyEditable: Bool } @@ -140,125 +171,3 @@ nonisolated enum CommentContentDecodingLimits { static let maximumTextLength = 100_000 static let maximumAggregateTextLength = 500_000 } - -nonisolated private struct CommentContentNode: Decodable { - let type: String? - let text: String? - let content: [CommentContentNode]? - let hasAttrs: Bool - let hasMarks: Bool - - private enum CodingKeys: String, CodingKey { - case type - case text - case content - case attrs - case marks - } - - init(from decoder: Decoder) throws { - guard decoder.codingPath.count <= CommentContentDecodingLimits.maximumDepth else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Comment content exceeds the supported nesting depth." - ) - ) - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decodeIfPresent(String.self, forKey: .type) - text = try container.decodeIfPresent(String.self, forKey: .text) - if let text, text.count > CommentContentDecodingLimits.maximumTextLength { - throw DecodingError.dataCorruptedError( - forKey: .text, - in: container, - debugDescription: "Comment text is too large." - ) - } - content = try container.decodeIfPresent([CommentContentNode].self, forKey: .content) - if let content, content.count > CommentContentDecodingLimits.maximumChildrenPerNode { - throw DecodingError.dataCorruptedError( - forKey: .content, - in: container, - debugDescription: "Comment content has too many child nodes." - ) - } - hasAttrs = container.contains(.attrs) - hasMarks = container.contains(.marks) - } - - var isPlainTextDocument: Bool { - guard type == "doc", - hasAttrs == false, - hasMarks == false, - let content, - content.isEmpty == false else { - return false - } - return content.allSatisfy(\.isPlainTextParagraph) - } - - private var isPlainTextParagraph: Bool { - guard type == "paragraph", - text == nil, - hasAttrs == false, - hasMarks == false else { - return false - } - return content?.allSatisfy(\.isPlainTextTextNode) ?? true - } - - private var isPlainTextTextNode: Bool { - type == "text" - && text != nil - && content == nil - && hasAttrs == false - && hasMarks == false - } - - func plainText() throws -> String { - var remainingNodes = CommentContentDecodingLimits.maximumNodeCount - var remainingText = CommentContentDecodingLimits.maximumAggregateTextLength - return try plainText(remainingNodes: &remainingNodes, remainingText: &remainingText) - } - - private func plainText(remainingNodes: inout Int, remainingText: inout Int) throws -> String { - remainingNodes -= 1 - guard remainingNodes >= 0 else { - throw DecodingError.dataCorrupted( - DecodingError.Context(codingPath: [], debugDescription: "Comment content has too many nodes.") - ) - } - - if type == "doc" { - var paragraphTexts: [String] = [] - for child in content ?? [] { - paragraphTexts.append( - try child.plainText(remainingNodes: &remainingNodes, remainingText: &remainingText) - ) - } - return paragraphTexts.joined(separator: "\n") - } - - if let text, text.isEmpty == false { - remainingText -= text.count - guard remainingText >= 0 else { - throw DecodingError.dataCorrupted( - DecodingError.Context(codingPath: [], debugDescription: "Comment content is too large.") - ) - } - return text - } - - var parts: [String] = [] - for child in content ?? [] { - let childText = try child.plainText(remainingNodes: &remainingNodes, remainingText: &remainingText) - if childText.isEmpty == false { - parts.append(childText) - } - } - - return parts.joined(separator: type == "paragraph" ? "" : " ") - } -} diff --git a/docmostly/Networking/DocmostEditablePage.swift b/docmostly/Networking/DocmostEditablePage.swift index 7159e09..534127a 100644 --- a/docmostly/Networking/DocmostEditablePage.swift +++ b/docmostly/Networking/DocmostEditablePage.swift @@ -39,3 +39,34 @@ nonisolated struct DocmostEditablePage: Decodable, Identifiable, Sendable { self.lastUpdatedBy = lastUpdatedBy } } + +nonisolated extension DocmostEditablePage { + private enum CodingKeys: String, CodingKey { + case id + case slugId + case title + case content + case icon + case spaceId + case createdAt + case updatedAt + case permissions + case creator + case lastUpdatedBy + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + slugId = try container.decode(String.self, forKey: .slugId) + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + content = try container.decodeIfPresent(ProseMirrorDocument.self, forKey: .content) + icon = try container.decodeIfPresent(String.self, forKey: .icon) + spaceId = try container.decode(String.self, forKey: .spaceId) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + permissions = try container.decodeIfPresent(DocmostPagePermissions.self, forKey: .permissions) + creator = try container.decodeIfPresent(DocmostPagePerson.self, forKey: .creator) + lastUpdatedBy = try container.decodeIfPresent(DocmostPagePerson.self, forKey: .lastUpdatedBy) + } +} diff --git a/docmostly/Networking/DocmostJSONDecoder.swift b/docmostly/Networking/DocmostJSONDecoder.swift index dad11b8..d372d65 100644 --- a/docmostly/Networking/DocmostJSONDecoder.swift +++ b/docmostly/Networking/DocmostJSONDecoder.swift @@ -3,6 +3,7 @@ import Foundation nonisolated enum DocmostJSONDecoder { static func make() -> JSONDecoder { let decoder = JSONDecoder() + decoder.userInfo[.proseMirrorDecodingBudget] = ProseMirrorDecodingBudget() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let value = try container.decode(String.self) @@ -23,4 +24,13 @@ nonisolated enum DocmostJSONDecoder { } return decoder } + + static func decode( + _ type: T.Type, + from data: Data, + using decoder: JSONDecoder + ) throws -> T { + decoder.userInfo[.proseMirrorDecodingBudget] = ProseMirrorDecodingBudget() + return try decoder.decode(type, from: data) + } } diff --git a/docmostly/Networking/DocmostPage.swift b/docmostly/Networking/DocmostPage.swift index 5c54ce6..9e8a41b 100644 --- a/docmostly/Networking/DocmostPage.swift +++ b/docmostly/Networking/DocmostPage.swift @@ -24,3 +24,56 @@ nonisolated struct DocmostPage: Decodable, Identifiable, Hashable, Sendable { let contributors: [DocmostPagePerson]? let space: DocmostPageSpace? } + +nonisolated extension DocmostPage { + private enum CodingKeys: String, CodingKey { + case id + case slugId + case title + case content + case icon + case coverPhoto + case parentPageId + case creatorId + case spaceId + case workspaceId + case isLocked + case lastUpdatedById + case createdAt + case updatedAt + case deletedAt + case position + case hasChildren + case permissions + case creator + case lastUpdatedBy + case contributors + case space + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + slugId = try container.decode(String.self, forKey: .slugId) + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + content = try container.decodeIfPresent(String.self, forKey: .content) + icon = try container.decodeIfPresent(String.self, forKey: .icon) + coverPhoto = try container.decodeIfPresent(String.self, forKey: .coverPhoto) + parentPageId = try container.decodeIfPresent(String.self, forKey: .parentPageId) + creatorId = try container.decodeIfPresent(String.self, forKey: .creatorId) + spaceId = try container.decode(String.self, forKey: .spaceId) + workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId) + isLocked = try container.decodeIfPresent(Bool.self, forKey: .isLocked) + lastUpdatedById = try container.decodeIfPresent(String.self, forKey: .lastUpdatedById) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) + deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt) + position = try container.decodeIfPresent(String.self, forKey: .position) + hasChildren = try container.decodeIfPresent(Bool.self, forKey: .hasChildren) + permissions = try container.decodeIfPresent(DocmostPagePermissions.self, forKey: .permissions) + creator = try container.decodeIfPresent(DocmostPagePerson.self, forKey: .creator) + lastUpdatedBy = try container.decodeIfPresent(DocmostPagePerson.self, forKey: .lastUpdatedBy) + contributors = try container.decodeIfPresent([DocmostPagePerson].self, forKey: .contributors) + space = try container.decodeIfPresent(DocmostPageSpace.self, forKey: .space) + } +} diff --git a/docmostly/Networking/DocmostTransclusionLookup.swift b/docmostly/Networking/DocmostTransclusionLookup.swift new file mode 100644 index 0000000..a8494f4 --- /dev/null +++ b/docmostly/Networking/DocmostTransclusionLookup.swift @@ -0,0 +1,90 @@ +import Foundation + +nonisolated struct DocmostTransclusionReference: Codable, Equatable, Hashable, Sendable { + let sourcePageId: String + let transclusionId: String +} + +nonisolated struct DocmostTransclusionLookupRequest: Encodable, Equatable, Sendable { + static let maximumReferences = 50 + + let references: [DocmostTransclusionReference] +} + +nonisolated struct DocmostTransclusionLookupResponse: Decodable, Equatable, Sendable { + let items: [DocmostTransclusionLookupItem] +} + +nonisolated enum DocmostTransclusionLookupStatus: String, Decodable, Equatable, Sendable { + case notFound = "not_found" + case noAccess = "no_access" +} + +nonisolated enum DocmostTransclusionLookupItem: Decodable, Equatable, Sendable { + case resolved( + reference: DocmostTransclusionReference, + content: ProseMirrorDocument, + sourceUpdatedAt: Date + ) + case notFound(reference: DocmostTransclusionReference) + case noAccess(reference: DocmostTransclusionReference) + + var reference: DocmostTransclusionReference { + switch self { + case .resolved(let reference, _, _), .notFound(let reference), .noAccess(let reference): + reference + } + } + + var content: ProseMirrorDocument? { + guard case .resolved(_, let content, _) = self else { return nil } + return content + } + + var sourceUpdatedAt: Date? { + guard case .resolved(_, _, let sourceUpdatedAt) = self else { return nil } + return sourceUpdatedAt + } + + var status: DocmostTransclusionLookupStatus? { + switch self { + case .resolved: + nil + case .notFound: + .notFound + case .noAccess: + .noAccess + } + } + + private enum CodingKeys: String, CodingKey { + case sourcePageId + case transclusionId + case content + case sourceUpdatedAt + case status + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let reference = DocmostTransclusionReference( + sourcePageId: try container.decode(String.self, forKey: .sourcePageId), + transclusionId: try container.decode(String.self, forKey: .transclusionId) + ) + + switch try container.decodeIfPresent(DocmostTransclusionLookupStatus.self, forKey: .status) { + case .notFound: + self = .notFound(reference: reference) + case .noAccess: + self = .noAccess(reference: reference) + case nil: + let content = try container.decode(ProseMirrorDocument.self, forKey: .content) + try content.validateNativeEditorBudget() + self = .resolved( + reference: reference, + content: content, + sourceUpdatedAt: try container.decode(Date.self, forKey: .sourceUpdatedAt) + ) + } + } +} diff --git a/docmostly/Networking/Endpoint.swift b/docmostly/Networking/Endpoint.swift index ab060a8..24f4e9b 100644 --- a/docmostly/Networking/Endpoint.swift +++ b/docmostly/Networking/Endpoint.swift @@ -39,6 +39,7 @@ nonisolated enum Endpoint: Sendable { case changeSpaceMemberRole(spaceId: String, role: String, userId: String? = nil, groupId: String? = nil) case sidebarPages(spaceId: String? = nil, pageId: String? = nil, cursor: String? = nil, limit: Int = 100) case pageInfo(pageId: String, format: ContentFormat = .html) + case transclusionLookup(DocmostTransclusionLookupRequest) case createPage( spaceId: String, parentPageId: String? = nil, @@ -212,6 +213,8 @@ nonisolated enum Endpoint: Sendable { "pages/sidebar-pages" case .pageInfo: "pages/info" + case .transclusionLookup: + "pages/transclusion/lookup" case .createPage: "pages/create" case .deletePage: @@ -408,6 +411,8 @@ nonisolated enum Endpoint: Sendable { return try encode(SidebarPagesRequest(spaceId: spaceId, pageId: pageId, cursor: cursor, limit: limit)) case .pageInfo(let pageId, let format): return try encode(PageInfoRequest(pageId: pageId, format: format.rawValue)) + case .transclusionLookup(let request): + return try encode(request) case .createPage(let spaceId, let parentPageId, let title, let icon, let content, let format): return try encode(CreatePageRequest( title: title, diff --git a/docmostly/Persistence/OfflineMutationPayload.swift b/docmostly/Persistence/OfflineMutationPayload.swift index abdcfe9..c482777 100644 --- a/docmostly/Persistence/OfflineMutationPayload.swift +++ b/docmostly/Persistence/OfflineMutationPayload.swift @@ -1,7 +1,13 @@ import Foundation nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { - case updatePage(pageId: String, title: String, document: ProseMirrorDocument) + case updatePage( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String? = nil, + baseDocument: ProseMirrorDocument? = nil + ) case createComment( localId: String, pageId: String, @@ -43,6 +49,8 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { case pageId case title case document + case baseTitle + case baseDocument case localId case content case plainText @@ -69,7 +77,9 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { self = try .updatePage( pageId: payload.decode(String.self, forKey: .pageId), title: payload.decode(String.self, forKey: .title), - document: payload.decode(ProseMirrorDocument.self, forKey: .document) + document: payload.decode(ProseMirrorDocument.self, forKey: .document), + baseTitle: payload.decodeIfPresent(String.self, forKey: .baseTitle), + baseDocument: payload.decodeIfPresent(ProseMirrorDocument.self, forKey: .baseDocument) ) } else if container.contains(.createComment) { let payload = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .createComment) @@ -161,11 +171,13 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .updatePage(let pageId, let title, let document): + case .updatePage(let pageId, let title, let document, let baseTitle, let baseDocument): var payload = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .updatePage) try payload.encode(pageId, forKey: .pageId) try payload.encode(title, forKey: .title) try payload.encode(document, forKey: .document) + try payload.encodeIfPresent(baseTitle, forKey: .baseTitle) + try payload.encodeIfPresent(baseDocument, forKey: .baseDocument) case .createComment( let localId, let pageId, @@ -265,7 +277,7 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { var coalescingKey: String? { switch self { - case .updatePage(let pageId, _, _): + case .updatePage(let pageId, _, _, _, _): "\(kind.rawValue):\(pageId)" case .resolveComment(let commentId, _, _): "\(kind.rawValue):\(commentId)" @@ -283,20 +295,53 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { } } + /// Only mutations that do not carry newly-authored user content may be discarded after + /// the server permanently rejects them. Keep this as an explicit allowlist so new payload + /// types default to durable retention until their failure semantics are reviewed. + var canDropAfterPermanentClientFailure: Bool { + switch self { + case .resolveComment, + .removePageLabel, + .addFavorite, + .removeFavorite, + .watchPage, + .unwatchPage, + .watchSpace, + .unwatchSpace, + .movePage, + .movePageToSpace: + true + case .updatePage, .createComment, .addPageLabels: + false + } + } + func replacingCommentIDs(_ mappings: [String: String]) -> OfflineMutationPayload { guard mappings.isEmpty == false else { return self } switch self { - case .updatePage(let pageId, let title, let document): + case .updatePage(let pageId, let title, let document, let baseTitle, let baseDocument): var patchedDocument = document + var patchedBaseDocument = baseDocument var didReplace = false for mapping in mappings { let replacement = patchedDocument.replacingCommentID(mapping.key, with: mapping.value) patchedDocument = replacement.document didReplace = didReplace || replacement.didReplace + if let baseDocument = patchedBaseDocument { + let baseReplacement = baseDocument.replacingCommentID(mapping.key, with: mapping.value) + patchedBaseDocument = baseReplacement.document + didReplace = didReplace || baseReplacement.didReplace + } } guard didReplace else { return self } - return .updatePage(pageId: pageId, title: title, document: patchedDocument) + return .updatePage( + pageId: pageId, + title: title, + document: patchedDocument, + baseTitle: baseTitle, + baseDocument: patchedBaseDocument + ) default: return self } diff --git a/docmostly/Persistence/OfflineMutationQueue.swift b/docmostly/Persistence/OfflineMutationQueue.swift index 38e8b3c..1ea315c 100644 --- a/docmostly/Persistence/OfflineMutationQueue.swift +++ b/docmostly/Persistence/OfflineMutationQueue.swift @@ -1,6 +1,18 @@ import Foundation import SwiftData +nonisolated enum OfflinePageUpdateSupersessionResult: Equatable, Sendable { + case enqueued + case superseded + case newerPendingUpdatePreserved +} + +nonisolated enum OfflinePageUpdateAcknowledgementResult: Equatable, Sendable { + case noPendingUpdate + case acknowledged + case newerPendingUpdatePreserved +} + nonisolated final class OfflineMutationQueue { private let context: ModelContext private let encoder = JSONEncoder() @@ -12,6 +24,7 @@ nonisolated final class OfflineMutationQueue { @discardableResult func enqueue(_ payload: OfflineMutationPayload, scope: CacheScope) throws -> OfflineMutationRecord { + let payload = try preservingOldestPageUpdateBase(in: payload, scope: scope) let payloadData = try encoder.encode(payload) let replacementOrder = try removeCoalescedMutation(for: payload, scope: scope) let replayOrder = if let replacementOrder { @@ -35,6 +48,176 @@ nonisolated final class OfflineMutationQueue { try context.save() } + func supersedePendingPageUpdate( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String? = nil, + baseDocument: ProseMirrorDocument? = nil, + snapshotCapturedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateSupersessionResult { + var payload = OfflineMutationPayload.updatePage( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument + ) + let coalescingKey = "\(OfflineMutationKind.updatePage.rawValue):\(pageId)" + + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + let descriptor = FetchDescriptor( + predicate: #Predicate { mutation in + mutation.cacheServerBaseURL == serverBaseURL && + mutation.cacheUserID == userID && + mutation.coalescingKey == coalescingKey + }, + sortBy: [ + SortDescriptor(\.replayOrder), + SortDescriptor(\.createdAt) + ] + ) + let matches = try context.fetch(descriptor) + guard let retainedMutation = matches.first else { + let mutation = QueuedOfflineMutation( + payload: payload, + scope: scope, + payloadData: try encoder.encode(payload), + replayOrder: try nextReplayOrder(scope: scope) + ) + mutation.createdAt = snapshotCapturedAt + mutation.updatedAt = snapshotCapturedAt + context.insert(mutation) + try context.save() + return .enqueued + } + if case .updatePage(_, _, _, let oldestBaseTitle, let oldestBaseDocument) = try decoder.decode( + OfflineMutationPayload.self, + from: retainedMutation.payloadData + ) { + payload = .updatePage( + pageId: pageId, + title: title, + document: document, + baseTitle: oldestBaseTitle, + baseDocument: oldestBaseDocument + ) + } + guard matches.allSatisfy({ $0.createdAt <= snapshotCapturedAt }) else { + return .newerPendingUpdatePreserved + } + + retainedMutation.kindRaw = payload.kind.rawValue + retainedMutation.coalescingKey = coalescingKey + retainedMutation.payloadData = try encoder.encode(payload) + retainedMutation.createdAt = snapshotCapturedAt + retainedMutation.updatedAt = .now + retainedMutation.attemptCount = 0 + retainedMutation.lastErrorMessage = nil + + for mutation in matches.dropFirst() { + context.delete(mutation) + } + try context.save() + return .superseded + } + + func acknowledgePendingPageUpdate( + pageId: String, + snapshotCapturedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateAcknowledgementResult { + let coalescingKey = "\(OfflineMutationKind.updatePage.rawValue):\(pageId)" + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + let descriptor = FetchDescriptor( + predicate: #Predicate { mutation in + mutation.cacheServerBaseURL == serverBaseURL && + mutation.cacheUserID == userID && + mutation.coalescingKey == coalescingKey + } + ) + let matches = try context.fetch(descriptor) + guard matches.isEmpty == false else { return .noPendingUpdate } + guard matches.allSatisfy({ $0.createdAt <= snapshotCapturedAt }) else { + return .newerPendingUpdatePreserved + } + + for mutation in matches { + context.delete(mutation) + } + try context.save() + return .acknowledged + } + + // swiftlint:disable:next function_parameter_count + func resolvePendingPageUpdateKeepingLocal( + pageId: String, + title: String, + document: ProseMirrorDocument, + remoteBaseTitle: String, + remoteBaseDocument: ProseMirrorDocument, + replacingThrough cutoff: Date, + resolvedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateSupersessionResult { + let payload = OfflineMutationPayload.updatePage( + pageId: pageId, + title: title, + document: document, + baseTitle: remoteBaseTitle, + baseDocument: remoteBaseDocument + ) + let coalescingKey = "\(OfflineMutationKind.updatePage.rawValue):\(pageId)" + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + let descriptor = FetchDescriptor( + predicate: #Predicate { mutation in + mutation.cacheServerBaseURL == serverBaseURL && + mutation.cacheUserID == userID && + mutation.coalescingKey == coalescingKey + }, + sortBy: [ + SortDescriptor(\.replayOrder), + SortDescriptor(\.createdAt) + ] + ) + let matches = try context.fetch(descriptor) + + guard matches.allSatisfy({ $0.createdAt <= cutoff }) else { + return .newerPendingUpdatePreserved + } + + guard let retainedMutation = matches.first else { + let mutation = QueuedOfflineMutation( + payload: payload, + scope: scope, + payloadData: try encoder.encode(payload), + replayOrder: try nextReplayOrder(scope: scope), + createdAt: resolvedAt + ) + context.insert(mutation) + try context.save() + return .enqueued + } + + retainedMutation.kindRaw = payload.kind.rawValue + retainedMutation.coalescingKey = coalescingKey + retainedMutation.payloadData = try encoder.encode(payload) + retainedMutation.createdAt = resolvedAt + retainedMutation.updatedAt = resolvedAt + retainedMutation.attemptCount = 0 + retainedMutation.lastErrorMessage = nil + + for mutation in matches.dropFirst() { + context.delete(mutation) + } + try context.save() + return .superseded + } + func removePendingPageLabel(pageId: String, localId: String, scope: CacheScope) throws { try updatePendingMutations(scope: scope) { payload in guard case .addPageLabels(let queuedPageId, let labels) = payload, queuedPageId == pageId else { @@ -122,6 +305,45 @@ nonisolated final class OfflineMutationQueue { return replayOrder } + private func preservingOldestPageUpdateBase( + in payload: OfflineMutationPayload, + scope: CacheScope + ) throws -> OfflineMutationPayload { + guard case .updatePage(let pageId, let title, let document, _, _) = payload, + let coalescingKey = payload.coalescingKey + else { + return payload + } + + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + var descriptor = FetchDescriptor( + predicate: #Predicate { mutation in + mutation.cacheServerBaseURL == serverBaseURL && + mutation.cacheUserID == userID && + mutation.coalescingKey == coalescingKey + }, + sortBy: [SortDescriptor(\.createdAt)] + ) + descriptor.fetchLimit = 1 + guard let existingMutation = try context.fetch(descriptor).first, + case .updatePage(_, _, _, let oldestBaseTitle, let oldestBaseDocument) = try decoder.decode( + OfflineMutationPayload.self, + from: existingMutation.payloadData + ) + else { + return payload + } + + return .updatePage( + pageId: pageId, + title: title, + document: document, + baseTitle: oldestBaseTitle, + baseDocument: oldestBaseDocument + ) + } + private enum PendingMutationUpdate { case unchanged case replace(OfflineMutationPayload) diff --git a/docmostly/Persistence/OfflineMutationQueueRepository.swift b/docmostly/Persistence/OfflineMutationQueueRepository.swift index 3c67579..7f0faa9 100644 --- a/docmostly/Persistence/OfflineMutationQueueRepository.swift +++ b/docmostly/Persistence/OfflineMutationQueueRepository.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftData actor OfflineMutationQueueRepository { @@ -28,6 +29,61 @@ actor OfflineMutationQueueRepository { try queue().removeCoalescedMutations(for: payload, scope: scope) } + func supersedePendingPageUpdate( + pageId: String, + title: String, + document: ProseMirrorDocument, + baseTitle: String? = nil, + baseDocument: ProseMirrorDocument? = nil, + snapshotCapturedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateSupersessionResult { + try queue().supersedePendingPageUpdate( + pageId: pageId, + title: title, + document: document, + baseTitle: baseTitle, + baseDocument: baseDocument, + snapshotCapturedAt: snapshotCapturedAt, + scope: scope + ) + } + + func acknowledgePendingPageUpdate( + pageId: String, + snapshotCapturedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateAcknowledgementResult { + try queue().acknowledgePendingPageUpdate( + pageId: pageId, + snapshotCapturedAt: snapshotCapturedAt, + scope: scope + ) + } + + // swiftlint:disable:next function_parameter_count + func resolvePendingPageUpdateKeepingLocal( + pageId: String, + title: String, + document: ProseMirrorDocument, + remoteBaseTitle: String, + remoteBaseDocument: ProseMirrorDocument, + replacingThrough cutoff: Date, + resolvedAt: Date, + scope: CacheScope + ) throws -> OfflinePageUpdateSupersessionResult { + try queue().resolvePendingPageUpdateKeepingLocal( + pageId: pageId, + title: title, + document: document, + remoteBaseTitle: remoteBaseTitle, + remoteBaseDocument: remoteBaseDocument, + replacingThrough: cutoff, + resolvedAt: resolvedAt, + scope: scope + ) + } + func removePendingPageLabel(pageId: String, localId: String, scope: CacheScope) throws { try queue().removePendingPageLabel(pageId: pageId, localId: localId, scope: scope) } diff --git a/docmostly/Persistence/OfflinePageUpdateReplayDecision.swift b/docmostly/Persistence/OfflinePageUpdateReplayDecision.swift new file mode 100644 index 0000000..fe01d44 --- /dev/null +++ b/docmostly/Persistence/OfflinePageUpdateReplayDecision.swift @@ -0,0 +1,72 @@ +import Foundation + +nonisolated enum OfflinePageUpdateReplayDecision: Equatable, Sendable { + case alreadySynchronized + case updateTitleOnly + case replaceDocument(title: String?) + case conflict + + static func resolve( + serverPage: DocmostEditablePage, + queuedTitle: String, + queuedDocument: ProseMirrorDocument, + baseTitle: String?, + baseDocument: ProseMirrorDocument? + ) -> Self { + if serverPage.content == queuedDocument { + return titleDecision( + serverTitle: serverPage.title, + queuedTitle: queuedTitle, + baseTitle: baseTitle, + documentsAlreadySynchronized: true + ) + } + + guard let baseDocument, serverPage.content == baseDocument else { + return .conflict + } + switch titleDecision( + serverTitle: serverPage.title, + queuedTitle: queuedTitle, + baseTitle: baseTitle, + documentsAlreadySynchronized: false + ) { + case .alreadySynchronized: + return .replaceDocument(title: nil) + case .updateTitleOnly: + return .replaceDocument(title: queuedTitle) + case .replaceDocument: + return .replaceDocument(title: nil) + case .conflict: + return .conflict + } + } + + private static func titleDecision( + serverTitle: String, + queuedTitle: String, + baseTitle: String?, + documentsAlreadySynchronized: Bool + ) -> Self { + guard serverTitle != queuedTitle else { return .alreadySynchronized } + guard let baseTitle else { return .conflict } + + let localTitleChanged = queuedTitle != baseTitle + let remoteTitleChanged = serverTitle != baseTitle + if localTitleChanged, remoteTitleChanged { + return .conflict + } + if localTitleChanged { + return .updateTitleOnly + } + return documentsAlreadySynchronized ? .alreadySynchronized : .replaceDocument(title: nil) + } +} + +nonisolated struct OfflinePageUpdateReplayConflict: LocalizedError, Sendable { + let pageID: String + + var errorDescription: String? { + "Page \(pageID) changed remotely after this offline draft was captured. The local draft was retained." + } +} diff --git a/docmostlyTests/App/AppStateNavigationSelectionTests.swift b/docmostlyTests/App/AppStateNavigationSelectionTests.swift index 5f5bbfa..4a01eff 100644 --- a/docmostlyTests/App/AppStateNavigationSelectionTests.swift +++ b/docmostlyTests/App/AppStateNavigationSelectionTests.swift @@ -60,6 +60,26 @@ struct AppStateNavigationSelectionTests { #expect(appState.selectedPageID == nil) } + @Test func leavingTheSelectedPageClearsItsPageAndCommentSelection() { + let appState = makeAppState() + appState.selectPage(id: "page-1", commentID: "comment-1") + + appState.clearSelectedPage(ifMatching: "page-1") + + #expect(appState.selectedPageID == nil) + #expect(appState.selectedCommentID == nil) + } + + @Test func disappearingPreviousPageDoesNotClearNewerPageSelection() { + let appState = makeAppState() + appState.selectPage(id: "page-2", commentID: "comment-2") + + appState.clearSelectedPage(ifMatching: "page-1") + + #expect(appState.selectedPageID == "page-2") + #expect(appState.selectedCommentID == "comment-2") + } + @Test func switchingSpacesAfterOpeningAuxiliaryPageClearsStalePageSelection() { let appState = makeAppState() let target = PageOpenTarget( diff --git a/docmostlyTests/Comments/CommentBodyTests.swift b/docmostlyTests/Comments/CommentBodyTests.swift new file mode 100644 index 0000000..f9bd8f7 --- /dev/null +++ b/docmostlyTests/Comments/CommentBodyTests.swift @@ -0,0 +1,245 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct CommentBodyTests { + @Test func composerSerializesFormattingAndSafeLinksToDocmostJSON() throws { + let draft = CommentComposerState(body: CommentBody(plainText: "Docmost")) + draft.selection = AttributedTextSelection(range: draft.text.startIndex.. (Data, URLResponse) { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: "HTTP/1.1", + headerFields: nil + ) else { + throw APIError.invalidResponse + } + return (Data(#"{"message":"You cannot edit this comment."}"#.utf8), response) + } + + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { + throw APIError.invalidResponse + } +} + +private actor CommentCapturingHTTPDataLoader: HTTPDataLoading { + private(set) var request: URLRequest? + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.request = request + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + ) else { + throw APIError.invalidResponse + } + + let data = Data(""" + { + "data": { + "id": "comment-1", + "content": { + "type": "doc", + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": "Ship it", + "marks": [{ "type": "italic" }] + }] + }] + }, + "selection": null, + "type": "page", + "creatorId": "user-1", + "pageId": "page-1", + "parentCommentId": null, + "resolvedById": null, + "resolvedAt": null, + "workspaceId": "workspace-1", + "spaceId": "space-1", + "createdAt": "2026-07-18T10:00:00.000Z", + "editedAt": null, + "deletedAt": null + }, + "success": true, + "status": 200 + } + """.utf8) + return (data, response) + } + + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { + throw APIError.invalidResponse + } +} diff --git a/docmostlyTests/Editor/NativeEditorAutosaveCoordinatorTests.swift b/docmostlyTests/Editor/NativeEditorAutosaveCoordinatorTests.swift new file mode 100644 index 0000000..6345a1e --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorAutosaveCoordinatorTests.swift @@ -0,0 +1,187 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorAutosaveCoordinatorTests { + @Test func debounceCoalescesRapidEditRequests() async { + let delay = ControlledAutosaveDelay() + let coordinator = NativeEditorAutosaveCoordinator(waitForDebounce: delay.wait) + var operationCount = 0 + + coordinator.schedule { + operationCount += 1 + } + await delay.waitForWaiterCount(1) + coordinator.schedule { + operationCount += 1 + } + await delay.waitForWaiterCount(2) + coordinator.schedule { + operationCount += 1 + } + await delay.waitForWaiterCount(3) + + delay.resumeAll() + await waitUntil { operationCount == 1 } + + #expect(operationCount == 1) + } + + @Test func inFlightPersistenceIsNotCancelledAndRunsOneFollowUp() async { + let delay = ControlledAutosaveDelay() + let saver = ControlledAutosaveSaver() + let coordinator = NativeEditorAutosaveCoordinator(waitForDebounce: delay.wait) + + coordinator.schedule { + await saver.save() + } + await delay.waitForWaiterCount(1) + delay.resumeAll() + await saver.waitForInvocationCount(1) + + coordinator.schedule { + await saver.save() + } + await delay.waitForWaiterCount(1) + coordinator.schedule { + await saver.save() + } + await delay.waitForWaiterCount(2) + delay.resumeAll() + await Task.yield() + + #expect(saver.invocationCount == 1) + #expect(saver.cancellationAtStart == [false]) + + saver.completeNext() + await saver.waitForInvocationCount(2) + + #expect(saver.invocationCount == 2) + #expect(saver.cancellationAtStart == [false, false]) + #expect(saver.cancellationAtCompletion == [false]) + + saver.completeNext() + await waitUntil { saver.cancellationAtCompletion.count == 2 } + #expect(saver.cancellationAtCompletion == [false, false]) + } + + @Test func flushStartsImmediatelyAndInvalidatesPendingDebounce() async { + let delay = ControlledAutosaveDelay() + let coordinator = NativeEditorAutosaveCoordinator(waitForDebounce: delay.wait) + var operationCount = 0 + + coordinator.schedule { + operationCount += 1 + } + await delay.waitForWaiterCount(1) + coordinator.flush { + operationCount += 1 + } + await waitUntil { operationCount == 1 } + + delay.resumeAll() + await Task.yield() + + #expect(operationCount == 1) + } + + @Test func boundedWaitCompletesWithPersistence() async { + let saver = ControlledAutosaveSaver() + let coordinator = NativeEditorAutosaveCoordinator() + + coordinator.flush { + await saver.save() + } + await saver.waitForInvocationCount(1) + let waitTask = Task { + await coordinator.waitForCurrentPersistence(timeout: .seconds(60)) + } + + saver.completeNext() + + #expect(await waitTask.value) + } + + @Test func cancellingBoundedWaitDoesNotCancelPersistence() async { + let saver = ControlledAutosaveSaver() + let coordinator = NativeEditorAutosaveCoordinator() + + coordinator.flush { + await saver.save() + } + await saver.waitForInvocationCount(1) + let waitTask = Task { + await coordinator.waitForCurrentPersistence(timeout: .seconds(60)) + } + await Task.yield() + + waitTask.cancel() + + #expect(await waitTask.value == false) + #expect(saver.invocationCount == 1) + #expect(saver.cancellationAtStart == [false]) + saver.completeNext() + await waitUntil { saver.cancellationAtCompletion.count == 1 } + #expect(saver.cancellationAtCompletion == [false]) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<100 where condition() == false { + await Task.yield() + } + } +} + +@MainActor +private final class ControlledAutosaveDelay { + private(set) var continuations: [CheckedContinuation] = [] + + func wait() async throws { + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitForWaiterCount(_ count: Int) async { + for _ in 0..<100 where continuations.count < count { + await Task.yield() + } + } + + func resumeAll() { + let pendingContinuations = continuations + continuations.removeAll() + for continuation in pendingContinuations { + continuation.resume() + } + } +} + +@MainActor +private final class ControlledAutosaveSaver { + private(set) var invocationCount = 0 + private(set) var cancellationAtStart: [Bool] = [] + private(set) var cancellationAtCompletion: [Bool] = [] + private var continuations: [CheckedContinuation] = [] + + func save() async { + invocationCount += 1 + cancellationAtStart.append(Task.isCancelled) + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + cancellationAtCompletion.append(Task.isCancelled) + } + + func waitForInvocationCount(_ count: Int) async { + for _ in 0..<100 where invocationCount < count { + await Task.yield() + } + } + + func completeNext() { + guard continuations.isEmpty == false else { return } + continuations.removeFirst().resume() + } +} diff --git a/docmostlyTests/Editor/NativeEditorAwarenessFrameTests.swift b/docmostlyTests/Editor/NativeEditorAwarenessFrameTests.swift index 22a65ad..27391bf 100644 --- a/docmostlyTests/Editor/NativeEditorAwarenessFrameTests.swift +++ b/docmostlyTests/Editor/NativeEditorAwarenessFrameTests.swift @@ -3,6 +3,42 @@ import Testing @testable import docmostly struct NativeEditorAwarenessFrameTests { + @Test func relativePositionJSONPreservesExplicitNullBoundariesForYjs() throws { + let position = NativeEditorYjsRelativePosition( + type: nil, + targetName: NativeEditorCollaborationDocument.yjsFragmentName, + item: nil, + assoc: 0 + ) + + let object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(position)) as? [String: Any] + ) + + #expect(object["item"] is NSNull) + #expect(object["type"] is NSNull) + #expect(object["tname"] as? String == NativeEditorCollaborationDocument.yjsFragmentName) + #expect(object["assoc"] as? Int == 0) + } + + @Test func nestedYjsTypeTargetsPageFragmentWhileAnotherNamedRootDoesNot() { + let nestedPosition = NativeEditorYjsRelativePosition( + type: .id(NativeEditorYjsID(client: 7, clock: 1)), + targetName: nil, + item: NativeEditorYjsID(client: 7, clock: 2), + assoc: 0 + ) + let otherRootPosition = NativeEditorYjsRelativePosition( + type: nil, + targetName: "another-fragment", + item: nil, + assoc: 0 + ) + + #expect(nestedPosition.targetsDocmostDefaultFragment) + #expect(otherRootPosition.targetsDocmostDefaultFragment == false) + } + @Test func encodesMissingAwarenessCursorAsExplicitNull() throws { let update = try NativeEditorHocuspocusFrame.awarenessUpdate(states: [ NativeEditorAwarenessState( diff --git a/docmostlyTests/Editor/NativeEditorCRDTCollaborationTests.swift b/docmostlyTests/Editor/NativeEditorCRDTCollaborationTests.swift index b1983f3..4d2f46d 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTCollaborationTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTCollaborationTests.swift @@ -196,6 +196,26 @@ struct NativeEditorCRDTCollaborationTests { #expect(frame.message == .sync(.stepOne(Data([21, 22])))) } + @Test func receiveOnlyCollaborationSessionKeepsCRDTDriverWithoutLocalAwareness() async throws { + let engine = RecordingCRDTDocumentEngine() + engine.encodedStateVector = Data([21, 22]) + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Page", + crdtDocumentEngine: engine + ) + + let collaborationSession = viewModel.collaborationSession(participation: .receiveOnly) + let driver = try #require(collaborationSession.syncDriver) + let frames = try await driver.outboundFramesAfterAuthentication() + let frame = try NativeEditorHocuspocusFrame.parse(try #require(frames.first)) + + #expect(collaborationSession.participation == .receiveOnly) + #expect(collaborationSession.localAwarenessCursor == nil) + #expect(collaborationSession.localAwarenessUpdates == nil) + #expect(frame.message == .sync(.stepOne(Data([21, 22])))) + } + @Test func viewModelResolvesRemoteCursorsThroughCRDTEngine() async throws { let remoteCursor = remoteCursor(id: "user-2", name: "Alice") let resolvedCursor = NativeEditorResolvedRemoteCursor( diff --git a/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift b/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift index 072ab80..e4c737d 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift @@ -34,7 +34,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { #expect(viewModel.isDirty == false) } - @Test func preservesLocalDirtyStateWhenApplyingCRDTDocumentSnapshot() { + @Test func defersDivergentCRDTSnapshotWithoutReplacingDirtyLocalDocument() { let engine = SnapshotCRDTDocumentEngine() let viewModel = NativeRichEditorViewModel( pageID: "page-1", @@ -55,10 +55,236 @@ struct NativeEditorCRDTDocumentSnapshotTests { viewModel.applyCRDTDocumentSnapshot(snapshot) - #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Merged draft"]) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) #expect(viewModel.isDirty == true) + #expect(viewModel.pendingRemoteUpdate?.title == "Local") + #expect( + viewModel.pendingRemoteCRDTSnapshot?.document.proseMirrorDocument == + snapshot.document.proseMirrorDocument + ) + #expect(viewModel.realtimeStatus == .conflict) + } + + @Test func laterMatchingOrPartialSnapshotCannotClearEarlierDeferredConflict() { + let engine = SnapshotCRDTDocumentEngine() + let localText = "Second line survives autosave — merged after Backspace" + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Original") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString(localText) + viewModel.handleDocumentChanged() + + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Second line survives autosav", updatedAt: 20)) + viewModel.applyCRDTDocumentSnapshot(snapshot(text: localText, updatedAt: 21)) + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Second line survives autosave", updatedAt: 22)) + + #expect(viewModel.document.blocks.map { String($0.text.characters) } == [localText]) + #expect(viewModel.lastSavedDocument.blocks.map { String($0.text.characters) } == ["Original"]) + #expect(viewModel.pendingRemoteCRDTSnapshot?.updatedAt == Date(timeIntervalSince1970: 22)) + #expect(viewModel.pendingRemoteUpdate != nil) + #expect(viewModel.realtimeStatus == .conflict) + #expect(viewModel.isDirty) + } + + @Test func matchingSnapshotPreservesDirtyAutosaveWithoutReplacingLiveDocumentOrHistory() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Original") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString("Current draft") + viewModel.handleDocumentChanged() + let liveBlockID = viewModel.document.blocks[0].id + #expect(viewModel.canUndo) + + let matchingSnapshot = snapshot(text: "Current draft", updatedAt: 20) + #expect(matchingSnapshot.document.proseMirrorDocument.isCollaborationEquivalent( + to: viewModel.document.proseMirrorDocument + )) + viewModel.applyCRDTDocumentSnapshot(matchingSnapshot) + + #expect(viewModel.document.blocks[0].id == liveBlockID) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Current draft"]) + #expect(viewModel.lastSavedDocument.blocks.map { String($0.text.characters) } == ["Original"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) #expect(viewModel.pendingRemoteUpdate == nil) #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.canUndo) + #expect(viewModel.isDirty) + #expect(viewModel.hasOutgoingChangesRequiringPersistence) + } + + @Test func orderedMatchingSelfEchoDoesNotShowConflictOrCancelAutosave() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Original") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Original", updatedAt: 10)) + viewModel.document.blocks[0].text = AttributedString("ABCDE") + viewModel.handleDocumentChanged() + + let matchingSnapshot = snapshot(text: "ABCDE", updatedAt: 20) + #expect(matchingSnapshot.document.proseMirrorDocument.isCollaborationEquivalent( + to: viewModel.document.proseMirrorDocument + )) + viewModel.applyCRDTDocumentSnapshot(matchingSnapshot) + + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["ABCDE"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.isDirty) + } + + @Test func paragraphIDChangeIsNotTreatedAsMatchingSelfEcho() { + let local = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("local-anchor")], + content: [ProseMirrorNode(type: "text", text: "Same text")] + ) + ]) + let remote = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("remote-anchor")], + content: [ProseMirrorNode(type: "text", text: "Same text")] + ) + ]) + + #expect(local.isCollaborationEquivalent(to: remote) == false) + } + + @Test func immediateEditBeforeInitialSyncIsBufferedThenPublishedOverAuthoritativeBaseline() async throws { + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "REST baseline") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + + viewModel.document.blocks[0].text = AttributedString("Typed immediately") + viewModel.handleDocumentChanged() + try await viewModel.waitForPendingCRDTLocalChange() + #expect(engine.integratedDocuments.isEmpty) + + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "REST baseline", updatedAt: 20)) + try await viewModel.waitForPendingCRDTLocalChange() + + #expect(engine.integratedDocuments.map { $0.blocks.map { String($0.text.characters) } } == [ + ["Typed immediately"] + ]) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Typed immediately"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.isDirty) + } + + @Test func coordinatorAppliesRemoteUpdateOnlyAfterQueuedLocalIntegration() async throws { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Baseline") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Baseline", updatedAt: 10)) + viewModel.document.blocks[0].text = AttributedString("Local before remote") + viewModel.handleDocumentChanged() + let coordinator = try #require(viewModel.crdtSyncCoordinator) + + _ = try await coordinator.receive(.update(Data([9]))) + + #expect(engine.events == ["local", "remote"]) + } + + @Test func applyInstallsDeferredCRDTSnapshotAndClearsLocalHistory() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = makeDirtyViewModel(engine: engine) + let remoteSnapshot = snapshot(text: "Remote body", title: "Remote title", updatedAt: 20) + viewModel.applyCRDTDocumentSnapshot(remoteSnapshot) + + viewModel.acceptPendingRemoteUpdate() + + #expect(viewModel.title == "Remote title") + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Remote body"]) + #expect(viewModel.lastSavedDocument == viewModel.document) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.canUndo == false) + #expect(viewModel.isDirty == false) + #expect(viewModel.realtimeStatus == .connected) + } + + @Test func keepMineRebasesToRemoteAndPublishesRetainedLocalDocument() async throws { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = makeDirtyViewModel(engine: engine) + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Remote body", updatedAt: 20)) + + viewModel.rejectPendingRemoteUpdate() + try await viewModel.waitForPendingCRDTLocalChange() + + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) + #expect(viewModel.lastSavedDocument.blocks.map { String($0.text.characters) } == ["Remote body"]) + #expect(engine.integratedDocuments.last?.blocks.map { String($0.text.characters) } == ["Local draft"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.hasOutgoingChangesRequiringPersistence) + #expect(viewModel.isDirty) + #expect(viewModel.realtimeStatus == .connected) + } + + @Test func capturedConflictCannotAcceptNewerPendingSnapshot() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = makeDirtyViewModel(engine: engine) + let capturedSnapshot = snapshot(text: "First remote body", updatedAt: 20) + let newerSnapshot = snapshot(text: "Newer remote body", updatedAt: 21) + viewModel.applyCRDTDocumentSnapshot(capturedSnapshot) + let capturedUpdate = viewModel.pendingRemoteUpdate + viewModel.applyCRDTDocumentSnapshot(newerSnapshot) + + #expect(viewModel.acceptPendingRemoteUpdate( + matching: capturedSnapshot, + remoteUpdate: capturedUpdate + ) == false) + #expect(viewModel.pendingRemoteCRDTSnapshot == newerSnapshot) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) + } + + @Test func capturedConflictCannotRejectNewerPendingSnapshot() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = makeDirtyViewModel(engine: engine) + let capturedSnapshot = snapshot(text: "First remote body", updatedAt: 20) + let newerSnapshot = snapshot(text: "Newer remote body", updatedAt: 21) + viewModel.applyCRDTDocumentSnapshot(capturedSnapshot) + let capturedUpdate = viewModel.pendingRemoteUpdate + viewModel.applyCRDTDocumentSnapshot(newerSnapshot) + + #expect(viewModel.rejectPendingRemoteUpdate( + matching: capturedSnapshot, + remoteUpdate: capturedUpdate + ) == false) + #expect(viewModel.pendingRemoteCRDTSnapshot == newerSnapshot) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) } @Test func exposesCRDTDocumentSnapshotStreamFromEngine() async { @@ -88,11 +314,54 @@ struct NativeEditorCRDTDocumentSnapshotTests { #expect(emittedSnapshot?.document == snapshot.document) #expect(emittedSnapshot?.updatedAt == snapshot.updatedAt) } + + private func makeDirtyViewModel(engine: SnapshotCRDTDocumentEngine) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Local", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Original") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString("Local draft") + viewModel.handleDocumentChanged() + return viewModel + } + + private func snapshot( + text: String, + title: String? = "Daily notes", + updatedAt: TimeInterval + ) -> NativeEditorCRDTDocumentSnapshot { + NativeEditorCRDTDocumentSnapshot( + title: title, + document: document(text: text), + updatedAt: Date(timeIntervalSince1970: updatedAt) + ) + } + + private func document(text: String) -> NativeEditorDocument { + NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("stable-test-anchor")], + content: [ProseMirrorNode(type: "text", text: text)] + ) + ])) + } } @MainActor private final class SnapshotCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { + nonisolated let requiresInitialRemoteSnapshot: Bool var snapshotStream: AsyncStream? + private(set) var integratedDocuments: [NativeEditorDocument] = [] + private(set) var events: [String] = [] + + init(requiresInitialRemoteSnapshot: Bool = false) { + self.requiresInitialRemoteSnapshot = requiresInitialRemoteSnapshot + } func encodeStateVector() async throws -> Data { Data() @@ -102,7 +371,14 @@ private final class SnapshotCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { Data() } - func applyRemoteUpdate(_ update: Data) async throws { } + func applyRemoteUpdate(_ update: Data) async throws { + events.append("remote") + } + + func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { + integratedDocuments.append(change.after.document) + events.append("local") + } func flushPendingLocalChanges( title: String, diff --git a/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift b/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift index ec9a1e0..2bf9b5a 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift @@ -5,6 +5,26 @@ import Testing @MainActor struct NativeEditorCRDTSavesTests { + @Test func untitledPageCanPersistBodyEdits() async { + let engine = SavingCRDTDocumentEngine() + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "", + crdtDocumentEngine: engine + ) + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.resetEditingHistory() + + viewModel.document.blocks[0].text = AttributedString("Capture this without naming the page") + viewModel.handleDocumentChanged() + + #expect(viewModel.canSave) + #expect(await viewModel.save(appState: AppState())) + #expect(engine.flushRequests.first?.title == "") + #expect(viewModel.isDirty == false) + } + @Test func crdtBackedSaveFlushesDocumentEngineWithoutRESTClient() async { let engine = SavingCRDTDocumentEngine() engine.saveResult = NativeEditorCRDTSaveResult(updatedAt: Date(timeIntervalSince1970: 20)) @@ -84,7 +104,7 @@ struct NativeEditorCRDTSavesTests { #expect(engine.events == [.integrateLocalChange, .flushPendingLocalChanges]) } - @Test func crdtBackedSaveFailsWhenPendingLocalChangeIntegrationFails() async { + @Test func crdtBackedSaveRepairsFailedPendingIntegrationWithFullSnapshotFlush() async { let engine = SavingCRDTDocumentEngine() engine.integrationError = APIError.connectionFailed("CRDT local merge failed.") let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("Draft"), alignment: .left) @@ -101,18 +121,12 @@ struct NativeEditorCRDTSavesTests { let didSave = await viewModel.save(appState: AppState()) - #expect(didSave == false) - #expect(engine.events == [.integrateLocalChange]) - #expect(engine.flushRequests.isEmpty) - #expect(viewModel.isDirty == true) - #expect( - viewModel.saveErrorMessage == - APIError.connectionFailed("CRDT local merge failed.").localizedDescription - ) - #expect( - viewModel.realtimeStatus == - .failed(APIError.connectionFailed("CRDT local merge failed.").localizedDescription) - ) + #expect(didSave) + #expect(engine.events == [.integrateLocalChange, .flushPendingLocalChanges]) + #expect(engine.flushRequests.count == 1) + #expect(engine.flushRequests.first?.document == viewModel.document) + #expect(viewModel.isDirty == false) + #expect(viewModel.saveErrorMessage == nil) } } diff --git a/docmostlyTests/Editor/NativeEditorCollaborationScopeTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationScopeTests.swift index 255399e..04fe81d 100644 --- a/docmostlyTests/Editor/NativeEditorCollaborationScopeTests.swift +++ b/docmostlyTests/Editor/NativeEditorCollaborationScopeTests.swift @@ -8,4 +8,44 @@ struct NativeEditorCollaborationScopeTests { #expect(NativeEditorCollaborationScope.readonly.allowsLocalAwarenessUpdates == true) #expect(NativeEditorCollaborationScope.unknown.allowsLocalAwarenessUpdates == false) } + + @Test func receiveOnlyParticipationDisablesLocalSendersButKeepsInboundSync() throws { + let context = NativeEditorCollaborationSessionContext( + url: try #require(URL(string: "wss://docs.example.com/collab")), + token: "token", + documentName: "page.page-1", + participation: .receiveOnly, + user: nil, + syncDriver: nil, + localAwarenessCursor: nil, + localAwarenessUpdates: nil + ) + + #expect(context.allowsInitialDocumentSync(for: .readWrite)) + #expect(context.allowsLocalAwarenessUpdates(for: .readWrite) == false) + #expect(context.allowsLocalDocumentUpdates(for: .readWrite) == false) + #expect(context.allowsSyncReply(to: .stepOne(Data([1])), authenticatedScope: .readWrite) == false) + #expect(context.allowsSyncReply(to: .stepTwo(Data([2])), authenticatedScope: .readWrite)) + #expect(context.allowsSyncReply(to: .update(Data([3])), authenticatedScope: .readWrite)) + } + + @Test func interactiveParticipationStartsLocalSendersWhenScopePermits() throws { + let context = NativeEditorCollaborationSessionContext( + url: try #require(URL(string: "wss://docs.example.com/collab")), + token: "token", + documentName: "page.page-1", + participation: .interactive, + user: nil, + syncDriver: nil, + localAwarenessCursor: nil, + localAwarenessUpdates: nil + ) + + #expect(context.allowsInitialDocumentSync(for: .readWrite)) + #expect(context.allowsLocalAwarenessUpdates(for: .readWrite)) + #expect(context.allowsLocalDocumentUpdates(for: .readWrite)) + #expect(context.allowsSyncReply(to: .stepOne(Data([1])), authenticatedScope: .readWrite)) + #expect(context.allowsLocalDocumentUpdates(for: .readonly) == false) + #expect(context.allowsLocalAwarenessUpdates(for: .unknown) == false) + } } diff --git a/docmostlyTests/Editor/NativeEditorCollaborationStatusPresentationTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationStatusPresentationTests.swift index b62dd32..b37f22c 100644 --- a/docmostlyTests/Editor/NativeEditorCollaborationStatusPresentationTests.swift +++ b/docmostlyTests/Editor/NativeEditorCollaborationStatusPresentationTests.swift @@ -8,7 +8,6 @@ struct NativeEditorCollabStatusTests { let presentation = NativeEditorCollabStatusPresentation( realtimeStatus: .authenticationFailed("Invalid collab token"), canEdit: false, - activeCollaborators: [], pendingRemoteUpdate: nil ) @@ -19,34 +18,39 @@ struct NativeEditorCollabStatusTests { let presentation = NativeEditorCollabStatusPresentation( realtimeStatus: .authenticationFailed("Invalid collab token"), canEdit: false, - activeCollaborators: [], pendingRemoteUpdate: nil ) #expect(presentation.imageName == "person.crop.circle.badge.exclamationmark") } - @Test func reconnectingStatusUsesExplicitCopy() { + @Test func reconnectingStatusStaysHidden() { let presentation = NativeEditorCollabStatusPresentation( realtimeStatus: .connecting, canEdit: true, - activeCollaborators: [], pendingRemoteUpdate: nil ) - #expect(presentation.title == "Reconnecting") + #expect(presentation.isVisible == false) } - @Test func failureStatusTakesPriorityOverPresenceCopy() { + @Test func failureStatusUsesExplicitCopy() { let presentation = NativeEditorCollabStatusPresentation( realtimeStatus: .failed("Native CRDT runtime is unavailable."), canEdit: false, - activeCollaborators: [ - NativeEditorCollaborator(id: "user-2", name: "Alice", colorName: "#2563EB") - ], pendingRemoteUpdate: nil ) #expect(presentation.title == "Sync failed") } + + @Test func connectedEditableStatusStaysHidden() { + let presentation = NativeEditorCollabStatusPresentation( + realtimeStatus: .connected, + canEdit: true, + pendingRemoteUpdate: nil + ) + + #expect(presentation.isVisible == false) + } } diff --git a/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift index 5e28fd9..7889b3a 100644 --- a/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift +++ b/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftData import Testing @testable import docmostly @@ -23,7 +24,7 @@ struct NativeEditorCollaborationSyncStatusTests { #expect(viewModel.isDirty == false) } - @Test func readOnlyCollaborationScopeDiscardsPendingNativeEdits() { + @Test func readOnlyCollaborationScopeFreezesPendingNativeEditsAndHistory() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("Saved"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Saved title") viewModel.document = NativeEditorDocument(blocks: [block]) @@ -31,17 +32,45 @@ struct NativeEditorCollaborationSyncStatusTests { viewModel.resetEditingHistory() viewModel.focus(blockID: block.id) + viewModel.title = "Local title" + viewModel.handleTitleChanged() viewModel.document.blocks[0].text = AttributedString("Local draft") viewModel.handleDocumentChanged() #expect(viewModel.isDirty == true) + let undoHistory = viewModel.undoStack + let redoHistory = viewModel.redoStack viewModel.applyCollaborationAuthenticationScope(.readonly) #expect(viewModel.canEdit == false) #expect(viewModel.canSave == false) - #expect(String(viewModel.document.blocks[0].text.characters) == "Saved") - #expect(viewModel.isDirty == false) + #expect(viewModel.title == "Local title") + #expect(String(viewModel.document.blocks[0].text.characters) == "Local draft") + #expect(viewModel.isDirty) #expect(viewModel.activeBlockID == nil) + #expect(viewModel.undoStack == undoHistory) + #expect(viewModel.redoStack == redoHistory) + + viewModel.title = "Late title binding" + viewModel.handleTitleChanged() + viewModel.document.blocks[0].text = AttributedString("Late document binding") + viewModel.handleDocumentChanged() + + #expect(viewModel.title == "Local title") + #expect(String(viewModel.document.blocks[0].text.characters) == "Local draft") + #expect(viewModel.undoStack == undoHistory) + #expect(viewModel.redoStack == redoHistory) + + let revisionBeforeRestoringAccess = viewModel.localEditRevision + viewModel.applyCollaborationAuthenticationScope(.readWrite) + + #expect(viewModel.canEdit) + #expect(viewModel.isDirty) + #expect(viewModel.title == "Local title") + #expect(String(viewModel.document.blocks[0].text.characters) == "Local draft") + #expect(viewModel.localEditRevision == revisionBeforeRestoringAccess + 1) + #expect(viewModel.undoStack == undoHistory) + #expect(viewModel.redoStack == redoHistory) } @Test func readWriteCollaborationScopeDoesNotOverrideRestrictedPagePermissions() { @@ -60,6 +89,8 @@ struct NativeEditorCollaborationSyncStatusTests { viewModel.lastSavedDocument = viewModel.document viewModel.resetEditingHistory() viewModel.focus(blockID: block.id) + viewModel.title = "Local title" + viewModel.handleTitleChanged() viewModel.document.blocks[0].text = AttributedString("Local draft") viewModel.handleDocumentChanged() @@ -67,11 +98,88 @@ struct NativeEditorCollaborationSyncStatusTests { #expect(viewModel.canEdit == false) #expect(viewModel.canSave == false) - #expect(String(viewModel.document.blocks[0].text.characters) == "Saved") - #expect(viewModel.isDirty == false) + #expect(viewModel.title == "Local title") + #expect(String(viewModel.document.blocks[0].text.characters) == "Local draft") + #expect(viewModel.isDirty) #expect(viewModel.realtimeStatus == .authenticationFailed("Invalid collab token")) } + @Test func unavailableCollaborationRuntimeRetainsDirtyNativeDraft() { + let viewModel = dirtyViewModel() + + viewModel.markCollaborationUnavailable("Native CRDT runtime is unavailable.") + + #expect(viewModel.canEdit == false) + #expect(viewModel.title == "Local title") + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) + #expect(viewModel.isDirty) + #expect(viewModel.hasOutgoingChangesRequiringPersistence) + #expect(viewModel.realtimeStatus == .failed("Native CRDT runtime is unavailable.")) + } + + @Test func revokedPagePermissionRetainsDirtyNativeDraft() { + let viewModel = dirtyViewModel() + + viewModel.applyPagePermissions(DocmostPagePermissions(canEdit: false, hasRestriction: true)) + + #expect(viewModel.canEdit == false) + #expect(viewModel.title == "Local title") + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) + #expect(viewModel.isDirty) + #expect(viewModel.hasOutgoingChangesRequiringPersistence) + } + + @Test func frozenDraftCanBeMadeDurableWithoutAReadOnlyServerWrite() async throws { + let scope = CacheScope(serverBaseURL: "https://docs.example.com", userID: "user-1") + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let context = ModelContext(container) + let cacheRepository = CacheRepository(context: context) + try cacheRepository.saveEditablePage( + DocmostEditablePage( + id: "page-1", + slugId: "page-1", + title: "Saved title", + content: NativeEditorDocument( + blocks: [NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Saved"), + alignment: .left + )] + ).proseMirrorDocument, + icon: nil, + spaceId: "space-1", + updatedAt: Date(timeIntervalSince1970: 10), + permissions: nil, + lastUpdatedBy: nil + ), + scope: scope + ) + let appState = AppState() + appState.configure(modelContext: context) + appState.configurePreviewCacheScope(scope) + let viewModel = dirtyViewModel() + let savedBaseline = viewModel.lastSavedDocument.proseMirrorDocument + viewModel.applyCollaborationAuthenticationScope(.readonly) + + let didPersist = await viewModel.persistRetainedReadOnlyDraft(appState: appState) + + #expect(didPersist) + #expect(viewModel.canEdit == false) + #expect(viewModel.isDirty) + #expect(viewModel.hasDurablyPersistedLocalCRDTDraft) + #expect(viewModel.hasOutgoingChangesRequiringPersistence == false) + let offlineQueue = try #require(appState.offlineQueue) + #expect(try offlineQueue.pending(scope: scope).map(\.payload) == [ + .updatePage( + pageId: "page-1", + title: "Local title", + document: viewModel.document.proseMirrorDocument, + baseTitle: "Saved title", + baseDocument: savedBaseline + ) + ]) + } + @Test func unsyncedCollaborationStatusClearsTransientPresenceAndCursors() { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") let recentEditor = NativeEditorCollaborator( @@ -222,9 +330,22 @@ struct NativeEditorCollaborationSyncStatusTests { #expect(viewModel.canEdit == false) #expect(viewModel.canSave == false) #expect(viewModel.activeBlockID == nil) - #expect(viewModel.isDirty == false) + #expect(viewModel.isDirty) #expect(viewModel.errorMessage == "This page was deleted in Docmost.") - #expect(String(viewModel.document.blocks[0].text.characters) == "Saved") + #expect(String(viewModel.document.blocks[0].text.characters) == "Local draft") + } + + private func dirtyViewModel() -> NativeRichEditorViewModel { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("Saved"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Saved title") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.title = "Local title" + viewModel.handleTitleChanged() + viewModel.document.blocks[0].text = AttributedString("Local draft") + viewModel.handleDocumentChanged() + return viewModel } } diff --git a/docmostlyTests/Editor/NativeEditorCollaborationTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationTests.swift index 72d5784..9cc117b 100644 --- a/docmostlyTests/Editor/NativeEditorCollaborationTests.swift +++ b/docmostlyTests/Editor/NativeEditorCollaborationTests.swift @@ -134,7 +134,8 @@ struct NativeEditorCollaborationTests { #expect(viewModel.remoteCursors == [ NativeEditorRemoteCursor( - id: "user-2", + id: "client-11", + collaboratorID: "user-2", name: "Alice", colorName: "#2563EB", cursor: aliceCursor @@ -142,6 +143,39 @@ struct NativeEditorCollaborationTests { ]) } + @Test func unchangedAwarenessDoesNotRewriteCollaboratorsOrResolvedCursors() { + let viewModel = configuredViewModel() + let aliceCursor = awarenessCursor(client: 2, anchorClock: 3, headClock: 5) + let states = [ + NativeEditorAwarenessState( + clientID: 11, + clock: 1, + payload: NativeEditorAwarenessPayload( + user: NativeEditorAwarenessUser(id: "user-2", name: "Alice", color: "#2563EB"), + cursor: aliceCursor + ) + ) + ] + let resolvedCursor = NativeEditorResolvedRemoteCursor( + id: "user-2", + name: "Alice", + colorName: "#2563EB", + anchor: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 1), + head: NativeEditorRemoteTextPosition(blockIndex: 0, characterOffset: 5) + ) + + let firstUpdateChangedCursors = viewModel.applyAwarenessStates(states, localClientID: 10) + viewModel.resolvedRemoteCursors = [resolvedCursor] + let repeatedUpdateChangedCursors = viewModel.applyAwarenessStates(states, localClientID: 10) + + #expect(firstUpdateChangedCursors) + #expect(repeatedUpdateChangedCursors == false) + #expect(viewModel.activeCollaborators == [ + NativeEditorCollaborator(id: "user-2", name: "Alice", colorName: "#2563EB") + ]) + #expect(viewModel.resolvedRemoteCursors == [resolvedCursor]) + } + @Test func ignoresAwarenessCursorsOutsideDocmostDefaultFragment() { let state = NativeEditorAwarenessState( clientID: 11, diff --git a/docmostlyTests/Editor/NativeEditorEndpointTests.swift b/docmostlyTests/Editor/NativeEditorEndpointTests.swift index c9b4f9f..bc95c55 100644 --- a/docmostlyTests/Editor/NativeEditorEndpointTests.swift +++ b/docmostlyTests/Editor/NativeEditorEndpointTests.swift @@ -32,4 +32,19 @@ struct NativeEditorEndpointTests { #expect(object["format"] as? String == "json") #expect(content["type"] as? String == "doc") } + + @Test func collaborativeTitleUpdateOmitsReplaceContent() throws { + let baseURL = try #require(URL(string: "https://docs.example.com")) + let request = try Endpoint.updatePage(pageId: "page-1", title: "Updated title") + .urlRequest(baseURL: baseURL) + + let body = try #require(request.httpBody) + let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + + #expect(object["pageId"] as? String == "page-1") + #expect(object["title"] as? String == "Updated title") + #expect(object["content"] == nil) + #expect(object["operation"] == nil) + #expect(object["format"] == nil) + } } diff --git a/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift index ce5c5a9..19820ad 100644 --- a/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineMarkdownExportTests.swift @@ -5,7 +5,7 @@ import Testing @MainActor struct NativeEditorInlineMarkdownExportTests { - @Test func documentMarkdownConversionPreservesCommonInlineMarks() throws { + @Test func documentMarkdownConversionPreservesCommonInlineMarks() { var text = AttributedString("Use ") var bold = AttributedString("bold") bold.inlinePresentationIntent = .stronglyEmphasized @@ -14,7 +14,7 @@ struct NativeEditorInlineMarkdownExportTests { var code = AttributedString("code") code.inlinePresentationIntent = .code var link = AttributedString("link") - link.link = try #require(URL(string: "https://example.com/spec")) + link.link = URL(string: "https://example.com/spec") text += bold text += AttributedString(", ") diff --git a/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift b/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift index 09c1fe0..4acc19e 100644 --- a/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift +++ b/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift @@ -69,6 +69,21 @@ struct NativeEditorJSCRDTEngineTests { #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 20)) } + @Test func capturesRemoteSnapshotForAtomicNativeProjection() async throws { + let engine = try NativeEditorJSCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: document(text: "Seed"), + runtimeSource: Self.runtimeSource + ) + + let snapshot = try #require(try await engine.applyRemoteUpdateCapturingSnapshot(Data([8, 7]))) + + #expect(snapshot.title == "Remote") + #expect(snapshot.document.blocks.map { String($0.text.characters) } == ["Merged"]) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 20)) + } + @Test func flushesPendingLocalChangesThroughRuntime() async throws { let engine = try NativeEditorJSCRDTDocumentEngine( pageID: "page-1", diff --git a/docmostlyTests/Editor/NativeEditorLocalAwarenessUpdateTests.swift b/docmostlyTests/Editor/NativeEditorLocalAwarenessUpdateTests.swift index c80d1ac..8e1d65a 100644 --- a/docmostlyTests/Editor/NativeEditorLocalAwarenessUpdateTests.swift +++ b/docmostlyTests/Editor/NativeEditorLocalAwarenessUpdateTests.swift @@ -30,7 +30,7 @@ struct NativeEditorLocalAwarenessUpdateTests { viewModel.document = NativeEditorDocument(blocks: [ NativeEditorBlock(id: blockID, kind: .paragraph, text: AttributedString("Body"), alignment: .left) ]) - let session = viewModel.collaborationSession() + let session = viewModel.collaborationSession(participation: .interactive) var iterator = session.localAwarenessUpdates?.makeAsyncIterator() viewModel.focus(blockID: blockID) @@ -38,6 +38,20 @@ struct NativeEditorLocalAwarenessUpdateTests { #expect(await iterator?.next() != nil) } + @Test func receiveOnlyCollaborationSessionSuppressesLocalAwarenessUpdates() { + let blockID = UUID() + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock(id: blockID, kind: .paragraph, text: AttributedString("Body"), alignment: .left) + ]) + + let session = viewModel.collaborationSession(participation: .receiveOnly) + + #expect(session.participation == .receiveOnly) + #expect(session.localAwarenessCursor == nil) + #expect(session.localAwarenessUpdates == nil) + } + @Test func selectionChangesPublishLocalAwarenessWithoutDirtyingDocument() async { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") var iterator = viewModel.localAwarenessUpdates().makeAsyncIterator() diff --git a/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift b/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift new file mode 100644 index 0000000..9a19b3a --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift @@ -0,0 +1,301 @@ +import Foundation +import SwiftUI +import SwiftData +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorSaveRaceTests { + @Test func localEditActionsAdvanceAutosaveRevision() { + let viewModel = makeViewModel(engine: SuspendingSaveCRDTDocumentEngine()) + + viewModel.title = "Changed title" + viewModel.handleTitleChanged() + #expect(viewModel.localEditRevision == 1) + + viewModel.document.blocks[0].text = AttributedString("Changed body") + viewModel.handleDocumentChanged() + #expect(viewModel.localEditRevision == 2) + + viewModel.appendBlock() + #expect(viewModel.localEditRevision == 3) + + viewModel.undo() + #expect(viewModel.localEditRevision == 4) + + viewModel.redo() + #expect(viewModel.localEditRevision == 5) + } + + @Test func remoteDocumentSnapshotDoesNotAdvanceAutosaveRevision() { + let viewModel = makeViewModel(engine: SuspendingSaveCRDTDocumentEngine()) + + viewModel.applyCRDTDocumentSnapshot(NativeEditorCRDTDocumentSnapshot( + title: "Remote title", + document: document(text: "Remote body"), + updatedAt: Date(timeIntervalSince1970: 50) + )) + + #expect(viewModel.localEditRevision == 0) + #expect(viewModel.isDirty == false) + } + + @Test func saveCommitsOnlyCapturedSnapshotWhenNewerLocalEditsArrive() async { + let engine = SuspendingSaveCRDTDocumentEngine() + let viewModel = makeViewModel(engine: engine) + + viewModel.document.blocks[0].text = AttributedString("First local body") + viewModel.handleDocumentChanged() + let firstSnapshot = viewModel.document + + let saveTask = Task { + await viewModel.save(appState: AppState()) + } + await engine.waitUntilFlushSuspends() + + viewModel.title = "Second local title" + viewModel.handleTitleChanged() + viewModel.document.blocks[0].text = AttributedString("Second local body") + viewModel.handleDocumentChanged() + let secondSnapshot = viewModel.document + + #expect(engine.events == [ + .integrate("First local body"), + .flush("First local body") + ]) + + engine.resumeFlush(with: NativeEditorCRDTSaveResult(title: "Draft")) + let didSave = await saveTask.value + try? await viewModel.waitForPendingCRDTLocalChange() + + #expect(didSave) + #expect(engine.flushRequests.count == 1) + #expect(engine.flushRequests.first?.title == "Draft") + #expect(engine.flushRequests.first?.document == firstSnapshot) + #expect(viewModel.title == "Second local title") + #expect(viewModel.document == secondSnapshot) + #expect(viewModel.lastSavedTitle == "Draft") + #expect(viewModel.lastSavedDocument == firstSnapshot) + #expect(viewModel.isDirty) + #expect(viewModel.isSaving == false) + #expect(engine.events == [ + .integrate("First local body"), + .flush("First local body"), + .integrate("First local body"), + .integrate("Second local body") + ]) + } + + @Test func saveCompletionDoesNotOverwriteNewerRemoteBaseline() async { + let engine = SuspendingSaveCRDTDocumentEngine() + let viewModel = makeViewModel(engine: engine) + + viewModel.document.blocks[0].text = AttributedString("Local body") + viewModel.handleDocumentChanged() + + let saveTask = Task { + await viewModel.save(appState: AppState()) + } + await engine.waitUntilFlushSuspends() + + let remoteDocument = document(text: "Remote body") + viewModel.title = "Remote title" + viewModel.document = remoteDocument + viewModel.lastSavedTitle = "Remote title" + viewModel.lastSavedDocument = remoteDocument + viewModel.markRemoteBaseline(updatedAt: Date(timeIntervalSince1970: 50)) + viewModel.resetEditingHistory() + viewModel.recalculateDirty() + + engine.resumeFlush(with: NativeEditorCRDTSaveResult( + title: "Draft", + updatedAt: Date(timeIntervalSince1970: 20) + )) + let didSave = await saveTask.value + + #expect(didSave) + #expect(viewModel.title == "Remote title") + #expect(viewModel.document == remoteDocument) + #expect(viewModel.lastSavedTitle == "Remote title") + #expect(viewModel.lastSavedDocument == remoteDocument) + #expect(viewModel.lastRemoteUpdatedAt == Date(timeIntervalSince1970: 50)) + #expect(viewModel.isDirty == false) + } + + @Test func divergentRemoteCRDTSnapshotDuringSaveCannotReplaceLocalDraftOrBaseline() async { + let engine = SuspendingSaveCRDTDocumentEngine() + let viewModel = makeViewModel(engine: engine) + + viewModel.document.blocks[0].text = AttributedString("Local body") + viewModel.handleDocumentChanged() + + let saveTask = Task { + await viewModel.save(appState: AppState()) + } + await engine.waitUntilFlushSuspends() + + let remoteDocument = document(text: "Remote merged body") + viewModel.applyCRDTDocumentSnapshot(NativeEditorCRDTDocumentSnapshot( + title: "Remote title", + document: remoteDocument, + updatedAt: Date(timeIntervalSince1970: 50) + )) + + engine.resumeFlush(with: NativeEditorCRDTSaveResult( + title: "Draft", + updatedAt: Date(timeIntervalSince1970: 20) + )) + let didSave = await saveTask.value + + #expect(didSave) + #expect(viewModel.title == "Draft") + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local body"]) + #expect(viewModel.lastSavedTitle == "Draft") + #expect(viewModel.lastSavedDocument.blocks.map { String($0.text.characters) } == ["Original body"]) + #expect(viewModel.pendingRemoteCRDTSnapshot?.document == remoteDocument) + #expect(viewModel.pendingRemoteUpdate?.title == "Remote title") + #expect(viewModel.realtimeStatus == .conflict) + #expect(viewModel.isDirty) + } + + @Test func deferredConflictAutosaveQueuesLocalDraftWithoutFlushingYjsOrLoopingHandoff() async throws { + let engine = SuspendingSaveCRDTDocumentEngine() + let viewModel = makeViewModel(engine: engine) + let originalBaseline = viewModel.lastSavedDocument.proseMirrorDocument + viewModel.document.blocks[0].text = AttributedString("Local durable draft") + viewModel.handleDocumentChanged() + try await viewModel.waitForPendingCRDTLocalChange() + let eventsBeforeConflict = engine.events + viewModel.applyCRDTDocumentSnapshot(NativeEditorCRDTDocumentSnapshot( + title: "Remote title", + document: document(text: "Remote divergent body"), + updatedAt: Date(timeIntervalSince1970: 50) + )) + viewModel.document.blocks[0].text = AttributedString("Local draft after conflict") + viewModel.handleDocumentChanged() + try await viewModel.waitForPendingCRDTLocalChange() + + let scope = CacheScope(serverBaseURL: "https://docs.example.com", userID: "user-1") + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let context = ModelContext(container) + let cacheRepository = CacheRepository(context: context) + try cacheRepository.saveEditablePage( + DocmostEditablePage( + id: "page-1", + slugId: "page-1", + title: "Draft", + content: document(text: "Original body").proseMirrorDocument, + icon: nil, + spaceId: "space-1", + updatedAt: Date(timeIntervalSince1970: 10), + permissions: nil, + lastUpdatedBy: nil + ), + scope: scope + ) + let appState = AppState() + appState.configure(modelContext: context) + appState.configurePreviewCacheScope(scope) + + let didSave = await viewModel.save(appState: appState) + + #expect(didSave) + #expect(engine.flushRequests.isEmpty) + #expect(engine.events == eventsBeforeConflict) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft after conflict"]) + #expect(viewModel.pendingRemoteCRDTSnapshot != nil) + #expect(viewModel.realtimeStatus == .conflict) + #expect(viewModel.isDirty) + #expect(viewModel.hasDurablyPersistedLocalCRDTDraft) + #expect(viewModel.hasOutgoingChangesRequiringPersistence == false) + let offlineQueue = try #require(appState.offlineQueue) + #expect(try offlineQueue.pending(scope: scope).map(\.payload) == [ + .updatePage( + pageId: "page-1", + title: "Draft", + document: viewModel.document.proseMirrorDocument, + baseTitle: "Draft", + baseDocument: originalBaseline + ) + ]) + } + + private func makeViewModel( + engine: SuspendingSaveCRDTDocumentEngine + ) -> NativeRichEditorViewModel { + let initialDocument = document(text: "Original body") + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Draft", + crdtDocumentEngine: engine + ) + viewModel.document = initialDocument + viewModel.lastSavedDocument = initialDocument + viewModel.isCRDTEngineReadyForLocalChanges = true + viewModel.resetEditingHistory() + return viewModel + } + + private func document(text: String) -> NativeEditorDocument { + NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .paragraph, text: AttributedString(text), alignment: .left) + ]) + } +} + +@MainActor +private final class SuspendingSaveCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { + enum Event: Equatable { + case integrate(String) + case flush(String) + } + + struct FlushRequest { + let title: String + let document: NativeEditorDocument + } + + private(set) var flushRequests: [FlushRequest] = [] + private(set) var events: [Event] = [] + private var flushContinuation: CheckedContinuation? + + func encodeStateVector() async throws -> Data { + Data() + } + + func encodeStateAsUpdate(for stateVector: Data) async throws -> Data { + Data() + } + + func applyRemoteUpdate(_ update: Data) async throws { } + + func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { + events.append(.integrate(Self.bodyText(in: change.after.document))) + } + + func flushPendingLocalChanges( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTSaveResult { + events.append(.flush(Self.bodyText(in: document))) + flushRequests.append(FlushRequest(title: title, document: document)) + return await withCheckedContinuation { continuation in + flushContinuation = continuation + } + } + + func waitUntilFlushSuspends() async { + for _ in 0..<100 where flushContinuation == nil { + await Task.yield() + } + } + + func resumeFlush(with result: NativeEditorCRDTSaveResult) { + flushContinuation?.resume(returning: result) + flushContinuation = nil + } + + private static func bodyText(in document: NativeEditorDocument) -> String { + document.blocks.first.map { String($0.text.characters) } ?? "" + } +} diff --git a/docmostlyTests/Editor/NativeEditorSimpleContentSafetyTests.swift b/docmostlyTests/Editor/NativeEditorSimpleContentSafetyTests.swift new file mode 100644 index 0000000..b9306e6 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorSimpleContentSafetyTests.swift @@ -0,0 +1,125 @@ +import Testing +@testable import docmostly + +struct NativeEditorSimpleContentSafetyTests { + @Test func acceptsEmptyAndSinglePlainParagraphContent() { + let plainNodes = [ + ProseMirrorNode( + type: "paragraph", + content: [ + ProseMirrorNode(type: "text", text: "First line"), + ProseMirrorNode(type: "hardBreak"), + ProseMirrorNode(type: "text", text: "Second line") + ] + ) + ] + + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: []) == "") + #expect( + NativeEditorSimpleContentSafety.plainBlockText(in: plainNodes) == + "First line\nSecond line" + ) + } + + @Test func acceptsDocmostParagraphIdentityAndTextLayoutAttributes() { + let reloadedParagraph = ProseMirrorNode( + type: "paragraph", + attrs: [ + "id": .string("paragraph-1"), + "indent": .int(1), + "textAlign": .string("center") + ], + content: [ProseMirrorNode(type: "text", text: "Still editable")] + ) + + #expect( + NativeEditorSimpleContentSafety.plainBlockText(in: [reloadedParagraph]) == + "Still editable" + ) + } + + @Test func acceptsEmptyAndPlainInlineSummaryContent() { + let plainNodes = [ + ProseMirrorNode(type: "text", text: "Summary"), + ProseMirrorNode(type: "hardBreak"), + ProseMirrorNode(type: "text", text: "continued") + ] + + #expect(NativeEditorSimpleContentSafety.plainInlineText(in: []) == "") + #expect( + NativeEditorSimpleContentSafety.plainInlineText(in: plainNodes) == + "Summary\ncontinued" + ) + } + + @Test func rejectsMarksSemanticAtomsAndUnknownNodes() { + let markedParagraph = paragraph( + containing: ProseMirrorNode( + type: "text", + marks: [ProseMirrorMark(type: "bold")], + text: "Important" + ) + ) + let mentionParagraph = paragraph( + containing: ProseMirrorNode( + type: "mention", + attrs: ["id": .string("user-1")] + ) + ) + let unknownParagraph = paragraph(containing: ProseMirrorNode(type: "futureInlineNode")) + + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [markedParagraph]) == nil) + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [mentionParagraph]) == nil) + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [unknownParagraph]) == nil) + } + + @Test func rejectsNestedBlocksMultipleParagraphsAndUnknownMetadata() { + let nestedList = ProseMirrorNode( + type: "bulletList", + content: [ + ProseMirrorNode( + type: "listItem", + content: [paragraph(containing: ProseMirrorNode(type: "text", text: "Item"))] + ) + ] + ) + let attributedParagraph = ProseMirrorNode( + type: "paragraph", + attrs: ["futureAttribute": .string("preserve-me")], + content: [ProseMirrorNode(type: "text", text: "Future content")] + ) + let firstParagraph = paragraph(containing: ProseMirrorNode(type: "text", text: "One")) + let secondParagraph = paragraph(containing: ProseMirrorNode(type: "text", text: "Two")) + + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [nestedList]) == nil) + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [attributedParagraph]) == nil) + #expect( + NativeEditorSimpleContentSafety.plainBlockText( + in: [firstParagraph, secondParagraph] + ) == nil + ) + } + + @Test func rejectsMalformedHardBreaksAndNestedInlineContent() { + let attributedBreak = paragraph( + containing: ProseMirrorNode( + type: "hardBreak", + attrs: ["future": .bool(true)] + ) + ) + let nestedText = paragraph( + containing: ProseMirrorNode( + type: "text", + content: [ProseMirrorNode(type: "text", text: "Nested")], + text: "Outer" + ) + ) + + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [attributedBreak]) == nil) + #expect(NativeEditorSimpleContentSafety.plainBlockText(in: [nestedText]) == nil) + } + + private func paragraph(containing node: ProseMirrorNode) -> ProseMirrorNode { + ProseMirrorNode(type: "paragraph", content: [node]) + } +} diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 7d728d3..0e680ba 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -10,7 +10,7 @@ struct NativeEditorSlashCommandTests { #expect(slashCommandTitles(for: "embed").contains("Embed")) } - @Test func slashCommandInventoryIncludesBaseColumnsAndProviderEmbeds() { + @Test func commandEnumRetainsBaseCompatibilityAlongsideColumnsAndProviderEmbeds() { let titles = NativeEditorCommand.allCases.map(\.title) #expect(titles.contains("Base (Inline)")) @@ -31,7 +31,19 @@ struct NativeEditorSlashCommandTests { #expect(titles.contains("Google Sheets")) } - @Test func slashCommandInventoryUsesDocmostWebCommandTitles() { + @Test func userVisibleCommandInventoriesExcludeEditionGatedBaseCommands() { + let slashCommands = NativeEditorCommand.slashMenuCases + let richCommands = NativeEditorCommand.richCases + + #expect(slashCommands.contains(.baseInline) == false) + #expect(slashCommands.contains(.kanban) == false) + #expect(richCommands.contains(.baseInline) == false) + #expect(richCommands.contains(.kanban) == false) + #expect(slashCommandTitles(for: "base").contains("Base (Inline)") == false) + #expect(slashCommandTitles(for: "kanban").contains("Kanban") == false) + } + + @Test func commandEnumUsesDocmostWebCommandTitles() { let titles = NativeEditorCommand.allCases.map(\.title) let expectedTitles = [ "Text", @@ -108,8 +120,6 @@ struct NativeEditorSlashCommandTests { "Embed PDF", "File attachment", "Table", - "Base (Inline)", - "Kanban", "Toggle block", "Callout", "Math inline", @@ -152,7 +162,7 @@ struct NativeEditorSlashCommandTests { SlashCommandFilterExpectation(query: "latex", title: "Math inline"), SlashCommandFilterExpectation(query: "lozenge", title: "Status"), SlashCommandFilterExpectation(query: "reaction", title: "Emoji"), - SlashCommandFilterExpectation(query: "table", title: "Base (Inline)") + SlashCommandFilterExpectation(query: "table", title: "Table") ] for expectation in expectations { @@ -374,7 +384,7 @@ struct NativeEditorSlashCommandTests { } } - @Test func applyingBaseSlashCommandsCreatesRawBaseNodes() { + @Test func applyingLegacyBaseCommandsCreatesRawBaseNodes() { let expectations = [ BaseCommandExpectation(command: .baseInline, previewText: "Base"), BaseCommandExpectation(command: .kanban, previewText: "Kanban") diff --git a/docmostlyTests/Editor/NativeEditorTextMutationTests.swift b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift new file mode 100644 index 0000000..4577715 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift @@ -0,0 +1,366 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +struct NativeEditorTextMutationTests { + @Test func infersUnicodeSafeReplacementInCharacterOffsets() throws { + let delta = try #require( + NativeEditorTextDelta(previousText: "A👩‍🚀B", updatedText: "A👩‍🚀 bright B") + ) + + #expect(delta.replacedCharacterRange == 2..<2) + #expect(delta.replacement == " bright ") + #expect(delta.insertionCharacterOffset == 10) + } + + @Test func explicitDeltaPreservesSemanticRunsOutsideEdit() throws { + var text = AttributedString("Mention plain status math link highlighted") + let mentionRange = try attributedRange(of: "Mention", in: text) + let statusRange = try attributedRange(of: "status", in: text) + let mathRange = try attributedRange(of: "math", in: text) + let linkRange = try attributedRange(of: "link", in: text) + let highlightRange = try attributedRange(of: "highlighted", in: text) + + let mention = NativeEditorMention( + identifier: "mention-1", + label: "Mention", + entityType: "user", + entityID: "user-1", + slugID: nil, + creatorID: nil, + anchorID: nil + ) + text[mentionRange][NativeEditorMentionAttribute.self] = mention + text[statusRange][NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "status", color: "green") + text[mathRange][NativeEditorMathInlineAttribute.self] = NativeEditorMathInline(text: "x^2") + text[linkRange][NativeEditorLinkAttribute.self] = NativeEditorLink(href: "/s/test/p/page", isInternal: true) + text[linkRange].link = URL(string: "https://example.com/s/test/p/page") + text[highlightRange][NativeEditorHighlightColorNameAttribute.self] = "yellow" + text[highlightRange].backgroundColor = .yellow + text[highlightRange].setNativeEditorInlineComments([ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + ]) + + let plainText = String(text.characters) + let editIndex = try #require(plainText.range(of: "plain")?.lowerBound) + let characterOffset = plainText.distance(from: plainText.startIndex, to: editIndex) + let updated = NativeEditorTextDelta( + replacedCharacterRange: characterOffset..<(characterOffset + 5), + replacement: "ordinary" + ).applying(to: text) + + #expect(String(updated.characters) == "Mention ordinary status math link highlighted") + #expect(try attributes(for: "Mention", in: updated)[NativeEditorMentionAttribute.self] == mention) + #expect(try attributes(for: "status", in: updated)[NativeEditorStatusAttribute.self]?.color == "green") + #expect(try attributes(for: "math", in: updated)[NativeEditorMathInlineAttribute.self]?.text == "x^2") + #expect(try attributes(for: "link", in: updated)[NativeEditorLinkAttribute.self]?.isInternal == true) + #expect( + try attributes(for: "highlighted", in: updated)[NativeEditorHighlightColorNameAttribute.self] == "yellow" + ) + #expect( + try attributes(for: "highlighted", in: updated) + .nativeEditorInlineComments + .first? + .commentID == "comment-1" + ) + } + + @Test func insertedTextInheritsMarksWithoutExtendingAtomicInlineAttributes() throws { + var text = AttributedString("Bold Mention") + let boldRange = try attributedRange(of: "Bold", in: text) + text[boldRange].inlinePresentationIntent = .stronglyEmphasized + + let mentionRange = try attributedRange(of: "Mention", in: text) + text[mentionRange][NativeEditorMentionAttribute.self] = NativeEditorMention( + identifier: "mention-1", + label: "Mention", + entityType: "user", + entityID: "user-1", + slugID: nil, + creatorID: nil, + anchorID: nil + ) + + let boldInsertion = NativeEditorTextDelta(replacedCharacterRange: 2..<2, replacement: "!").applying(to: text) + #expect(try attributes(for: "!", in: boldInsertion).inlinePresentationIntent == .stronglyEmphasized) + + let mentionInsertion = NativeEditorTextDelta( + replacedCharacterRange: text.characters.count.. characterRange.count) + #expect(NativeEditorCharacterRange.characterRange(for: nsRange, in: value) == characterRange) + } + + private func attributedRange( + of substring: String, + in text: AttributedString + ) throws -> Range { + try #require(text.range(of: substring)) + } + + private func attributes( + for substring: String, + in text: AttributedString + ) throws -> AttributeContainer { + let range = try attributedRange(of: substring, in: text) + return try #require(text[range].runs.first?.attributes) + } + + private func insertionOffset(in block: NativeEditorBlock) throws -> Int { + switch block.selection.indices(in: block.text) { + case .insertionPoint(let index): + return block.text.characters.distance(from: block.text.startIndex, to: index) + case .ranges: + Issue.record("Expected an insertion-point selection") + return -1 + } + } + + private func textWithAtomicInlineRuns() throws -> AttributedString { + var text = AttributedString("Mention status math") + let mentionRange = try attributedRange(of: "Mention", in: text) + let statusRange = try attributedRange(of: "status", in: text) + let mathRange = try attributedRange(of: "math", in: text) + + text[mentionRange][NativeEditorMentionAttribute.self] = NativeEditorMention( + identifier: "mention-1", + label: "Mention", + entityType: "user", + entityID: "user-1", + slugID: nil, + creatorID: nil, + anchorID: nil + ) + text[statusRange][NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "status", color: "green") + text[mathRange][NativeEditorMathInlineAttribute.self] = NativeEditorMathInline(text: "x^2") + return text + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift new file mode 100644 index 0000000..80eaaa1 --- /dev/null +++ b/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift @@ -0,0 +1,555 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeRichEditorBlockMechanicsTests { + @Test func splitReplacesSelectionAndPreservesAttributedRuns() throws { + var prefix = AttributedString("Alpha ") + prefix.inlinePresentationIntent = .stronglyEmphasized + var removed = AttributedString("remove ") + removed.foregroundColor = .red + var suffix = AttributedString("Omega") + suffix[NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "Omega", color: "green") + let block = NativeEditorBlock( + kind: .paragraph, + text: prefix + removed + suffix, + alignment: .center, + indentLevel: 2 + ) + let viewModel = configuredViewModel(blocks: [block]) + + let continuationID = try #require(viewModel.splitBlock(block.id, replacing: 6..<13)) + + #expect(viewModel.document.blocks.map(plainText) == ["Alpha ", "Omega"]) + #expect(viewModel.document.blocks[0].text.runs.first?.inlinePresentationIntent == .stronglyEmphasized) + #expect( + viewModel.document.blocks[1].text.runs.first?[NativeEditorStatusAttribute.self] == + NativeEditorStatusBadge(text: "Omega", color: "green") + ) + #expect(viewModel.document.blocks[1].alignment == .center) + #expect(viewModel.document.blocks[1].indentLevel == 2) + #expect(viewModel.activeBlockID == continuationID) + #expect(try insertionOffset(in: viewModel.document.blocks[1]) == 0) + #expect(viewModel.undoStack.count == 1) + + viewModel.undo() + + #expect(viewModel.document.blocks.count == 1) + #expect(plainText(viewModel.document.blocks[0]) == "Alpha remove Omega") + #expect( + viewModel.document.blocks[0].text.runs.contains { + $0[NativeEditorStatusAttribute.self] == NativeEditorStatusBadge(text: "Omega", color: "green") + } + ) + } + + @Test func splitUsesCharacterOffsetsForExtendedGraphemeClusters() throws { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("A👨‍👩‍👧‍👦B"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + _ = try #require(viewModel.splitBlock(block.id, at: 2)) + + #expect(viewModel.document.blocks.map(plainText) == ["A👨‍👩‍👧‍👦", "B"]) + } + + @Test func headingSplitAtEndStartsAnUnstyledParagraph() throws { + let block = NativeEditorBlock( + kind: .heading(level: 2), + text: AttributedString("Heading"), + alignment: .center, + indentLevel: 3 + ) + let viewModel = configuredViewModel(blocks: [block]) + + _ = try #require(viewModel.splitBlock(block.id, at: 7)) + + #expect(viewModel.document.blocks.map(\.kind) == [.heading(level: 2), .paragraph]) + #expect(viewModel.document.blocks[1].alignment == .left) + #expect(viewModel.document.blocks[1].indentLevel == 0) + } + + @Test func headingSplitInTheMiddleContinuesHeadingStyle() throws { + let block = NativeEditorBlock( + kind: .heading(level: 2), + text: AttributedString("Heading"), + alignment: .right, + indentLevel: 1 + ) + let viewModel = configuredViewModel(blocks: [block]) + + _ = try #require(viewModel.splitBlock(block.id, at: 4)) + + #expect(viewModel.document.blocks.map(plainText) == ["Head", "ing"]) + #expect(viewModel.document.blocks.map(\.kind) == [.heading(level: 2), .heading(level: 2)]) + #expect(viewModel.document.blocks[1].alignment == .right) + #expect(viewModel.document.blocks[1].indentLevel == 1) + } +} + +@MainActor +struct NativeEditorReturnSemanticsTests { + @Test func emptyHeadingReturnKeepsHeadingAndStartsParagraph() throws { + let block = NativeEditorBlock( + kind: .heading(level: 2), + text: AttributedString(""), + alignment: .right, + indentLevel: 1 + ) + let viewModel = configuredViewModel(blocks: [block]) + + let paragraphID = try #require(viewModel.splitBlock(block.id, at: 0)) + + #expect(viewModel.document.blocks.map(\.kind) == [.heading(level: 2), .paragraph]) + #expect(viewModel.document.blocks.map(plainText) == ["", ""]) + #expect(viewModel.document.blocks[0].alignment == .right) + #expect(viewModel.document.blocks[0].indentLevel == 1) + #expect(viewModel.activeBlockID == paragraphID) + } + + @Test func blockquoteEndReturnContinuesQuoteThenEmptyReturnExits() throws { + let block = NativeEditorBlock( + kind: .blockquote, + text: AttributedString("Quoted"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + let emptyQuoteID = try #require(viewModel.splitBlock(block.id, at: 6)) + + #expect(viewModel.document.blocks.map(\.kind) == [.blockquote, .blockquote]) + #expect(viewModel.document.blocks.map(plainText) == ["Quoted", ""]) + #expect(viewModel.activeBlockID == emptyQuoteID) + + let paragraphID = try #require(viewModel.splitBlock(emptyQuoteID, at: 0)) + + #expect(paragraphID == emptyQuoteID) + #expect(viewModel.document.blocks.map(\.kind) == [.blockquote, .paragraph]) + #expect(viewModel.document.blocks.map(plainText) == ["Quoted", ""]) + #expect(viewModel.activeBlockID == emptyQuoteID) + #expect(viewModel.undoStack.count == 2) + } + + @Test func emptyBlockquoteExitPreservesLiftedParagraphAttributes() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [{ + "type": "blockquote", + "attrs": { "tone": "muted" }, + "content": [{ + "type": "paragraph", + "attrs": { "id": "paragraph-1", "custom": "preserved" } + }] + }] + } + """.utf8) + ) + let viewModel = configuredViewModel( + blocks: NativeEditorDocument(proseMirrorDocument: original).blocks + ) + let quoteID = try #require(viewModel.document.blocks.first?.id) + let baseline = viewModel.document.proseMirrorDocument + + let paragraphID = try #require(viewModel.splitBlock(quoteID, at: 0)) + + let paragraph = try #require(viewModel.document.proseMirrorDocument.content.first) + #expect(paragraphID == quoteID) + #expect(viewModel.document.blocks.map(\.kind) == [.paragraph]) + #expect(paragraph.type == "paragraph") + #expect(paragraph.attrs?["custom"]?.stringValue == "preserved") + #expect(paragraph.attrs?["id"]?.stringValue == "paragraph-1") + #expect(paragraph.attrs?["tone"] == nil) + + viewModel.undo() + + #expect(viewModel.document.proseMirrorDocument == baseline) + } + + @Test func emptyRawBlockquoteRefusesToDiscardAdditionalChildren() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [{ + "type": "blockquote", + "content": [ + { "type": "paragraph" }, + { "type": "paragraph", "content": [{ "type": "text", "text": "Preserved" }] } + ] + }] + } + """.utf8) + ) + let viewModel = configuredViewModel( + blocks: NativeEditorDocument(proseMirrorDocument: original).blocks + ) + let quoteID = try #require(viewModel.document.blocks.first?.id) + let baseline = viewModel.document.proseMirrorDocument + + #expect(viewModel.splitBlock(quoteID, at: 0) == nil) + #expect(viewModel.document.proseMirrorDocument == baseline) + #expect(viewModel.undoStack.isEmpty) + } + + @Test func codeBlockReturnInsertsNewlineWithoutLeavingCode() throws { + let block = NativeEditorBlock( + kind: .codeBlock(language: "swift"), + text: AttributedString("let value = 1"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.insertSoftBreak(in: block.id, at: 13)) + + #expect(viewModel.document.blocks.map(\.kind) == [.codeBlock(language: "swift")]) + #expect(viewModel.document.blocks.map(plainText) == ["let value = 1\n"]) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.first?.text == "let value = 1\n") + } + + @Test func emptyCodeBlockReturnInsertsNewlineWithoutLeavingCode() throws { + let block = NativeEditorBlock( + kind: .codeBlock(language: nil), + text: AttributedString(""), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.insertSoftBreak(in: block.id, at: 0)) + + #expect(viewModel.document.blocks.map(\.kind) == [.codeBlock(language: nil)]) + #expect(viewModel.document.blocks.map(plainText) == ["\n"]) + #expect(viewModel.document.blocks.count == 1) + } + + @Test func tripleReturnCodeExitRemovesSentinelBlankLines() throws { + let block = NativeEditorBlock( + kind: .codeBlock(language: "swift"), + text: AttributedString("let value = 1\n\n"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + let paragraphID = try #require(viewModel.splitBlock(block.id, at: 15)) + + #expect(viewModel.document.blocks.map(\.kind) == [ + .codeBlock(language: "swift"), + .paragraph + ]) + #expect(viewModel.document.blocks.map(plainText) == ["let value = 1", ""]) + #expect(viewModel.activeBlockID == paragraphID) + #expect(viewModel.undoStack.count == 1) + + viewModel.undo() + + #expect(viewModel.document.blocks.map(plainText) == ["let value = 1\n\n"]) + } + + @Test func explicitCodeExitAtEndStartsParagraph() throws { + let block = NativeEditorBlock( + kind: .codeBlock(language: "swift"), + text: AttributedString("let value = 1"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + let paragraphID = try #require(viewModel.splitBlock(block.id, at: 13)) + + #expect(viewModel.document.blocks.map(\.kind) == [ + .codeBlock(language: "swift"), + .paragraph + ]) + #expect(viewModel.document.blocks.map(plainText) == ["let value = 1", ""]) + #expect(viewModel.activeBlockID == paragraphID) + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } + + private func plainText(_ block: NativeEditorBlock) -> String { + String(block.text.characters) + } +} + +extension NativeRichEditorBlockMechanicsTests { + @Test func softBreakReplacesSelectionAndIsOneUndoableEdit() throws { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Hello brave world"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [block]) + + #expect(viewModel.insertSoftBreak(in: block.id, replacing: 6..<12)) + + #expect(plainText(viewModel.document.blocks[0]) == "Hello \nworld") + #expect(try insertionOffset(in: viewModel.document.blocks[0]) == 7) + #expect(viewModel.document.proseMirrorDocument.content[0].content?.map(\.type) == [ + "text", + "hardBreak", + "text" + ]) + #expect(viewModel.undoStack.count == 1) + + viewModel.undo() + + #expect(plainText(viewModel.document.blocks[0]) == "Hello brave world") + } + + @Test func continuingTaskItemSplitsTextAndResetsCheckedState() throws { + let block = NativeEditorBlock( + kind: .taskListItem(isChecked: true), + text: AttributedString("Ship today"), + alignment: .left, + indentLevel: 1 + ) + let viewModel = configuredViewModel(blocks: [block]) + + let continuationID = try #require(viewModel.continueListItem(block.id, at: 5)) + + #expect(viewModel.document.blocks.map(plainText) == ["Ship ", "today"]) + #expect(viewModel.document.blocks.map(\.kind) == [ + .taskListItem(isChecked: true), + .taskListItem(isChecked: false) + ]) + #expect(viewModel.document.blocks.map(\.indentLevel) == [1, 1]) + #expect(viewModel.activeBlockID == continuationID) + #expect(viewModel.undoStack.count == 1) + } + + @Test func continuingOrderedItemRenumbersFollowingSiblings() throws { + let first = NativeEditorBlock( + kind: .orderedListItem(ordinal: 4), + text: AttributedString("Four"), + alignment: .left + ) + let second = NativeEditorBlock( + kind: .orderedListItem(ordinal: 5), + text: AttributedString("Five"), + alignment: .left + ) + let third = NativeEditorBlock( + kind: .orderedListItem(ordinal: 6), + text: AttributedString("Six"), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [first, second, third]) + + _ = try #require(viewModel.continueListItem(first.id, at: 4)) + + #expect(viewModel.document.blocks.map(plainText) == ["Four", "", "Five", "Six"]) + #expect(viewModel.document.blocks.map(\.kind) == [ + .orderedListItem(ordinal: 4), + .orderedListItem(ordinal: 5), + .orderedListItem(ordinal: 6), + .orderedListItem(ordinal: 7) + ]) + } + + @Test func continuingEmptyTopLevelListItemExitsToParagraph() throws { + let block = NativeEditorBlock(kind: .bulletListItem, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + + let resultingID = try #require(viewModel.continueListItem(block.id, at: 0)) + + #expect(resultingID == block.id) + #expect(viewModel.document.blocks.count == 1) + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(viewModel.document.blocks[0].rawNode == nil) + #expect(viewModel.activeBlockID == block.id) + #expect(viewModel.undoStack.count == 1) + } + + @Test func continuingEmptyNestedListItemOutdentsWithoutChangingKind() throws { + let block = NativeEditorBlock( + kind: .orderedListItem(ordinal: 8), + text: AttributedString(""), + alignment: .left, + indentLevel: 2 + ) + let viewModel = configuredViewModel(blocks: [block]) + + _ = try #require(viewModel.continueListItem(block.id, at: 0)) + + #expect(viewModel.document.blocks[0].kind == .orderedListItem(ordinal: 8)) + #expect(viewModel.document.blocks[0].indentLevel == 1) + #expect(viewModel.undoStack.count == 1) + } + + @Test func mergePreservesCustomRunsAndPlacesCaretAtJoin() throws { + var firstText = AttributedString("Plan ") + firstText.inlinePresentationIntent = .stronglyEmphasized + var secondText = AttributedString("SET STATUS") + secondText[NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "", color: "gray") + secondText.inlinePresentationIntent = .stronglyEmphasized + let first = NativeEditorBlock(kind: .paragraph, text: firstText, alignment: .left) + let second = NativeEditorBlock(kind: .paragraph, text: secondText, alignment: .left) + let viewModel = configuredViewModel(blocks: [first, second]) + + #expect(viewModel.mergeBlockBackward(second.id)) + + let merged = try #require(viewModel.document.blocks.first) + #expect(viewModel.document.blocks.count == 1) + #expect(plainText(merged) == "Plan SET STATUS") + #expect(try insertionOffset(in: merged) == 5) + #expect( + merged.text.runs.contains { + $0[NativeEditorStatusAttribute.self] == NativeEditorStatusBadge(text: "", color: "gray") + } + ) + #expect(viewModel.activeBlockID == first.id) + #expect(viewModel.undoStack.count == 1) + + viewModel.undo() + + #expect(viewModel.document.blocks.map(plainText) == ["Plan ", "SET STATUS"]) + } + + @Test func mergeRefusesToDiscardAdditionalRawListContent() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Before" }] }, + { + "type": "bulletList", + "content": [{ + "type": "listItem", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Visible" }] }, + { "type": "paragraph", "content": [{ "type": "text", "text": "Preserved" }] } + ] + }] + } + ] + } + """.utf8) + ) + let viewModel = configuredViewModel( + blocks: NativeEditorDocument(proseMirrorDocument: original).blocks + ) + let sourceID = try #require(viewModel.document.blocks.last?.id) + let baseline = viewModel.document.proseMirrorDocument + + #expect(viewModel.mergeBlockBackward(sourceID) == false) + #expect(viewModel.document.proseMirrorDocument == baseline) + #expect(viewModel.undoStack.isEmpty) + } + + @Test func listSplitPreservesAdditionalRawContentOnOriginalItem() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [{ + "type": "bulletList", + "content": [{ + "type": "listItem", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Launch" }] }, + { "type": "paragraph", "content": [{ "type": "text", "text": "Preserved" }] } + ] + }] + }] + } + """.utf8) + ) + let viewModel = configuredViewModel( + blocks: NativeEditorDocument(proseMirrorDocument: original).blocks + ) + let sourceID = try #require(viewModel.document.blocks.first?.id) + + _ = try #require(viewModel.continueListItem(sourceID, at: 3)) + + let items = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + let originalItem = try #require(items.first) + let continuationItem = try #require(items.dropFirst().first) + let originalItemContent = try #require(originalItem.content) + #expect(items.count == 2) + try #require(originalItemContent.count == 2) + #expect(originalItemContent[0].content?.first?.text == "Lau") + #expect(originalItemContent[1].content?.first?.text == "Preserved") + #expect(continuationItem.content?.first?.content?.first?.text == "nch") + } + + @Test func listSplitMovesNestedListToContinuationWithoutDuplicatingIt() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [{ + "type": "bulletList", + "content": [{ + "type": "listItem", + "attrs": { "custom": "preserved" }, + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Launch" }] }, + { + "type": "bulletList", + "content": [{ + "type": "listItem", + "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Nested" }] } + ] + }] + } + ] + }] + }] + } + """.utf8) + ) + let viewModel = configuredViewModel( + blocks: NativeEditorDocument(proseMirrorDocument: original).blocks + ) + let sourceID = try #require(viewModel.document.blocks.first?.id) + + _ = try #require(viewModel.continueListItem(sourceID, at: 3)) + + let items = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + let leadingContent = try #require(items.first?.content) + let continuationContent = try #require(items.dropFirst().first?.content) + #expect(items.count == 2) + #expect(items.first?.attrs?["custom"] == .string("preserved")) + #expect(leadingContent.contains(where: \.isListContainer) == false) + #expect(continuationContent.filter(\.isListContainer).count == 1) + #expect(continuationContent.last?.content?.first?.content?.first?.content?.first?.text == "Nested") + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } + + private func plainText(_ block: NativeEditorBlock) -> String { + String(block.text.characters) + } + + private func insertionOffset(in block: NativeEditorBlock) throws -> Int { + switch block.selection.indices(in: block.text) { + case .insertionPoint(let index): + return block.text.characters.distance(from: block.text.startIndex, to: index) + case .ranges: + Issue.record("Expected an insertion-point selection") + return -1 + } + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorBlockPropertyTests.swift b/docmostlyTests/Editor/NativeRichEditorBlockPropertyTests.swift index f452b6d..a03b04d 100644 --- a/docmostlyTests/Editor/NativeRichEditorBlockPropertyTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorBlockPropertyTests.swift @@ -211,6 +211,9 @@ struct NativeRichEditorBlockPropertyTests { updateCallout: { _, _, _, _ in }, updateDetails: { _, _, _, _ in }, updateColumns: { _, _, _, _ in }, + updateNestedContent: { _, _, _ in }, + setColumnCount: { _, _ in }, + updateColumnWidth: { _, _, _ in }, updateTransclusionSource: { _, _, _ in }, updateTransclusionReference: { _, _, _ in }, updateMediaBlock: { _, _ in }, diff --git a/docmostlyTests/Editor/NativeRichEditorStructuralAuthoringTests.swift b/docmostlyTests/Editor/NativeRichEditorStructuralAuthoringTests.swift new file mode 100644 index 0000000..cb0db41 --- /dev/null +++ b/docmostlyTests/Editor/NativeRichEditorStructuralAuthoringTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeRichEditorStructuralAuthoringTests { + @Test func nestedCalloutEditPreservesContainerAttributesAndRichNodes() throws { + let originalNode = ProseMirrorNode( + type: "callout", + attrs: ["type": .string("warning"), "icon": .string("⚠️"), "future": .string("keep")], + content: [paragraph("Before")] + ) + let viewModel = viewModel(containing: originalNode) + let blockID = try #require(viewModel.document.blocks.first?.id) + let replacement = [ + ProseMirrorNode( + type: "heading", + attrs: ["level": .int(2)], + content: [ProseMirrorNode(type: "text", marks: [ProseMirrorMark(type: "bold")], text: "Release")] + ), + ProseMirrorNode( + type: "bulletList", + content: [ + ProseMirrorNode(type: "listItem", content: [paragraph("Preserved")]) + ] + ) + ] + + viewModel.updateNestedContent(blockID: blockID, target: .callout, content: replacement) + + let updatedNode = try #require(viewModel.document.blocks.first?.rawNode) + #expect(updatedNode.attrs == originalNode.attrs) + #expect(updatedNode.content == replacement) + let serializedNode = try #require(viewModel.document.proseMirrorDocument.content.first) + #expect(serializedNode.type == updatedNode.type) + #expect(serializedNode.attrs == updatedNode.attrs) + #expect(serializedNode.content?.map(\.type) == replacement.map(\.type)) + #expect( + NativeEditorDocument.plainText(in: serializedNode.content ?? []) == + NativeEditorDocument.plainText(in: replacement) + ) + + let encodedDocument = try JSONEncoder().encode(viewModel.document.proseMirrorDocument) + let decodedDocument = try JSONDecoder().decode(ProseMirrorDocument.self, from: encodedDocument) + #expect(decodedDocument == viewModel.document.proseMirrorDocument) + } + + @Test func reducingColumnsMergesRemovedRichChildrenIntoLastRetainedColumn() throws { + let columns = ProseMirrorNode( + type: "columns", + attrs: ["layout": .string("four_equal"), "widthMode": .string("wide")], + content: [ + column(width: 0.5, content: [paragraph("One")]), + column(width: 1.5, content: [paragraph("Two")]), + column(width: nil, content: [ + ProseMirrorNode( + type: "heading", + attrs: ["level": .int(3)], + content: [ProseMirrorNode(type: "text", text: "Three")] + ) + ]), + column(width: nil, content: [ + ProseMirrorNode(type: "paragraph", content: []), + ProseMirrorNode(type: "horizontalRule") + ]) + ] + ) + let viewModel = viewModel(containing: columns) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.setColumnCount(blockID: blockID, count: 2) + + let updatedNode = try #require(viewModel.document.blocks.first?.rawNode) + let updatedColumns = try #require(updatedNode.content) + #expect(updatedColumns.count == 2) + #expect(updatedNode.attrs?["layout"] == .string("two_equal")) + #expect(updatedNode.attrs?["widthMode"] == .string("wide")) + #expect(updatedColumns[0].attrs?["width"] == .double(0.5)) + #expect(updatedColumns[1].attrs?["width"] == .double(1.5)) + #expect(updatedColumns[1].content?.map(\.type) == ["paragraph", "heading", "horizontalRule"]) + } + + @Test func columnWidthEditPreservesColumnContentAndUnknownAttributes() throws { + let originalContent = [paragraph("Rich column")] + let columns = ProseMirrorNode( + type: "columns", + attrs: ["layout": .string("two_equal")], + content: [ + ProseMirrorNode( + type: "column", + attrs: ["future": .bool(true)], + content: originalContent + ), + column(width: nil, content: [paragraph("Two")]) + ] + ) + let viewModel = viewModel(containing: columns) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateColumnWidth(blockID: blockID, columnIndex: 0, width: 1.75) + + let updatedColumn = try #require(viewModel.document.blocks.first?.rawNode?.content?.first) + #expect(updatedColumn.attrs?["width"] == .double(1.75)) + #expect(updatedColumn.attrs?["future"] == .bool(true)) + #expect(updatedColumn.content == originalContent) + } + + @Test func syncedSourceRejectsNestedSyncedNodesButAcceptsRichAllowedContent() throws { + let source = ProseMirrorNode( + type: "transclusionSource", + attrs: ["id": .string("sync-1")], + content: [paragraph("Original")] + ) + let viewModel = viewModel(containing: source) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateNestedContent( + blockID: blockID, + target: .transclusionSource, + content: [ProseMirrorNode(type: "transclusionSource", content: [paragraph("Nested")])] + ) + #expect(viewModel.document.blocks.first?.rawNode == source) + + let allowed = [ProseMirrorNode(type: "table", content: [])] + viewModel.updateNestedContent(blockID: blockID, target: .transclusionSource, content: allowed) + #expect(viewModel.document.blocks.first?.rawNode?.content == allowed) + } + + @Test func syncedSourceAcceptsExistingPageBreaks() throws { + let source = ProseMirrorNode( + type: "transclusionSource", + attrs: ["id": .string("sync-1")], + content: [paragraph("Before"), ProseMirrorNode(type: "pageBreak"), paragraph("After")] + ) + let viewModel = viewModel(containing: source) + let blockID = try #require(viewModel.document.blocks.first?.id) + let updatedContent = [paragraph("Updated"), ProseMirrorNode(type: "pageBreak"), paragraph("After")] + + viewModel.updateNestedContent( + blockID: blockID, + target: .transclusionSource, + content: updatedContent + ) + + #expect(viewModel.document.blocks.first?.rawNode?.content == updatedContent) + } + + @Test func mediaConfigurationPreservesServerManagedAndUnknownAttributes() throws { + let image = ProseMirrorNode( + type: "image", + attrs: [ + "src": .string("/files/original.png"), + "attachmentId": .string("attachment-1"), + "size": .int(4096), + "future": .string("keep") + ] + ) + let viewModel = viewModel(containing: image) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateMediaBlock( + blockID: blockID, + update: NativeEditorMediaBlockUpdate( + source: "/files/updated.png", + alternativeText: "Architecture", + width: "50%", + height: "", + alignment: "right" + ) + ) + + let attrs = try #require(viewModel.document.blocks.first?.rawNode?.attrs) + #expect(attrs["src"] == .string("/files/updated.png")) + #expect(attrs["alt"] == .string("Architecture")) + #expect(attrs["width"] == .string("50%")) + #expect(attrs["align"] == .string("right")) + #expect(attrs["attachmentId"] == .string("attachment-1")) + #expect(attrs["size"] == .int(4096)) + #expect(attrs["future"] == .string("keep")) + } + + private func viewModel(containing node: ProseMirrorNode) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1") + viewModel.document = NativeEditorDocument( + proseMirrorDocument: ProseMirrorDocument(content: [node]) + ) + return viewModel + } + + private func paragraph(_ text: String) -> ProseMirrorNode { + ProseMirrorNode(type: "paragraph", content: [ProseMirrorNode(type: "text", text: text)]) + } + + private func column(width: Double?, content: [ProseMirrorNode]) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + attrs: width.map { ["width": .double($0)] }, + content: content + ) + } +} + +struct NativeEditorStructuralPolicyTests { + @Test func tableFocusMovesAcrossRowsAndAppendsAfterLastCell() { + #expect( + NativeEditorTableFocusNavigation.destination( + from: NativeEditorTableCellCoordinate(rowIndex: 0, columnIndex: 1), + direction: .forward, + rowCount: 2, + columnCount: 2 + ) == .cell(NativeEditorTableCellCoordinate(rowIndex: 1, columnIndex: 0)) + ) + #expect( + NativeEditorTableFocusNavigation.destination( + from: NativeEditorTableCellCoordinate(rowIndex: 1, columnIndex: 1), + direction: .forward, + rowCount: 2, + columnCount: 2 + ) == .appendRowAndFocus(NativeEditorTableCellCoordinate(rowIndex: 2, columnIndex: 0)) + ) + #expect( + NativeEditorTableFocusNavigation.destination( + from: NativeEditorTableCellCoordinate(rowIndex: 1, columnIndex: 0), + direction: .backward, + rowCount: 2, + columnCount: 2 + ) == .cell(NativeEditorTableCellCoordinate(rowIndex: 0, columnIndex: 1)) + ) + } + + @Test func columnLayoutUsesWebPresetsAndStacksNarrowCanvases() { + #expect( + NativeEditorColumnsLayoutPolicy.weights( + layout: "three_with_sidebars", + explicitWidths: [nil, nil, nil], + count: 3 + ) == [0.7, 1.6, 0.7] + ) + #expect( + NativeEditorColumnsLayoutPolicy.weights( + layout: "two_equal", + explicitWidths: [0.5, 1.5], + count: 2 + ) == [0.5, 1.5] + ) + #expect(NativeEditorColumnsLayoutPolicy.shouldStack(availableWidth: 320, count: 2)) + #expect(NativeEditorColumnsLayoutPolicy.shouldStack(availableWidth: 900, count: 3) == false) + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift index 935376f..0866a6f 100644 --- a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift @@ -147,6 +147,144 @@ struct NativeRichEditorStructuralBlockTests { #expect(nodes[2].attrs?["transclusionId"] == .string("sync-2")) } + @Test func containerPropertyUpdatesPreserveStructuredNestedContent() throws { + let calloutNode = structuredCalloutNode() + let detailsNode = structuredDetailsNode() + let columnsNode = structuredColumnsNode() + let syncedNode = structuredSyncedNode() + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument( + proseMirrorDocument: ProseMirrorDocument(content: [calloutNode, detailsNode, columnsNode, syncedNode]) + ) + let baselineNodes = viewModel.document.proseMirrorDocument.content + + let calloutID = viewModel.document.blocks[0].id + let detailsID = viewModel.document.blocks[1].id + let columnsID = viewModel.document.blocks[2].id + let syncedID = viewModel.document.blocks[3].id + let callout = try #require(viewModel.document.blocks[0].rawNode) + let details = try #require(viewModel.document.blocks[1].rawNode) + let columns = try #require(viewModel.document.blocks[2].rawNode) + let synced = try #require(viewModel.document.blocks[3].rawNode) + + viewModel.updateCallout( + blockID: calloutID, + style: "warning", + icon: "⚠️", + text: NativeEditorDocument.plainText(in: callout.content ?? []) + ) + viewModel.updateDetails( + blockID: detailsID, + summary: "More", + body: NativeEditorDocument.plainText(in: details.content ?? []), + isOpen: true + ) + viewModel.updateColumns( + blockID: columnsID, + layout: "two_left_sidebar", + widthMode: "wide", + columnTexts: (columns.content ?? []).map { NativeEditorDocument.plainText(in: $0.content ?? []) } + ) + viewModel.updateTransclusionSource( + blockID: syncedID, + identifier: "sync-2", + text: NativeEditorDocument.plainText(in: synced.content ?? []) + ) + + let updatedNodes = viewModel.document.proseMirrorDocument.content + #expect(updatedNodes[0].content == baselineNodes[0].content) + #expect(updatedNodes[0].attrs?["type"] == .string("warning")) + #expect(updatedNodes[1].content == baselineNodes[1].content) + #expect(updatedNodes[1].attrs?["open"] == .bool(true)) + #expect(updatedNodes[2].content == baselineNodes[2].content) + #expect(updatedNodes[2].attrs?["layout"] == .string("two_left_sidebar")) + #expect(updatedNodes[3].content == baselineNodes[3].content) + #expect(updatedNodes[3].attrs?["id"] == .string("sync-2")) + } + + private func structuredCalloutNode() -> ProseMirrorNode { + ProseMirrorNode( + type: "callout", + attrs: ["type": .string("info")], + content: [ + ProseMirrorNode( + type: "heading", + attrs: ["level": .int(2)], + content: [ProseMirrorNode(type: "text", text: "Important")] + ), + ProseMirrorNode( + type: "bulletList", + content: [ + ProseMirrorNode( + type: "listItem", + content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: "Keep formatting")] + ) + ] + ) + ] + ) + ] + ) + } + + private func structuredDetailsNode() -> ProseMirrorNode { + ProseMirrorNode( + type: "details", + attrs: ["open": .bool(false)], + content: [ + ProseMirrorNode( + type: "detailsSummary", + content: [ProseMirrorNode(type: "text", text: "More")] + ), + ProseMirrorNode( + type: "detailsContent", + content: [structuredEmbedNode()] + ) + ] + ) + } + + private func structuredColumnsNode() -> ProseMirrorNode { + ProseMirrorNode( + type: "columns", + attrs: ["layout": .string("two_equal"), "widthMode": .string("normal")], + content: [ + ProseMirrorNode( + type: "column", + content: [ + ProseMirrorNode( + type: "heading", + attrs: ["level": .int(3)], + content: [ProseMirrorNode(type: "text", text: "Left")] + ) + ] + ), + ProseMirrorNode( + type: "column", + content: [ProseMirrorNode(type: "paragraph", content: [])] + ) + ] + ) + } + + private func structuredSyncedNode() -> ProseMirrorNode { + ProseMirrorNode( + type: "transclusionSource", + attrs: ["id": .string("sync-1")], + content: [structuredEmbedNode()] + ) + } + + private func structuredEmbedNode() -> ProseMirrorNode { + ProseMirrorNode( + type: "embed", + attrs: ["src": .string("https://open.spotify.com/track/example")] + ) + } + private func structuralBlockViewModel() -> NativeRichEditorViewModel { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: [ diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 0d8cf64..ae8c4ac 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -323,7 +323,7 @@ struct NativeRichEditorViewModelTests { #expect(node?.attrs?["size"] == .int(4096)) } - @Test func deletesSelectedBlockAndKeepsAdjacentBlockActive() { + @Test func deletesSelectedBlockAndFocusesPrecedingBlockAtItsEnd() throws { let firstBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("First"), alignment: .left) let selectedBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("Selected"), alignment: .left) let lastBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("Last"), alignment: .left) @@ -335,7 +335,12 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["First", "Last"]) #expect(viewModel.selectedBlockID == nil) - #expect(viewModel.activeBlockID == lastBlock.id) + #expect(viewModel.activeBlockID == firstBlock.id) + let selection = try #require(NativeEditorCharacterRange.characterRange( + for: viewModel.document.blocks[0].selection, + in: viewModel.document.blocks[0].text + )) + #expect(selection == 5..<5) #expect(viewModel.isDirty == true) } diff --git a/docmostlyTests/Engagement/CursorPageAccumulatorTests.swift b/docmostlyTests/Engagement/CursorPageAccumulatorTests.swift new file mode 100644 index 0000000..a3daac0 --- /dev/null +++ b/docmostlyTests/Engagement/CursorPageAccumulatorTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import docmostly + +struct CursorPageAccumulatorTests { + @Test func appendingPagesDeduplicatesStableIDsAndAdvancesCursor() { + var accumulator = CursorPageAccumulator() + accumulator.replace(with: response( + items: [TestItem(id: "1"), TestItem(id: "2")], + nextCursor: "cursor-2" + )) + + accumulator.append( + response(items: [TestItem(id: "2"), TestItem(id: "3")], nextCursor: nil), + requestedCursor: "cursor-2" + ) + + #expect(accumulator.items.map(\.id) == ["1", "2", "3"]) + #expect(accumulator.hasNextPage == false) + #expect(accumulator.nextCursor == nil) + } + + @Test func repeatedServerCursorStopsPaginationLoop() { + var accumulator = CursorPageAccumulator() + accumulator.replace(with: response(items: [TestItem(id: "1")], nextCursor: "same-cursor")) + + accumulator.append( + response(items: [TestItem(id: "2")], nextCursor: "same-cursor"), + requestedCursor: "same-cursor" + ) + + #expect(accumulator.items.map(\.id) == ["1", "2"]) + #expect(accumulator.hasNextPage == false) + } + + private func response( + items: [TestItem], + nextCursor: String? + ) -> PaginatedResponse { + PaginatedResponse( + items: items, + meta: PaginationMeta( + limit: 2, + hasNextPage: nextCursor != nil, + hasPrevPage: false, + nextCursor: nextCursor, + prevCursor: nil + ) + ) + } +} + +private struct TestItem: Codable, Identifiable, Sendable { + let id: String +} diff --git a/docmostlyTests/Engagement/FavoritesViewModelTests.swift b/docmostlyTests/Engagement/FavoritesViewModelTests.swift new file mode 100644 index 0000000..1429c8d --- /dev/null +++ b/docmostlyTests/Engagement/FavoritesViewModelTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct FavoritesViewModelTests { + @Test func optimisticRemovalStaysRemovedAfterServerSuccess() async { + let favorite = pageFavorite(id: "favorite-page") + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(response(items: [favorite])) + + await viewModel.remove(favorite) { } + + #expect(viewModel.favorites.isEmpty) + #expect(viewModel.errorMessage == nil) + } + + @Test func optimisticRemovalRestoresOriginalOrderAfterFailure() async { + let first = pageFavorite(id: "favorite-page") + let second = spaceFavorite(id: "favorite-space", name: "Product") + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(response(items: [first, second])) + + await viewModel.remove(first) { + throw FavoriteTestError.failed + } + + #expect(viewModel.favorites.map(\.id) == [first.id, second.id]) + #expect(viewModel.errorMessage != nil) + } + + @Test func sectionsRepresentSpacesPagesAndTemplatesWithoutDroppingTypes() { + let zulu = spaceFavorite(id: "favorite-zulu", name: "Zulu") + let alpha = spaceFavorite(id: "favorite-alpha", name: "Alpha") + let page = pageFavorite(id: "favorite-page") + let template = templateFavorite(id: "favorite-template") + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(response(items: [zulu, page, template, alpha])) + + #expect(viewModel.sections.map(\.type) == [.space, .page, .template]) + #expect(viewModel.sections.first?.favorites.map(\.title) == ["Alpha", "Zulu"]) + #expect(PageOpenTarget(favorite: page)?.slugId == "roadmap") + #expect(zulu.targetID == "space-1") + #expect(template.title == "Planning Template") + } + + private func response( + items: [DocmostFavorite] + ) -> PaginatedResponse { + PaginatedResponse( + items: items, + meta: PaginationMeta( + limit: 30, + hasNextPage: false, + hasPrevPage: false, + nextCursor: nil, + prevCursor: nil + ) + ) + } + + private func pageFavorite(id: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: "page-1", + spaceId: nil, + templateId: nil, + type: .page, + workspaceId: "workspace-1", + createdAt: .now, + page: DocmostFavoritePage( + id: "page-1", + slugId: "roadmap", + title: "Roadmap", + icon: nil, + spaceId: "space-1" + ), + space: DocmostFavoriteSpace(id: "space-1", name: "Product", slug: "product", logo: nil), + template: nil + ) + } + + private func spaceFavorite(id: String, name: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: nil, + spaceId: "space-1", + templateId: nil, + type: .space, + workspaceId: "workspace-1", + createdAt: .now, + page: nil, + space: DocmostFavoriteSpace(id: "space-1", name: name, slug: name.lowercased(), logo: nil), + template: nil + ) + } + + private func templateFavorite(id: String) -> DocmostFavorite { + DocmostFavorite( + id: id, + userId: "user-1", + pageId: nil, + spaceId: nil, + templateId: "template-1", + type: .template, + workspaceId: "workspace-1", + createdAt: .now, + page: nil, + space: nil, + template: DocmostFavoriteTemplate( + id: "template-1", + title: "Planning Template", + description: nil, + icon: nil, + spaceId: nil + ) + ) + } +} + +private enum FavoriteTestError: Error { + case failed +} diff --git a/docmostlyTests/Engagement/NotificationDeepLinkRoutingTests.swift b/docmostlyTests/Engagement/NotificationDeepLinkRoutingTests.swift new file mode 100644 index 0000000..602f2ef --- /dev/null +++ b/docmostlyTests/Engagement/NotificationDeepLinkRoutingTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NotificationDeepLinkRoutingTests { + @Test func commentNotificationCarriesCommentThroughAppNavigation() throws { + let notification = DocmostNotification( + id: "notification-1", + userId: "user-1", + workspaceId: "workspace-1", + type: .commentCreated, + actorId: "user-2", + pageId: "page-1", + spaceId: "space-1", + commentId: "comment-1", + data: nil, + readAt: nil, + emailedAt: nil, + archivedAt: nil, + createdAt: .now, + actor: nil, + page: DocmostNotificationPage(id: "page-1", title: "Roadmap", slugId: "roadmap", icon: nil), + space: DocmostNotificationSpace(id: "space-1", name: "Product", slug: "product"), + comment: nil + ) + let target = try #require(PageOpenTarget(notification: notification)) + let appState = makeAppState() + + appState.openPage(target) + + #expect(target.commentId == "comment-1") + #expect(appState.selectedPageID == "roadmap") + #expect(appState.selectedSpaceID == "space-1") + #expect(appState.selectedCommentID == "comment-1") + } + + private func makeAppState() -> AppState { + let suiteName = "Docmostly.NotificationDeepLinkRoutingTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard + userDefaults.removePersistentDomain(forName: suiteName) + return AppState(settingsStore: LocalSettingsStore(userDefaults: userDefaults)) + } +} diff --git a/docmostlyTests/Engagement/NotificationListViewModelTests.swift b/docmostlyTests/Engagement/NotificationListViewModelTests.swift new file mode 100644 index 0000000..1b4aa01 --- /dev/null +++ b/docmostlyTests/Engagement/NotificationListViewModelTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NotificationListViewModelTests { + @Test func defaultsToAllNotifications() { + let viewModel = NotificationListViewModel() + + #expect(viewModel.selectedType == .all) + } + + @Test func optimisticReadUpdatesCountAndLoadedRow() async { + let notification = makeNotification(id: "notification-1") + let viewModel = NotificationListViewModel() + let store = NotificationStore() + store.reconcile(unreadCount: 2) + viewModel.applyInitialPage(response(items: [notification])) + + await viewModel.markRead(notification, store: store) { } + + #expect(viewModel.isUnread(notification) == false) + #expect(store.unreadCount == 1) + #expect(viewModel.errorMessage == nil) + } + + @Test func failedOptimisticReadRollsBackCountAndLoadedRow() async { + let notification = makeNotification(id: "notification-1") + let viewModel = NotificationListViewModel() + let store = NotificationStore() + store.reconcile(unreadCount: 2) + viewModel.applyInitialPage(response(items: [notification])) + + await viewModel.markRead(notification, store: store) { + throw EngagementTestError.failed + } + + #expect(viewModel.isUnread(notification)) + #expect(store.unreadCount == 2) + #expect(viewModel.errorMessage != nil) + } + + @Test func markAllReadRollsBackEveryLoadedRowOnFailure() async { + let first = makeNotification(id: "notification-1") + let second = makeNotification(id: "notification-2") + let viewModel = NotificationListViewModel() + let store = NotificationStore() + store.reconcile(unreadCount: 4) + viewModel.applyInitialPage(response(items: [first, second])) + + await viewModel.markAllRead(store: store) { + throw EngagementTestError.failed + } + + #expect(viewModel.isUnread(first)) + #expect(viewModel.isUnread(second)) + #expect(store.unreadCount == 4) + } + + private func response( + items: [DocmostNotification] + ) -> PaginatedResponse { + PaginatedResponse( + items: items, + meta: PaginationMeta( + limit: 30, + hasNextPage: false, + hasPrevPage: false, + nextCursor: nil, + prevCursor: nil + ) + ) + } + + private func makeNotification(id: String) -> DocmostNotification { + DocmostNotification( + id: id, + userId: "user-1", + workspaceId: "workspace-1", + type: .pageUpdated, + actorId: nil, + pageId: "page-1", + spaceId: "space-1", + commentId: nil, + data: nil, + readAt: nil, + emailedAt: nil, + archivedAt: nil, + createdAt: .now, + actor: nil, + page: DocmostNotificationPage(id: "page-1", title: "Roadmap", slugId: "roadmap", icon: nil), + space: DocmostNotificationSpace(id: "space-1", name: "Product", slug: "product"), + comment: nil + ) + } +} + +private enum EngagementTestError: Error { + case failed +} diff --git a/docmostlyTests/Networking/CommentDecodingTests.swift b/docmostlyTests/Networking/CommentDecodingTests.swift index dea9153..3259d73 100644 --- a/docmostlyTests/Networking/CommentDecodingTests.swift +++ b/docmostlyTests/Networking/CommentDecodingTests.swift @@ -44,7 +44,7 @@ struct CommentDecodingTests { #expect(comment.isNativelyEditable) } - @Test func richJSONContentIsNotNativelyEditable() throws { + @Test func supportedRichJSONContentIsNativelyEditable() throws { let data = commentData(contentJSON: """ { "type": "doc", @@ -68,7 +68,10 @@ struct CommentDecodingTests { let comment = try DocmostJSONDecoder.make().decode(DocmostComment.self, from: data) #expect(comment.content == "Docmost") - #expect(comment.isNativelyEditable == false) + #expect(comment.isNativelyEditable) + #expect(comment.body?.document.content.first?.content?.first?.marks?.contains(where: { mark in + mark.type == "link" && mark.attrs?["href"]?.stringValue == "https://docmost.com" + }) == true) } @Test func multiParagraphPlainJSONContentIsNativelyEditable() throws { @@ -203,6 +206,56 @@ struct CommentDecodingTests { #expect(comment.isNativelyEditable == false) } + @Test func rejectsOversizedCommentAttributesDuringDecode() throws { + let oversizedAttribute = String( + repeating: "A", + count: ProseMirrorDecodingLimits.maximumAttributeStringLength + 1 + ) + let data = commentData(contentJSON: """ + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "attrs": { "metadata": "\(oversizedAttribute)" }, + "content": [{ "type": "text", "text": "Safe text" }] + } + ] + } + """) + + let comment = try DocmostJSONDecoder.make().decode(DocmostComment.self, from: data) + + #expect(comment.content == nil) + #expect(comment.body == nil) + #expect(comment.isNativelyEditable == false) + } + + @Test func sharedAPIDecoderReceivesAFreshDocumentBudgetForEveryTopLevelDecode() throws { + let decoder = DocmostJSONDecoder.make() + let initialBudget = try #require( + decoder.userInfo[.proseMirrorDecodingBudget] as? ProseMirrorDecodingBudget + ) + let data = commentData(contentJSON: """ + { + "type": "doc", + "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Safe" }] }] + } + """) + + _ = try DocmostJSONDecoder.decode(DocmostComment.self, from: data, using: decoder) + let firstDecodeBudget = try #require( + decoder.userInfo[.proseMirrorDecodingBudget] as? ProseMirrorDecodingBudget + ) + _ = try DocmostJSONDecoder.decode(DocmostComment.self, from: data, using: decoder) + let secondDecodeBudget = try #require( + decoder.userInfo[.proseMirrorDecodingBudget] as? ProseMirrorDecodingBudget + ) + + #expect(initialBudget !== firstDecodeBudget) + #expect(firstDecodeBudget !== secondDecodeBudget) + } + private func commentData(contentJSON: String) -> Data { Data(""" { diff --git a/docmostlyTests/Networking/EngagementDecodingTests.swift b/docmostlyTests/Networking/EngagementDecodingTests.swift index 6a5e7bb..5276b95 100644 --- a/docmostlyTests/Networking/EngagementDecodingTests.swift +++ b/docmostlyTests/Networking/EngagementDecodingTests.swift @@ -3,6 +3,31 @@ import Testing @testable import docmostly struct EngagementDecodingTests { + @Test func decodesUntitledCreatePageResponseWithNullTitle() throws { + let data = Data(""" + { + "data": { + "id": "page-1", + "slugId": "new-note", + "title": null, + "spaceId": "space-1", + "permissions": { + "canEdit": true, + "hasRestriction": false + } + }, + "success": true, + "status": 200 + } + """.utf8) + + let envelope = try DocmostJSONDecoder.make().decode(APIEnvelope.self, from: data) + + #expect(envelope.data.title == "") + #expect(envelope.data.slugId == "new-note") + #expect(envelope.data.permissions?.canEdit == true) + } + @Test func decodesBreadcrumbs() throws { let data = Data(""" { diff --git a/docmostlyTests/Networking/PageDetailsDecodingTests.swift b/docmostlyTests/Networking/PageDetailsDecodingTests.swift index 7169361..b361cb7 100644 --- a/docmostlyTests/Networking/PageDetailsDecodingTests.swift +++ b/docmostlyTests/Networking/PageDetailsDecodingTests.swift @@ -3,6 +3,23 @@ import Testing @testable import docmostly struct PageDetailsDecodingTests { + @Test func decodesUntitledEditablePageWithNullTitle() throws { + let data = Data(""" + { + "id": "page-1", + "slugId": "new-note", + "title": null, + "content": null, + "spaceId": "space-1" + } + """.utf8) + + let page = try DocmostJSONDecoder.make().decode(DocmostEditablePage.self, from: data) + + #expect(page.title == "") + #expect(page.content == nil) + } + @Test func editablePageKeepsDetailsMetadata() throws { let data = Data(""" { diff --git a/docmostlyTests/Networking/TransclusionDecodingTests.swift b/docmostlyTests/Networking/TransclusionDecodingTests.swift new file mode 100644 index 0000000..f10a06d --- /dev/null +++ b/docmostlyTests/Networking/TransclusionDecodingTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import docmostly + +struct TransclusionDecodingTests { + @Test func decodesResolvedNotFoundAndNoAccessResults() throws { + let data = Data(""" + { + "data": { + "items": [ + { + "sourcePageId": "page-1", + "transclusionId": "sync-1", + "content": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Daily update" }] + } + ] + }, + "sourceUpdatedAt": "2026-07-10T09:30:00.000Z" + }, + { + "sourcePageId": "page-2", + "transclusionId": "sync-2", + "status": "not_found" + }, + { + "sourcePageId": "page-3", + "transclusionId": "sync-3", + "status": "no_access" + } + ] + }, + "success": true, + "status": 200 + } + """.utf8) + + let envelope = try DocmostJSONDecoder.make().decode( + APIEnvelope.self, + from: data + ) + + #expect(envelope.data.items.count == 3) + guard case .resolved(let reference, let content, let updatedAt) = envelope.data.items[0] else { + Issue.record("Expected a resolved synced block.") + return + } + #expect(reference == DocmostTransclusionReference(sourcePageId: "page-1", transclusionId: "sync-1")) + #expect(content.content.first?.content?.first?.text == "Daily update") + #expect(updatedAt == (try? Date( + "2026-07-10T09:30:00.000Z", + strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true) + ))) + #expect(envelope.data.items[1].status == .notFound) + #expect(envelope.data.items[2].status == .noAccess) + } + + @Test func rejectsUnknownLookupStatus() { + let data = Data(""" + { + "items": [ + { + "sourcePageId": "page-1", + "transclusionId": "sync-1", + "status": "deleted" + } + ] + } + """.utf8) + + #expect(throws: DecodingError.self) { + try DocmostJSONDecoder.make().decode(DocmostTransclusionLookupResponse.self, from: data) + } + } +} diff --git a/docmostlyTests/Networking/TransclusionEndpointTests.swift b/docmostlyTests/Networking/TransclusionEndpointTests.swift new file mode 100644 index 0000000..2121f43 --- /dev/null +++ b/docmostlyTests/Networking/TransclusionEndpointTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import docmostly + +struct TransclusionEndpointTests { + @Test func buildsTypedTransclusionLookupRequest() throws { + let baseURL = try #require(URL(string: "https://docs.example.com")) + let endpoint = Endpoint.transclusionLookup(DocmostTransclusionLookupRequest(references: [ + DocmostTransclusionReference(sourcePageId: "page-1", transclusionId: "sync-1"), + DocmostTransclusionReference(sourcePageId: "page-2", transclusionId: "sync-2") + ])) + let request = try endpoint.urlRequest(baseURL: baseURL) + + #expect(request.url?.absoluteString == "https://docs.example.com/api/pages/transclusion/lookup") + #expect(request.httpMethod == "POST") + + let body = try #require(request.httpBody) + let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let references = try #require(object["references"] as? [[String: String]]) + + #expect(references.count == 2) + #expect(references[0]["sourcePageId"] == "page-1") + #expect(references[0]["transclusionId"] == "sync-1") + #expect(references[1]["sourcePageId"] == "page-2") + #expect(references[1]["transclusionId"] == "sync-2") + } +} diff --git a/docmostlyTests/PageReader/PageReaderCollaborationTaskKeyTests.swift b/docmostlyTests/PageReader/PageReaderCollaborationTaskKeyTests.swift new file mode 100644 index 0000000..7e1b6a2 --- /dev/null +++ b/docmostlyTests/PageReader/PageReaderCollaborationTaskKeyTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import docmostly + +struct PageReaderCollaborationTaskKeyTests { + @Test func collaborationPresenceTaskKeyTracksParticipation() { + let interactiveKey = PageReaderCollaborationTaskKeys.collaborationPresence( + pageID: "page-1", + participation: .interactive + ) + let receiveOnlyKey = PageReaderCollaborationTaskKeys.collaborationPresence( + pageID: "page-1", + participation: .receiveOnly + ) + + #expect(interactiveKey != receiveOnlyKey) + } + + @Test func collaborationPresenceTaskKeyRequiresAVisiblePage() { + #expect( + PageReaderCollaborationTaskKeys.collaborationPresence( + pageID: "page-1", + participation: .interactive, + isVisible: false + ) == nil + ) + #expect( + PageReaderCollaborationTaskKeys.collaborationPresence( + pageID: "page-1", + participation: .interactive, + isVisible: true + ) != nil + ) + } + + @Test func realtimeAndCRDTSnapshotTaskKeysStayPageScopedAcrossParticipation() { + #expect( + PageReaderCollaborationTaskKeys.realtimeEvents( + pageID: "page-1", + participation: .interactive + ) == PageReaderCollaborationTaskKeys.realtimeEvents( + pageID: "page-1", + participation: .receiveOnly + ) + ) + #expect( + PageReaderCollaborationTaskKeys.crdtDocumentSnapshots( + pageID: "page-1", + participation: .interactive + ) == PageReaderCollaborationTaskKeys.crdtDocumentSnapshots( + pageID: "page-1", + participation: .receiveOnly + ) + ) + } +} diff --git a/docmostlyTests/PageReader/PageReaderCommentStateTests.swift b/docmostlyTests/PageReader/PageReaderCommentStateTests.swift index 6840ba9..0eb9025 100644 --- a/docmostlyTests/PageReader/PageReaderCommentStateTests.swift +++ b/docmostlyTests/PageReader/PageReaderCommentStateTests.swift @@ -138,13 +138,15 @@ struct PageReaderCommentStateTests { @Test func replyDraftIsClearedWhenReplyIsApplied() throws { let viewModel = PageReaderViewModel() - viewModel.replyDraftsByCommentID["parent-1"] = "Thanks" + viewModel.replyDraftsByCommentID["parent-1"] = CommentComposerState( + body: CommentBody(plainText: "Thanks") + ) let reply = try comment(id: "reply-1", text: "Thanks", resolvedAt: nil, parentCommentId: "parent-1") viewModel.applyCreatedReply(reply, parentCommentID: "parent-1") #expect(viewModel.comments.map(\.id) == ["reply-1"]) - #expect(viewModel.replyDraftsByCommentID["parent-1"] == nil) + #expect(viewModel.replyDraftsByCommentID["parent-1"]?.isEmpty == true) } @Test func replySubmissionDisallowsQueuedParentsRepliesAndDuplicatePosts() throws { @@ -152,9 +154,15 @@ struct PageReaderCommentStateTests { let parent = try comment(id: "parent-1", text: "Parent", resolvedAt: nil) let queuedParent = try comment(id: "offline-comment-1", text: "Queued", resolvedAt: nil) let reply = try comment(id: "reply-1", text: "Reply", resolvedAt: nil, parentCommentId: "parent-1") - viewModel.replyDraftsByCommentID[parent.id] = "Reply" - viewModel.replyDraftsByCommentID[queuedParent.id] = "Reply" - viewModel.replyDraftsByCommentID[reply.id] = "Reply" + viewModel.replyDraftsByCommentID[parent.id] = CommentComposerState( + body: CommentBody(plainText: "Reply") + ) + viewModel.replyDraftsByCommentID[queuedParent.id] = CommentComposerState( + body: CommentBody(plainText: "Reply") + ) + viewModel.replyDraftsByCommentID[reply.id] = CommentComposerState( + body: CommentBody(plainText: "Reply") + ) #expect(viewModel.canSubmitReply(to: parent)) #expect(viewModel.canSubmitReply(to: queuedParent) == false) @@ -187,10 +195,12 @@ struct PageReaderCommentStateTests { viewModel.comments = [existingComment] viewModel.beginEditing(existingComment) - #expect(viewModel.editDraftsByCommentID["comment-1"] == "Original") + #expect(viewModel.editDraftsByCommentID["comment-1"]?.plainText == "Original") #expect(viewModel.isEditingComment(id: "comment-1")) - viewModel.editDraftsByCommentID["comment-1"] = "Updated" + viewModel.editDraftsByCommentID["comment-1"] = CommentComposerState( + body: CommentBody(plainText: "Updated") + ) viewModel.applyEditedComment(updated) #expect(viewModel.comments.first?.content == "Updated") diff --git a/docmostlyTests/PageReader/PageReaderPageSwitchHandoffTests.swift b/docmostlyTests/PageReader/PageReaderPageSwitchHandoffTests.swift new file mode 100644 index 0000000..f0be385 --- /dev/null +++ b/docmostlyTests/PageReader/PageReaderPageSwitchHandoffTests.swift @@ -0,0 +1,387 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct PageReaderPageSwitchHandoffTests { + @Test func flushesOutgoingBeforeDetachAndIncomingLoad() async { + var events: [String] = [] + var hasOutgoingChanges = true + + let outcome = await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + hasOutgoingChanges + }, + flushOutgoing: { + events.append("flush") + hasOutgoingChanges = false + return .completed + }, + detachOutgoing: { + events.append("detach") + return true + }, + loadIncoming: { + events.append("load") + } + ) + + #expect(outcome == .completed) + #expect(events == ["flush", "detach", "load"]) + } + + @Test func flushesAgainWhenAnEditArrivesDuringPersistence() async { + var events: [String] = [] + var remainingFlushes = 2 + + let outcome = await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + remainingFlushes > 0 + }, + flushOutgoing: { + events.append("flush-\(remainingFlushes)") + remainingFlushes -= 1 + return .completed + }, + detachOutgoing: { + events.append("detach") + return true + }, + loadIncoming: { + events.append("load") + } + ) + + #expect(outcome == .completed) + #expect(events == ["flush-2", "flush-1", "detach", "load"]) + } + + @Test func cleanOutgoingStillDrainsOutstandingPersistenceBeforeLoad() async { + var events: [String] = [] + + let outcome = await PageReaderPageSwitchHandoff.perform( + requiresInitialOutgoingFlush: true, + hasOutgoingChanges: { + false + }, + flushOutgoing: { + events.append("wait-for-persistence") + return .completed + }, + detachOutgoing: { + events.append("detach") + return true + }, + loadIncoming: { + events.append("load") + } + ) + + #expect(outcome == .completed) + #expect(events == ["wait-for-persistence", "detach", "load"]) + } + + @Test func durablyQueuedDeferredConflictDoesNotBusyLoopBeforeDetach() async { + let editor = NativeRichEditorViewModel(pageID: "page-1") + editor.isDirty = true + editor.hasDurablyPersistedLocalCRDTDraft = true + var flushCount = 0 + var didDetach = false + + let outcome = await PageReaderPageSwitchHandoff.perform( + requiresInitialOutgoingFlush: true, + hasOutgoingChanges: { + editor.hasOutgoingChangesRequiringPersistence + }, + flushOutgoing: { + flushCount += 1 + return .completed + }, + detachOutgoing: { + didDetach = true + return true + }, + loadIncoming: { } + ) + + #expect(outcome == .completed) + #expect(flushCount == 1) + #expect(didDetach) + #expect(editor.isDirty) + } + + @Test func failedFlushKeepsOutgoingAttachedAndDoesNotLoadIncoming() async { + var didDetach = false + var didLoad = false + + let outcome = await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + true + }, + flushOutgoing: { + .failed + }, + detachOutgoing: { + didDetach = true + return true + }, + loadIncoming: { + didLoad = true + } + ) + + #expect(outcome == .outgoingFlushFailed) + #expect(didDetach == false) + #expect(didLoad == false) + } + + @Test func persistenceTimeoutRetriesUntilTheOutgoingDraftIsDurable() async { + var events: [String] = [] + var flushAttempts = 0 + var hasOutgoingChanges = true + + let outcome = await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + hasOutgoingChanges + }, + flushOutgoing: { + flushAttempts += 1 + events.append("wait-\(flushAttempts)") + if flushAttempts == 1 { + return .retry + } + hasOutgoingChanges = false + return .completed + }, + detachOutgoing: { + events.append("detach") + return true + }, + loadIncoming: { + events.append("load") + } + ) + + #expect(outcome == .completed) + #expect(events == ["wait-1", "wait-2", "detach", "load"]) + } + + @Test func rapidAToBToCNavigationLoadsOnlyTheLatestRequestAfterPersistence() async { + let persistence = SharedPageSwitchPersistence() + var events: [String] = [] + let requestB = makeLatestRequestHandoffTask( + destination: "B", + persistence: persistence, + events: { events.append($0) } + ) + await persistence.waitUntilStarted(count: 1) + + requestB.cancel() + let requestC = makeLatestRequestHandoffTask( + destination: "C", + persistence: persistence, + events: { events.append($0) } + ) + await persistence.waitUntilStarted(count: 2) + persistence.complete() + + #expect(await requestB.value == .cancelled) + #expect(await requestC.value == .completed) + #expect(events == ["detach-C", "load-C"]) + } + + @Test func replacedOutgoingEditorDoesNotStartIncomingLoad() async { + var didLoad = false + + let outcome = await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + false + }, + flushOutgoing: { + .completed + }, + detachOutgoing: { + false + }, + loadIncoming: { + didLoad = true + } + ) + + #expect(outcome == .outgoingChanged) + #expect(didLoad == false) + } + + @Test func cancellationDuringFlushDoesNotDetachOrLoad() async { + let flush = ControlledPageSwitchFlush() + var didDetach = false + var didLoad = false + + let task = Task { @MainActor in + await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + true + }, + flushOutgoing: { + await flush.run() ? .completed : .failed + }, + detachOutgoing: { + didDetach = true + return true + }, + loadIncoming: { + didLoad = true + } + ) + } + + await flush.waitUntilStarted() + task.cancel() + flush.complete(with: true) + let outcome = await task.value + + #expect(outcome == .cancelled) + #expect(didDetach == false) + #expect(didLoad == false) + } + + @Test func outgoingEditorLifetimeExtendsThroughSuspendedFlush() async throws { + let flush = ControlledPageSwitchFlush() + var outgoingEditor: PageSwitchLifetimeProbe? = PageSwitchLifetimeProbe() + let weakOutgoingEditor = WeakPageSwitchLifetimeProbe(outgoingEditor) + let task = makeLifetimeHandoffTask( + outgoingEditor: try #require(outgoingEditor), + flush: flush + ) + outgoingEditor = nil + + await flush.waitUntilStarted() + #expect(weakOutgoingEditor.value != nil) + + flush.complete(with: true) + #expect(await task.value == .completed) + } + + private func makeLifetimeHandoffTask( + outgoingEditor: PageSwitchLifetimeProbe, + flush: ControlledPageSwitchFlush + ) -> Task { + Task { @MainActor [outgoingEditor] in + var hasChanges = true + return await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + _ = outgoingEditor.id + return hasChanges + }, + flushOutgoing: { + let didFlush = await flush.run() + hasChanges = false + return didFlush ? .completed : .failed + }, + detachOutgoing: { + _ = outgoingEditor.id + return true + }, + loadIncoming: { + _ = outgoingEditor.id + } + ) + } + } + + private func makeLatestRequestHandoffTask( + destination: String, + persistence: SharedPageSwitchPersistence, + events: @escaping @MainActor (String) -> Void + ) -> Task { + Task { @MainActor in + var hasChanges = true + return await PageReaderPageSwitchHandoff.perform( + hasOutgoingChanges: { + hasChanges + }, + flushOutgoing: { + let result = await persistence.waitForCompletion() + hasChanges = false + return result + }, + detachOutgoing: { + events("detach-\(destination)") + return true + }, + loadIncoming: { + events("load-\(destination)") + } + ) + } + } +} + +@MainActor +private final class ControlledPageSwitchFlush { + private var isStarted = false + private var continuation: CheckedContinuation? + + func run() async -> Bool { + isStarted = true + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func waitUntilStarted() async { + for _ in 0..<100 where isStarted == false { + await Task.yield() + } + } + + func complete(with result: Bool) { + continuation?.resume(returning: result) + continuation = nil + } +} + +@MainActor +private final class SharedPageSwitchPersistence { + private var startCount = 0 + private var isComplete = false + private var continuations: [CheckedContinuation] = [] + + func waitForCompletion() async -> PageReaderPageSwitchHandoff.FlushResult { + startCount += 1 + guard isComplete == false else { return .completed } + + return await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitUntilStarted(count: Int) async { + for _ in 0..<100 where startCount < count { + await Task.yield() + } + } + + func complete() { + isComplete = true + let pendingContinuations = continuations + continuations.removeAll() + for continuation in pendingContinuations { + continuation.resume(returning: .completed) + } + } +} + +@MainActor +private final class PageSwitchLifetimeProbe { + let id = UUID() +} + +@MainActor +private final class WeakPageSwitchLifetimeProbe { + weak var value: PageSwitchLifetimeProbe? + + init(_ value: PageSwitchLifetimeProbe?) { + self.value = value + } +} diff --git a/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift b/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift new file mode 100644 index 0000000..e1687aa --- /dev/null +++ b/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift @@ -0,0 +1,188 @@ +import Foundation +import SwiftData +import Testing +@testable import docmostly + +struct AppStateOfflineQueueSafetyTests { + @Test func cancellationStopsReplayWithoutRequestingRecordRemoval() { + let disposition = OfflineMutationReplayFailureDisposition(error: CancellationError()) + + #expect(disposition == .stopWithoutMutation) + } + + @Test(arguments: [409, 412, 422]) + func permanentClientFailureRetainsAuthoredPageContent(status: Int) { + let payload = OfflineMutationPayload.updatePage( + pageId: "page-1", + title: "Local draft", + document: document(text: "Local body") + ) + let disposition = OfflineMutationReplayFailureDisposition( + error: APIError.httpStatus(status, nil), + payload: payload + ) + + #expect(disposition == .retainForRetry) + } + + @Test func permanentClientFailureRetainsAuthoredCommentAndLabelContent() { + let payloads: [OfflineMutationPayload] = [ + .createComment( + localId: "local-comment-1", + pageId: "page-1", + content: "

Keep this comment

", + plainText: "Keep this comment", + type: .page, + selection: nil, + yjsSelection: nil + ), + .addPageLabels( + pageId: "page-1", + labels: [OfflinePageLabel(pageId: "page-1", name: "Customer research")] + ) + ] + + for payload in payloads { + #expect(OfflineMutationReplayFailureDisposition( + error: APIError.httpStatus(422, nil), + payload: payload + ) == .retainForRetry) + } + } + + @Test func explicitlyDisposableMutationMayDropAfterPermanentClientFailure() { + let disposition = OfflineMutationReplayFailureDisposition( + error: APIError.httpStatus(422, nil), + payload: .watchPage(pageId: "page-1") + ) + + #expect(disposition == .dropRecord) + } + + @Test func transientFailureRetainsTheRecordForRetry() { + let disposition = OfflineMutationReplayFailureDisposition( + error: APIError.connectionFailed("Offline") + ) + + #expect(disposition == .retainForRetry) + } + + @MainActor + @Test func retainedDocumentConflictDoesNotMasqueradeAsAConnectivityFailure() { + let appState = AppState() + + #expect(appState.canQueueOfflineMutation( + after: OfflinePageUpdateReplayConflict(pageID: "page-1") + ) == false) + } + + @MainActor + @Test func conflictResolutionDiscardsOnlyTheCapturedCollaborativeDraft() async throws { + let (appState, scope) = makeConfiguredAppState() + let record = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Local draft", + document: document(text: "Local body"), + baseDocument: document(text: "Remote body") + )) + + let result = try await appState.discardPendingCollaborativeDraft( + pageId: "page-1", + through: record.createdAt.addingTimeInterval(1) + ) + let pending = try await appState.offlineQueueRepository?.pending(scope: scope) + + #expect(result == .acknowledged) + #expect(pending?.isEmpty == true) + } + + @MainActor + @Test func conflictResolutionPreservesANewerCollaborativeDraft() async throws { + let (appState, scope) = makeConfiguredAppState() + let record = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Newer local draft", + document: document(text: "Newer local body"), + baseDocument: document(text: "Remote body") + )) + + let result = try await appState.discardPendingCollaborativeDraft( + pageId: "page-1", + through: record.createdAt.addingTimeInterval(-1) + ) + let pending = try await appState.offlineQueueRepository?.pending(scope: scope) + + #expect(result == .newerPendingUpdatePreserved) + #expect(pending?.map(\.id) == [record.id]) + } + + @MainActor + @Test func keepMineIsDurableBeforeTheEditorPublishesAgain() async throws { + let (appState, scope) = makeConfiguredAppState() + let remoteDocument = document(text: "Remote concurrent edit") + let localDocument = document(text: "Local retained edit") + try appState.cacheRepository?.saveEditablePage( + DocmostEditablePage( + id: "page-1", + slugId: "page-1", + title: "Remote", + content: remoteDocument, + icon: nil, + spaceId: "space-1", + updatedAt: .now, + permissions: nil, + lastUpdatedBy: nil + ), + scope: scope + ) + let oldRecord = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Local", + document: localDocument, + baseDocument: document(text: "Original base") + )) + + let result = try await appState.keepPendingCollaborativeDraft( + pageId: "page-1", + title: "Local", + document: localDocument, + remoteBaseTitle: "Remote title", + remoteBaseDocument: remoteDocument, + replacingThrough: oldRecord.createdAt.addingTimeInterval(1) + ) + + let pending = try await appState.offlineQueueRepository?.pending(scope: scope) + let cached = try appState.cacheRepository?.loadEditablePage(idOrSlugId: "page-1", scope: scope) + #expect(result == .superseded) + #expect(pending?.map(\.payload) == [ + .updatePage( + pageId: "page-1", + title: "Local", + document: localDocument, + baseTitle: "Remote title", + baseDocument: remoteDocument + ) + ]) + #expect(cached?.title == "Local") + #expect(cached?.content == localDocument) + } + + @MainActor + private func makeConfiguredAppState() -> (AppState, CacheScope) { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let appState = AppState() + let scope = CacheScope(serverBaseURL: "https://docs.example.com", userID: "user-1") + appState.configure(modelContext: ModelContext(container), modelContainer: container) + appState.configurePreviewCacheScope(scope) + return (appState, scope) + } + + private func document(text: String) -> ProseMirrorDocument { + ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: text)] + ) + ]) + } +} diff --git a/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift b/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift new file mode 100644 index 0000000..2e47613 --- /dev/null +++ b/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift @@ -0,0 +1,180 @@ +import Foundation +import SwiftData +import Testing +@testable import docmostly + +struct AppStateOfflineReplayRecoveryTests { + @MainActor + @Test func reopeningAPageSurfacesItsQueuedDraftOverTheRemoteCopy() async throws { + let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 200, data: try editablePageEnvelope(title: "Remote title", body: "Remote body")) + ]) + let (appState, scope) = try makeConfiguredAppState(loader: loader) + let localDocument = document(text: "Durable local body") + _ = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Durable local title", + document: localDocument, + baseDocument: document(text: "Original body") + )) + + let loadedPage = try await appState.loadEditablePage(idOrSlugId: "remote-slug") + let pending = try await appState.offlineQueueRepository?.pending(scope: scope) + let requestedPaths = await loader.requestedPaths + + #expect(loadedPage.id == "page-1") + #expect(loadedPage.slugId == "remote-slug") + #expect(loadedPage.title == "Durable local title") + #expect(loadedPage.content == localDocument) + #expect(loadedPage.icon == "📝") + #expect(pending?.count == 1) + #expect(requestedPaths == ["/api/pages/info"]) + } + + @MainActor + @Test func pageConflictDoesNotBlockAnUnrelatedQueuedMutation() async throws { + let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 200, data: try editablePageEnvelope(title: "Remote", body: "Remote change")), + .init(statusCode: 200) + ]) + let (appState, scope) = try makeConfiguredAppState(loader: loader) + _ = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Local", + document: document(text: "Local change"), + baseDocument: document(text: "Original body") + )) + _ = try await appState.queueOfflineMutation(.movePage( + pageId: "page-2", + parentPageId: nil, + position: "a0" + )) + + appState.scheduleOfflineQueueReconciliation() + let replayTask = try #require(appState.offlineReplayTask) + await replayTask.value + + let pendingRecords = try await appState.offlineQueueRepository?.pending(scope: scope) + let pending = try #require(pendingRecords) + let requestedPaths = await loader.requestedPaths + #expect(pending.count == 1) + #expect(pending.first?.kind == .updatePage) + #expect(pending.first?.attemptCount == 1) + #expect(requestedPaths == ["/api/pages/info", "/api/pages/move"]) + } + + @MainActor + @Test func rejectedPageContentIsRetainedWithoutBlockingLaterQueuedWork() async throws { + let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 422), + .init(statusCode: 200) + ]) + let (appState, scope) = try makeConfiguredAppState(loader: loader) + _ = try await appState.queueOfflineMutation(.updatePage( + pageId: "page-1", + title: "Local", + document: document(text: "Never discard this"), + baseDocument: document(text: "Original body") + )) + _ = try await appState.queueOfflineMutation(.movePage( + pageId: "page-2", + parentPageId: nil, + position: "a0" + )) + + appState.scheduleOfflineQueueReconciliation() + let replayTask = try #require(appState.offlineReplayTask) + await replayTask.value + + let pendingRecords = try await appState.offlineQueueRepository?.pending(scope: scope) + let pending = try #require(pendingRecords) + let requestedPaths = await loader.requestedPaths + #expect(pending.count == 1) + #expect(pending.first?.kind == .updatePage) + #expect(pending.first?.attemptCount == 1) + #expect(requestedPaths == ["/api/pages/info", "/api/pages/move"]) + } + + @MainActor + private func makeConfiguredAppState( + loader: OfflineReplayHTTPDataLoader + ) throws -> (AppState, CacheScope) { + let baseURL = try #require(URL(string: "https://docs.example.com")) + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let appState = AppState(apiClient: DocmostAPIClient(baseURL: baseURL, loader: loader)) + let scope = CacheScope(serverBaseURL: baseURL, userID: "user-1") + appState.configure(modelContext: ModelContext(container), modelContainer: container) + appState.configurePreviewCacheScope(scope) + return (appState, scope) + } + + private func editablePageEnvelope(title: String, body: String) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "data": [ + "id": "page-1", + "slugId": "remote-slug", + "title": title, + "content": [ + "type": "doc", + "content": [[ + "type": "paragraph", + "content": [["type": "text", "text": body]] + ]] + ], + "icon": "📝", + "spaceId": "space-1" + ], + "success": true, + "status": 200 + ]) + } + + private func document(text: String) -> ProseMirrorDocument { + ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: text)] + ) + ]) + } +} + +private actor OfflineReplayHTTPDataLoader: HTTPDataLoading { + struct Stub: Sendable { + let statusCode: Int + let data: Data + + init(statusCode: Int, data: Data = Data()) { + self.statusCode = statusCode + self.data = data + } + } + + private var stubs: [Stub] + private(set) var requestedPaths: [String] = [] + + init(stubs: [Stub]) { + self.stubs = stubs + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let url = request.url, stubs.isEmpty == false else { + throw APIError.connectionFailed("Missing offline replay test response.") + } + requestedPaths.append(url.path) + let stub = stubs.removeFirst() + guard let response = HTTPURLResponse( + url: url, + statusCode: stub.statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + ) else { + throw APIError.invalidResponse + } + return (stub.data, response) + } + + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { + throw APIError.connectionFailed("Uploads are not used by offline replay tests.") + } +} diff --git a/docmostlyTests/Persistence/OfflineMutationQueueConflictResolutionTests.swift b/docmostlyTests/Persistence/OfflineMutationQueueConflictResolutionTests.swift new file mode 100644 index 0000000..cea5846 --- /dev/null +++ b/docmostlyTests/Persistence/OfflineMutationQueueConflictResolutionTests.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData +import Testing +@testable import docmostly + +@MainActor +struct OfflineQueueConflictResolutionTests { + private let scope = CacheScope(serverBaseURL: "https://docs.example.com", userID: "user-1") + + @Test func keepMineAtomicallyRebasesPendingDraftToRejectedRemoteDocument() throws { + let queue = makeQueue() + let originalBase = document(text: "Original base") + let remoteDocument = document(text: "Remote concurrent edit") + let localDocument = document(text: "Retained local edit") + let record = try queue.enqueue( + .updatePage( + pageId: "page-1", + title: "Before resolution", + document: localDocument, + baseDocument: originalBase + ), + scope: scope + ) + let resolvedAt = record.createdAt.addingTimeInterval(2) + + let result = try queue.resolvePendingPageUpdateKeepingLocal( + pageId: "page-1", + title: "Keep local", + document: localDocument, + remoteBaseTitle: "Remote title", + remoteBaseDocument: remoteDocument, + replacingThrough: record.createdAt.addingTimeInterval(1), + resolvedAt: resolvedAt, + scope: scope + ) + + let pending = try queue.pending(scope: scope) + #expect(result == .superseded) + #expect(pending.map(\.payload) == [ + .updatePage( + pageId: "page-1", + title: "Keep local", + document: localDocument, + baseTitle: "Remote title", + baseDocument: remoteDocument + ) + ]) + #expect(pending.first?.createdAt == resolvedAt) + } + + @Test func keepMineDoesNotReplaceANewerPendingDraft() throws { + let queue = makeQueue() + let newerDocument = document(text: "Newer draft") + let record = try queue.enqueue( + .updatePage(pageId: "page-1", title: "Newer", document: newerDocument), + scope: scope + ) + + let result = try queue.resolvePendingPageUpdateKeepingLocal( + pageId: "page-1", + title: "Older resolution", + document: document(text: "Older local"), + remoteBaseTitle: "Remote title", + remoteBaseDocument: document(text: "Remote"), + replacingThrough: record.createdAt.addingTimeInterval(-1), + resolvedAt: record.createdAt, + scope: scope + ) + + #expect(result == .newerPendingUpdatePreserved) + #expect(try queue.pending(scope: scope).map(\.payload) == [ + .updatePage(pageId: "page-1", title: "Newer", document: newerDocument) + ]) + } + + private func makeQueue() -> OfflineMutationQueue { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + return OfflineMutationQueue(context: ModelContext(container)) + } + + private func document(text: String) -> ProseMirrorDocument { + ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: text)] + ) + ]) + } +} diff --git a/docmostlyTests/Persistence/OfflineMutationQueueTests.swift b/docmostlyTests/Persistence/OfflineMutationQueueTests.swift index ffff986..2d274c3 100644 --- a/docmostlyTests/Persistence/OfflineMutationQueueTests.swift +++ b/docmostlyTests/Persistence/OfflineMutationQueueTests.swift @@ -62,6 +62,155 @@ struct OfflineMutationQueueTests { )) } + @Test func pageEditsPreserveTheOldestServerBaseline() throws { + let queue = makeQueue() + let serverBaseline = document(text: "Server baseline") + let firstDraft = document(text: "First offline edit") + let latestDraft = document(text: "Latest offline edit") + + _ = try queue.enqueue( + .updatePage( + pageId: "page-1", + title: "First", + document: firstDraft, + baseTitle: "Server title", + baseDocument: serverBaseline + ), + scope: scope + ) + _ = try queue.enqueue( + .updatePage( + pageId: "page-1", + title: "Latest", + document: latestDraft, + baseTitle: "First", + baseDocument: firstDraft + ), + scope: scope + ) + + #expect(try queue.pending(scope: scope).map(\.payload) == [ + .updatePage( + pageId: "page-1", + title: "Latest", + document: latestDraft, + baseTitle: "Server title", + baseDocument: serverBaseline + ) + ]) + } + + @Test func collaborativeSaveSupersedesOnlyAnOlderPendingPageDraft() throws { + let queue = makeQueue() + let oldDocument = document(text: "Old offline body") + let latestDocument = document(text: "Latest collaborative body") + let oldRecord = try queue.enqueue( + .updatePage(pageId: "page-1", title: "Old title", document: oldDocument), + scope: scope + ) + try queue.markFailed(id: oldRecord.id, scope: scope, message: "Offline") + let snapshotCapturedAt = oldRecord.createdAt.addingTimeInterval(1) + + let result = try queue.supersedePendingPageUpdate( + pageId: "page-1", + title: "Latest title", + document: latestDocument, + snapshotCapturedAt: snapshotCapturedAt, + scope: scope + ) + + let pending = try queue.pending(scope: scope) + #expect(result == .superseded) + #expect(pending.count == 1) + #expect(pending.first?.id == oldRecord.id) + #expect(pending.first?.payload == .updatePage( + pageId: "page-1", + title: "Latest title", + document: latestDocument + )) + #expect(pending.first?.createdAt == snapshotCapturedAt) + #expect(pending.first?.attemptCount == 0) + #expect(pending.first?.lastErrorMessage == nil) + } + + @Test func olderCollaborativeSnapshotPreservesANewerPendingPageDraft() throws { + let queue = makeQueue() + let queuedDocument = document(text: "Newer queued body") + let olderDocument = document(text: "Older in-flight body") + let queuedRecord = try queue.enqueue( + .updatePage(pageId: "page-1", title: "Newer title", document: queuedDocument), + scope: scope + ) + + let result = try queue.supersedePendingPageUpdate( + pageId: "page-1", + title: "Older title", + document: olderDocument, + snapshotCapturedAt: queuedRecord.createdAt.addingTimeInterval(-1), + scope: scope + ) + + #expect(result == .newerPendingUpdatePreserved) + #expect(try queue.pending(scope: scope).map(\.payload) == [ + .updatePage(pageId: "page-1", title: "Newer title", document: queuedDocument) + ]) + } + + @Test func unconfirmedCollaborativeSaveCreatesADurableReplay() throws { + let queue = makeQueue() + let document = document(text: "Online body not yet confirmed") + + let result = try queue.supersedePendingPageUpdate( + pageId: "page-1", + title: "Online title", + document: document, + snapshotCapturedAt: .now, + scope: scope + ) + + #expect(result == .enqueued) + #expect(try queue.pending(scope: scope).map(\.payload) == [ + .updatePage(pageId: "page-1", title: "Online title", document: document) + ]) + } + + @Test func serverConfirmationAcknowledgesOnlyOlderPendingDrafts() throws { + let queue = makeQueue() + let record = try queue.enqueue( + .updatePage(pageId: "page-1", title: "Queued", document: document(text: "Queued body")), + scope: scope + ) + + let result = try queue.acknowledgePendingPageUpdate( + pageId: "page-1", + snapshotCapturedAt: record.createdAt.addingTimeInterval(1), + scope: scope + ) + + #expect(result == .acknowledged) + #expect(try queue.pending(scope: scope).isEmpty) + } + + @Test func olderServerConfirmationPreservesNewerPendingDraft() throws { + let queue = makeQueue() + let document = document(text: "Newer queued body") + let record = try queue.enqueue( + .updatePage(pageId: "page-1", title: "Newer", document: document), + scope: scope + ) + + let result = try queue.acknowledgePendingPageUpdate( + pageId: "page-1", + snapshotCapturedAt: record.createdAt.addingTimeInterval(-1), + scope: scope + ) + + #expect(result == .newerPendingUpdatePreserved) + #expect(try queue.pending(scope: scope).map(\.payload) == [ + .updatePage(pageId: "page-1", title: "Newer", document: document) + ]) + } + @Test func engagementTogglesCoalesceByTarget() throws { let queue = makeQueue() @@ -221,7 +370,7 @@ struct OfflineMutationQueueTests { ) let pending = try queue.pending(scope: scope) - guard case .updatePage(_, _, let patchedDocument) = try #require(pending.first?.payload) else { + guard case .updatePage(_, _, let patchedDocument, _, _) = try #require(pending.first?.payload) else { Issue.record("Expected a queued page update") return } @@ -257,4 +406,13 @@ struct OfflineMutationQueueTests { let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) return OfflineMutationQueue(context: ModelContext(container)) } + + private func document(text: String) -> ProseMirrorDocument { + ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: text)] + ) + ]) + } } diff --git a/docmostlyTests/Persistence/OfflinePageUpdateReplayDecisionTests.swift b/docmostlyTests/Persistence/OfflinePageUpdateReplayDecisionTests.swift new file mode 100644 index 0000000..4c61d00 --- /dev/null +++ b/docmostlyTests/Persistence/OfflinePageUpdateReplayDecisionTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import docmostly + +struct OfflinePageUpdateReplayDecisionTests { + @Test func matchingLocalBodyNeedsNoDocumentReplacement() { + let localDocument = document("Local") + + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Local", document: localDocument), + queuedTitle: "Local", + queuedDocument: localDocument, + baseTitle: "Base", + baseDocument: document("Base") + ) == .alreadySynchronized) + } + + @Test func unchangedServerBaselineAllowsReplacement() { + let baseDocument = document("Base") + + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Base", document: baseDocument), + queuedTitle: "Local", + queuedDocument: document("Local"), + baseTitle: "Base", + baseDocument: baseDocument + ) == .replaceDocument(title: "Local")) + } + + @Test func bodyOnlyEditPreservesNewerRemoteTitle() { + let baseDocument = document("Base") + + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Remote title", document: baseDocument), + queuedTitle: "Base title", + queuedDocument: document("Local body"), + baseTitle: "Base title", + baseDocument: baseDocument + ) == .replaceDocument(title: nil)) + } + + @Test func concurrentDivergentTitleEditsRetainConflict() { + let baseDocument = document("Base") + + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Remote title", document: baseDocument), + queuedTitle: "Local title", + queuedDocument: document("Local body"), + baseTitle: "Base title", + baseDocument: baseDocument + ) == .conflict) + } + + @Test func unknownOrChangedServerBodyRetainsConflict() { + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Remote", document: document("Remote")), + queuedTitle: "Local", + queuedDocument: document("Local"), + baseTitle: "Base", + baseDocument: document("Base") + ) == .conflict) + #expect(OfflinePageUpdateReplayDecision.resolve( + serverPage: page(title: "Remote", document: document("Remote")), + queuedTitle: "Local", + queuedDocument: document("Local"), + baseTitle: nil, + baseDocument: nil + ) == .conflict) + } + + @Test func conflictErrorIsAlwaysRetainedForRetry() { + #expect(OfflineMutationReplayFailureDisposition( + error: OfflinePageUpdateReplayConflict(pageID: "page-1") + ) == .retainForRetry) + } + + private func document(_ text: String) -> ProseMirrorDocument { + ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ProseMirrorNode(type: "text", text: text)]) + ]) + } + + private func page(title: String, document: ProseMirrorDocument) -> DocmostEditablePage { + DocmostEditablePage( + id: "page-1", + slugId: "page-1", + title: title, + content: document, + icon: nil, + spaceId: "space-1", + updatedAt: nil, + permissions: nil, + lastUpdatedBy: nil + ) + } +} diff --git a/docmostlyUITests/docmostlyUITests.swift b/docmostlyUITests/docmostlyUITests.swift index 33114df..92283bf 100644 --- a/docmostlyUITests/docmostlyUITests.swift +++ b/docmostlyUITests/docmostlyUITests.swift @@ -33,6 +33,39 @@ final class DocmostlyUITests: XCTestCase { // https://developer.apple.com/documentation/xcuiautomation } + @MainActor + func testSettingsRowsOpenTheirDestinations() throws { + let app = XCUIApplication() + app.launchArguments = ["-MainShellPreview", "-MainShellPreviewSettings"] + app.launch() + + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + + assertSettingsDestinationOpens("account", title: "Account", app: app) + assertSettingsDestinationOpens("workspace", title: "Workspace", app: app) + assertSettingsDestinationOpens("members", title: "Members", app: app) + assertSettingsDestinationOpens("spaces", title: "Spaces", app: app) + assertSettingsDestinationOpens("groups", title: "Groups", app: app) + } + + @MainActor + func testSpaceSettingsEntrySupportsNestedNavigation() throws { + let app = XCUIApplication() + app.launchArguments = ["-MainShellPreview"] + app.launch() + + let spaceActions = app.buttons["Space Actions"] + XCTAssertTrue(spaceActions.waitForExistence(timeout: 5)) + spaceActions.tap() + + let spaceSettings = app.buttons["Space Settings"] + XCTAssertTrue(spaceSettings.waitForExistence(timeout: 5)) + spaceSettings.tap() + + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + assertSettingsDestinationOpens("account", title: "Account", app: app) + } + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. @@ -40,4 +73,15 @@ final class DocmostlyUITests: XCTestCase { XCUIApplication().launch() } } + + @MainActor + private func assertSettingsDestinationOpens(_ identifier: String, title: String, app: XCUIApplication) { + let destination = app.buttons["SettingsDestination.\(identifier)"] + XCTAssertTrue(destination.waitForExistence(timeout: 5)) + destination.tap() + + XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 5)) + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + } }