diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d4e6f3..cce58fa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,11 +79,24 @@ jobs: - name: Test run: | + result_bundle="$RUNNER_TEMP/docmostly-ios-tests.xcresult" + set +e xcodebuild test \ -project docmostly.xcodeproj \ -scheme docmostly \ -destination '${{ steps.ios-simulator.outputs.destination }}' \ + -resultBundlePath "$result_bundle" \ CODE_SIGNING_ALLOWED=NO + test_status=$? + set -e + + if [ "$test_status" -ne 0 ]; then + xcrun xcresulttool get test-results summary \ + --path "$result_bundle" \ + --compact + fi + + exit "$test_status" ipad-build: name: iPad build diff --git a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js index 8fcbef35..aba4abae 100644 --- a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js +++ b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js @@ -237,11 +237,29 @@ class DocmostlyCRDTDocument { return base64FromBytes(Y.encodeStateAsUpdate(this.ydoc, bytesFromBase64(stateVector))); } + validateUpdate(update) { + const validationDocument = new Y.Doc(); + try { + Y.applyUpdate(validationDocument, bytesFromBase64(update)); + return true; + } finally { + validationDocument.destroy(); + } + } + applyRemoteUpdate(update) { Y.applyUpdate(this.ydoc, bytesFromBase64(update), this.remoteOrigin); this.enqueueSnapshot(); } + currentSnapshot() { + return { + title: null, + document: yDocToProsemirrorJSON(this.ydoc, fragmentName), + updatedAt: null + }; + } + integrateLocalChange(change) { this.applyDocument(change.after.title, change.after.document, this.localOrigin); } @@ -324,7 +342,7 @@ class DocmostlyCRDTDocument { enqueueSnapshot() { this.snapshots.push({ - title: this.title, + title: null, document: yDocToProsemirrorJSON(this.ydoc, fragmentName), updatedAt: null }); diff --git a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js index f0da9f60..82debf1d 100644 --- a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js +++ b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js @@ -61,7 +61,7 @@ test("applies remote document update to empty native state", () => { secondDocument.applyRemoteUpdate(update); assert.deepEqual(secondDocument.drainDocumentSnapshots(), [{ - title: "Page", + title: null, document: paragraphDocument("Shared edit"), updatedAt: null }]); @@ -82,7 +82,7 @@ test("does not duplicate content when syncing with server-converted ydoc", () => nativeDocument.applyRemoteUpdate(serverUpdate); assert.deepEqual(nativeDocument.drainDocumentSnapshots(), [{ - title: "Page", + title: null, document: paragraphDocument("Seed"), updatedAt: null }]); @@ -100,12 +100,41 @@ test("restores a cached full document update into an empty native document", () restoredDocument.applyRemoteUpdate(cachedState); assert.deepEqual(restoredDocument.drainDocumentSnapshots(), [{ - title: "Page", + title: null, document: paragraphDocument("Cached offline base"), updatedAt: null }]); }); +test("validates an update without mutating the live document", () => { + const source = serverYDocFromJSON(paragraphDocument("Validated")); + const update = base64FromBytes(Y.encodeStateAsUpdate(source)); + const document = globalThis.docmostlyCRDT.createDocument({ + pageID: "page-1", + title: "Page", + document: paragraphDocument("Stale projection") + }); + + assert.equal(document.validateUpdate(update), true); + assert.deepEqual(document.currentSnapshot(), { + title: null, + document: { type: "doc", content: [] }, + updatedAt: null + }); + assert.deepEqual(document.drainDocumentSnapshots(), []); +}); + +test("rejects corrupt updates during validation", () => { + const document = globalThis.docmostlyCRDT.createDocument({ + pageID: "page-1", + title: "Page", + document: paragraphDocument("Seed") + }); + + assert.throws(() => document.validateUpdate("AQIDBA==")); + assert.deepEqual(document.drainDocumentSnapshots(), []); +}); + test("merges non-overlapping edits from two offline native documents", () => { const baseDocument = paragraphsDocument("First", "Second"); const serverDocument = serverYDocFromJSON(baseDocument); diff --git a/docmostly/App/AppState+Collaboration.swift b/docmostly/App/AppState+Collaboration.swift index db99c4a2..e91c5c52 100644 --- a/docmostly/App/AppState+Collaboration.swift +++ b/docmostly/App/AppState+Collaboration.swift @@ -1,7 +1,7 @@ import Foundation -nonisolated struct NativeEditorPreparedCRDTDocumentEngine: Sendable { - let engine: any NativeEditorCRDTDocumentEngine +nonisolated struct NativeEditorPreparedDocumentSession: Sendable { + let session: DocumentSession let restoredLocalState: Bool } @@ -24,52 +24,32 @@ extension AppState { return try NativeEditorRealtimeEventEndpoint.webSocketURL(serverBaseURL: serverURL) } - func makeCRDTDocumentEngine( + func makeDocumentSession( pageID: String, title: String, document: NativeEditorDocument - ) async throws -> NativeEditorPreparedCRDTDocumentEngine? { - guard let crdtDocumentEngineFactory else { return nil } - - let engine = try await crdtDocumentEngineFactory.makeDocumentEngine( - pageID: pageID, + ) async throws -> NativeEditorPreparedDocumentSession? { + guard let documentSessionRegistry else { return nil } + guard let cacheScope, let workspaceID = currentUser?.workspace.id else { + throw APIError.connectionFailed("Offline collaboration storage is unavailable until you sign in.") + } + let key = DocumentStoreKey( + serverBaseURL: cacheScope.serverBaseURL, + userID: cacheScope.userID, + workspaceID: workspaceID, + pageID: pageID + ) + let session = try await documentSessionRegistry.session( + for: key, title: title, document: document ) - let restoredState = try await loadCachedCRDTStateUpdate(pageID: pageID) - if let restoredState { - try await engine.applyRemoteUpdate(restoredState) - } - return NativeEditorPreparedCRDTDocumentEngine( - engine: engine, - restoredLocalState: restoredState != nil + return NativeEditorPreparedDocumentSession( + session: session, + restoredLocalState: session.restoredLocalState ) } - func persistCRDTStateUpdate(pageID: String, update: Data) async throws { - guard update.isEmpty == false else { - throw APIError.connectionFailed("The collaborative document returned an empty local state.") - } - let scope = try requireCacheScope( - message: "Offline collaboration storage is unavailable until you sign in." - ) - if let cacheWriter { - try await cacheWriter.saveCRDTStateUpdate(pageId: pageID, update: update, scope: scope) - return - } - guard let cacheRepository else { - throw APIError.connectionFailed("Offline collaboration storage is unavailable on this device.") - } - try cacheRepository.saveCRDTStateUpdate(pageId: pageID, update: update, scope: scope) - } - - private func loadCachedCRDTStateUpdate(pageID: String) async throws -> Data? { - guard let cacheScope else { return nil } - if let cacheReader { - return try await cacheReader.loadCRDTStateUpdate(pageId: pageID, scope: cacheScope) - } - return try cacheRepository?.loadCRDTStateUpdate(pageId: pageID, scope: cacheScope) - } } enum NativeEditorCRDTDocumentEngineAttachment { @@ -84,7 +64,7 @@ enum NativeEditorCRDTDocumentEngineAttachment { do { try Task.checkCancellation() - guard let preparedEngine = try await appState.makeCRDTDocumentEngine( + guard let preparedSession = try await appState.makeDocumentSession( pageID: pageID, title: title, document: document @@ -94,11 +74,11 @@ enum NativeEditorCRDTDocumentEngineAttachment { } try Task.checkCancellation() - editorViewModel.configureCRDTDocumentEngine( - preparedEngine.engine, - restoredLocalState: preparedEngine.restoredLocalState + editorViewModel.configureDocumentSession( + preparedSession.session, + restoredLocalState: preparedSession.restoredLocalState ) - if appState.isOffline, preparedEngine.restoredLocalState == false { + if appState.isOffline, preparedSession.restoredLocalState == false { editorViewModel.markCollaborationUnavailable( "Open this page online once before editing it offline so its collaborative state can be cached." ) diff --git a/docmostly/App/AppState+CollaborativeDraftResolution.swift b/docmostly/App/AppState+CollaborativeDraftResolution.swift index 5dd0eea0..184b7ee7 100644 --- a/docmostly/App/AppState+CollaborativeDraftResolution.swift +++ b/docmostly/App/AppState+CollaborativeDraftResolution.swift @@ -29,56 +29,30 @@ extension AppState { } } - // 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.") + let acknowledgement = try await acknowledgeCollaborativeDraft(pageId: pageId, through: cutoff) + guard acknowledgement != .newerPendingUpdatePreserved else { + return .newerPendingUpdatePreserved } - - guard result != .newerPendingUpdatePreserved else { return result } + _ = try await queueOfflineMutation(.updatePageMetadata( + pageId: pageId, + title: title, + baseTitle: remoteBaseTitle + )) _ = try await saveLocalEditableDraft( pageId: pageId, title: title, document: document ) await refreshOfflineMutationCount() - return result + return .superseded } private func acknowledgeCollaborativeDraft( diff --git a/docmostly/App/AppState+EditorPersistence.swift b/docmostly/App/AppState+EditorPersistence.swift index a5404ba5..29f00d79 100644 --- a/docmostly/App/AppState+EditorPersistence.swift +++ b/docmostly/App/AppState+EditorPersistence.swift @@ -15,15 +15,9 @@ extension AppState { baseDocument: ProseMirrorDocument? = nil ) async throws -> DocmostEditablePage { guard let apiClient else { - let page = try await queuePageUpdate( - pageId: pageId, - title: title, - document: document, - baseTitle: baseTitle, - baseDocument: baseDocument + throw APIError.connectionFailed( + "Offline document edits require the local collaborative document store." ) - markPageDiscoveryChanged() - return page } do { @@ -47,41 +41,32 @@ extension AppState { markPageDiscoveryChanged() return page } catch { - guard canQueueOfflineMutation(after: error) else { throw error } - isOffline = true - statusMessage = error.localizedDescription - let page = try await queuePageUpdate( - pageId: pageId, - title: title, - document: document, - baseTitle: baseTitle, - baseDocument: baseDocument - ) - markPageDiscoveryChanged() - return page + throw error } } - // swiftlint:disable:next function_parameter_count func updateCollaborativePageTitle( pageId: String, title: String, documentSnapshot: ProseMirrorDocument, - crdtStateUpdate: Data, - baseTitle: String, - snapshotCapturedAt: Date + baseTitle: String ) async throws -> CollaborativePagePersistenceResult { - guard crdtStateUpdate.isEmpty == false else { - throw APIError.connectionFailed("The collaborative document returned an empty local state.") + let reconciliationMarker: OfflineMutationRecord? + if cacheScope != nil, offlineQueue != nil || offlineQueueRepository != nil { + reconciliationMarker = try await queuePageMetadataUpdate( + pageId: pageId, + title: title, + baseTitle: baseTitle + ) + } else { + reconciliationMarker = nil } guard let apiClient else { return try await persistCRDTPageLocally( pageId: pageId, title: title, document: documentSnapshot, - stateUpdate: crdtStateUpdate, - baseTitle: baseTitle, - snapshotCapturedAt: snapshotCapturedAt + baseTitle: baseTitle ) } @@ -92,49 +77,27 @@ extension AppState { title: title )) } catch { - guard canQueueOfflineMutation(after: error) else { throw error } + guard canQueueOfflineMutation(after: error) else { + if let reconciliationMarker { + try await removeQueuedOfflineMutation(reconciliationMarker) + } + throw error + } isOffline = true statusMessage = error.localizedDescription return try await persistCRDTPageLocally( pageId: pageId, title: title, document: documentSnapshot, - stateUpdate: crdtStateUpdate, - baseTitle: baseTitle, - snapshotCapturedAt: snapshotCapturedAt + baseTitle: baseTitle ) } isOffline = false cacheCollaborativeSnapshot(page: page, document: documentSnapshot) - try await persistCRDTStateUpdate(pageID: pageId, update: crdtStateUpdate) - do { - let serverDocument = page.content ?? ProseMirrorDocument() - if serverDocument.isCollaborationEquivalent(to: documentSnapshot) { - _ = try await acknowledgePendingPageUpdate( - pageId: pageId, - snapshotCapturedAt: snapshotCapturedAt - ) - await refreshOfflineMutationCount() - scheduleOfflineQueueReconciliation() - } else { - _ = try await supersedePendingCRDTPageUpdate( - pageId: pageId, - title: title, - document: documentSnapshot, - stateUpdate: crdtStateUpdate, - baseTitle: baseTitle, - snapshotCapturedAt: snapshotCapturedAt - ) - await refreshOfflineMutationCount() - scheduleOfflineQueueReconciliation() - statusMessage = "Saved locally. Waiting for the collaborative document to sync." - } - } catch { - statusMessage = "Could not make the collaborative document durable: " + error.localizedDescription - throw error - } + await refreshOfflineMutationCount() + scheduleOfflineQueueReconciliation() markPageDiscoveryChanged() return CollaborativePagePersistenceResult( page: page, @@ -143,22 +106,17 @@ extension AppState { ) } - // swiftlint:disable:next function_parameter_count func persistDeferredCollaborativeDraft( pageId: String, title: String, documentSnapshot: ProseMirrorDocument, - baseTitle: String, - baseDocument: ProseMirrorDocument, - snapshotCapturedAt: Date + baseTitle: String ) async throws -> CollaborativePagePersistenceResult { try await persistCollaborativePageLocally( pageId: pageId, title: title, document: documentSnapshot, - baseTitle: baseTitle, - baseDocument: baseDocument, - snapshotCapturedAt: snapshotCapturedAt + baseTitle: baseTitle ) } } @@ -182,127 +140,15 @@ private extension AppState { 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 - ) - } - - // swiftlint:disable:next function_parameter_count - func supersedePendingCRDTPageUpdate( - pageId: String, - title: String, - document: ProseMirrorDocument, - stateUpdate: Data, - baseTitle: String, - 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.supersedePendingCRDTPageUpdate( - pageId: pageId, - title: title, - document: document, - stateUpdate: stateUpdate, - baseTitle: baseTitle, - snapshotCapturedAt: snapshotCapturedAt, - scope: cacheScope - ) - } - guard let offlineQueue else { - throw APIError.connectionFailed("Offline document durability is unavailable on this device.") - } - return try offlineQueue.supersedePendingCRDTPageUpdate( - pageId: pageId, - title: title, - document: document, - stateUpdate: stateUpdate, - baseTitle: baseTitle, - 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 + baseTitle: String ) async throws -> CollaborativePagePersistenceResult { - let supersessionResult = try await supersedePendingPageUpdate( - pageId: pageId, - title: title, - document: document, - baseTitle: baseTitle, - baseDocument: baseDocument, - snapshotCapturedAt: snapshotCapturedAt - ) + try await queuePageMetadataUpdate(pageId: pageId, title: title, baseTitle: baseTitle) await refreshOfflineMutationCount() - guard supersessionResult != .newerPendingUpdatePreserved else { - return CollaborativePagePersistenceResult( - page: nil, - persistedTitle: title, - updatedAt: nil - ) - } - let page = try await saveLocalEditableDraft( pageId: pageId, title: title, @@ -316,31 +162,16 @@ private extension AppState { ) } - // swiftlint:disable:next function_parameter_count func persistCRDTPageLocally( pageId: String, title: String, document: ProseMirrorDocument, - stateUpdate: Data, - baseTitle: String, - snapshotCapturedAt: Date + baseTitle: String ) async throws -> CollaborativePagePersistenceResult { - let supersessionResult = try await supersedePendingCRDTPageUpdate( - pageId: pageId, - title: title, - document: document, - stateUpdate: stateUpdate, - baseTitle: baseTitle, - snapshotCapturedAt: snapshotCapturedAt - ) + try await queuePageMetadataUpdate(pageId: pageId, title: title, baseTitle: baseTitle) 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) - try await persistCRDTStateUpdate(pageID: pageId, update: stateUpdate) markPageDiscoveryChanged() return CollaborativePagePersistenceResult( page: page, @@ -348,4 +179,17 @@ private extension AppState { updatedAt: page.updatedAt ) } + + @discardableResult + func queuePageMetadataUpdate( + pageId: String, + title: String, + baseTitle: String? + ) async throws -> OfflineMutationRecord { + try await queueOfflineMutation(.updatePageMetadata( + pageId: pageId, + title: title, + baseTitle: baseTitle + )) + } } diff --git a/docmostly/App/AppState+Management.swift b/docmostly/App/AppState+Management.swift index 897a10dd..73b1e8c3 100644 --- a/docmostly/App/AppState+Management.swift +++ b/docmostly/App/AppState+Management.swift @@ -19,6 +19,20 @@ extension AppState { return page } + func updatePageIcon(pageId: String, icon: String) async throws -> DocmostEditablePage { + guard let apiClient else { + throw APIError.connectionFailed("Changing a page emoji requires a network connection.") + } + + let page: DocmostEditablePage = try await apiClient.send(.updatePage(pageId: pageId, icon: icon)) + isOffline = false + if let cacheScope { + scheduleCacheWrite(.upsertEditablePageMetadata(page, scope: cacheScope)) + } + markPageDiscoveryChanged() + return page + } + func deletePage(pageId: String, permanentlyDelete: Bool = false) async throws { guard let apiClient else { throw APIError.connectionFailed("Deleting pages requires a network connection.") @@ -239,6 +253,16 @@ extension AppState { return workspace } + func loadWorkspaceEntitlements() async throws -> DocmostWorkspaceEntitlements { + guard let apiClient else { + throw APIError.connectionFailed("Workspace feature availability requires a network connection.") + } + + let entitlements: DocmostWorkspaceEntitlements = try await apiClient.send(.workspaceEntitlements) + isOffline = false + return entitlements + } + func updateWorkspace(_ update: WorkspaceUpdate) async throws -> DocmostWorkspace { guard let apiClient else { throw APIError.connectionFailed("Updating workspace settings requires a network connection.") diff --git a/docmostly/App/AppState+Navigation.swift b/docmostly/App/AppState+Navigation.swift index 56bc673f..95dd4560 100644 --- a/docmostly/App/AppState+Navigation.swift +++ b/docmostly/App/AppState+Navigation.swift @@ -2,25 +2,32 @@ import Foundation extension AppState { func selectSidebarDestination(_ destination: SidebarDestination?) { - selectedSidebarDestination = destination + let resolvedDestination = destination ?? sidebarReturnDestination + sidebarReturnDestination = nil + selectedSidebarDestination = resolvedDestination - if case .space(let spaceID) = destination { + if case .space(let spaceID) = resolvedDestination { selectSpace(id: spaceID) } } - func selectSidebarUtilityDestination(_ destination: SidebarDestination) { + func selectSidebarUtilityDestination( + _ destination: SidebarDestination, + returningTo returnDestination: SidebarDestination? = nil + ) { if case .space(let spaceID) = destination { selectSpace(id: spaceID) return } + sidebarReturnDestination = returnDestination selectedSidebarDestination = destination selectedPageID = nil selectedCommentID = nil } func selectSpace(id spaceID: String, clearsPage: Bool = true) { + sidebarReturnDestination = nil selectedSpaceID = spaceID selectedSidebarDestination = .space(spaceID) rememberSelectedSpace(id: spaceID) @@ -70,6 +77,7 @@ extension AppState { } func resetNavigationSelection() { + sidebarReturnDestination = nil selectedSidebarDestination = nil selectedSpaceID = nil selectedPageID = nil diff --git a/docmostly/App/AppState+OfflineQueue.swift b/docmostly/App/AppState+OfflineQueue.swift index d2372db6..b46ce33e 100644 --- a/docmostly/App/AppState+OfflineQueue.swift +++ b/docmostly/App/AppState+OfflineQueue.swift @@ -102,23 +102,6 @@ extension AppState { ) } - func queuePageUpdate( - pageId: String, - title: String, - document: ProseMirrorDocument, - baseTitle: String? = nil, - baseDocument: ProseMirrorDocument? = nil - ) async throws -> DocmostEditablePage { - 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) @@ -128,6 +111,12 @@ extension AppState { PendingOfflinePageDraft(pageID: pageID, title: title, document: document) case .updatePageCRDT(let pageID, let title, let document, _, _): PendingOfflinePageDraft(pageID: pageID, title: title, document: document) + case .updatePageMetadata(let pageID, let title, _): + PendingOfflinePageDraft( + pageID: pageID, + title: title, + document: page.content ?? ProseMirrorDocument() + ) default: nil } @@ -284,32 +273,45 @@ extension AppState { return status >= 400 && status < 500 && status != 401 && status != 403 && status != 408 && status != 429 } + // swiftlint:disable:next cyclomatic_complexity private func replay( _ record: OfflineMutationRecord, payload: OfflineMutationPayload, using apiClient: DocmostAPIClient ) async throws -> (localID: String, serverID: String)? { switch payload { - case .updatePageCRDT(let pageId, let title, let document, let stateUpdate, let baseTitle): - try await replayCRDTPageUpdate( + case .updatePageMetadata(let pageId, let title, let baseTitle): + try await synchronizeExistingQueuedDocument( + record: record, + pageId: pageId, + using: apiClient + ) + try await replayPageMetadata( pageId: pageId, title: title, - document: document, - stateUpdate: stateUpdate, baseTitle: baseTitle, using: apiClient ) return nil - case .updatePage(let pageId, let title, let document, let baseTitle, let baseDocument): - try await replayPageUpdate( + case .updatePageCRDT(let pageId, let title, let document, _, let baseTitle): + try await synchronizeQueuedDocument( + record: record, pageId: pageId, title: title, document: document, - baseTitle: baseTitle, - baseDocument: baseDocument, - scope: record.scope, using: apiClient ) + try await replayPageMetadata(pageId: pageId, title: title, baseTitle: baseTitle, using: apiClient) + return nil + case .updatePage(let pageId, let title, let document, let baseTitle, _): + try await synchronizeQueuedDocument( + record: record, + pageId: pageId, + title: title, + document: document, + using: apiClient + ) + try await replayPageMetadata(pageId: pageId, title: title, baseTitle: baseTitle, using: apiClient) return nil case .createComment: return try await replayCommentCreation(payload, scope: record.scope, using: apiClient) @@ -343,108 +345,103 @@ extension AppState { } } - // swiftlint:disable:next function_parameter_count - private func replayCRDTPageUpdate( + private func synchronizeQueuedDocument( + record: OfflineMutationRecord, pageId: String, title: String, - document: ProseMirrorDocument, - stateUpdate: Data, - baseTitle: String?, + document: ProseMirrorDocument?, using apiClient: DocmostAPIClient ) async throws { - guard stateUpdate.isEmpty == false else { - throw APIError.connectionFailed("Queued collaborative document state is empty.") - } - guard let crdtDocumentEngineFactory else { - throw APIError.connectionFailed("Native CRDT replay is unavailable on this device.") - } - - let nativeDocument = NativeEditorDocument(proseMirrorDocument: document) - let engine = try await crdtDocumentEngineFactory.makeDocumentEngine( - pageID: pageId, - title: title, - document: nativeDocument + guard let documentSessionRegistry, let workspaceID = currentUser?.workspace.id else { + throw APIError.connectionFailed("The local document session is unavailable until you sign in.") + } + let key = DocumentStoreKey( + serverBaseURL: record.scope.serverBaseURL, + userID: record.scope.userID, + workspaceID: workspaceID, + pageID: pageId ) - try await engine.applyRemoteUpdate(stateUpdate) - let saveResult = try await engine.flushPendingLocalChanges(title: title, document: nativeDocument) - let preparedState: Data - if let stateUpdate = saveResult.documentStateUpdate { - preparedState = stateUpdate + let seedDocument: ProseMirrorDocument + if let document { + seedDocument = document + } else if let cacheReader, + let page = try await cacheReader.loadEditablePage(idOrSlugId: pageId, scope: record.scope) { + seedDocument = page.content ?? ProseMirrorDocument() + } else if let cacheRepository, + let page = try cacheRepository.loadEditablePage(idOrSlugId: pageId, scope: record.scope) { + seedDocument = page.content ?? ProseMirrorDocument() } else { - preparedState = try await engine.encodeDocumentState() + let page: DocmostEditablePage = try await apiClient.send(.pageInfo(pageId: pageId, format: .json)) + seedDocument = page.content ?? ProseMirrorDocument() } - try await persistCRDTStateUpdate(pageID: pageId, update: preparedState) - + let session = try await documentSessionRegistry.session( + for: key, + title: title, + document: NativeEditorDocument(proseMirrorDocument: seedDocument) + ) + guard try await session.hasPendingSynchronization() else { return } let collaborationToken: CollaborationTokenResponse = try await apiClient.send(.collabToken) guard let token = collaborationToken.token else { throw APIError.connectionFailed("Realtime collaboration token is missing.") } try await offlineCRDTSynchronizer.synchronize( pageID: pageId, - engine: engine, - url: collaborationWebSocketURL(), + session: session, + url: try collaborationWebSocketURL(), token: token, user: currentUser?.user ) - let mergedState = try await engine.encodeDocumentState() - try await persistCRDTStateUpdate(pageID: pageId, update: mergedState) + } - let serverPage: DocmostEditablePage = try await apiClient.send(.pageInfo(pageId: pageId, format: .json)) - let persistedTitle: String - switch OfflinePageTitleReplayDecision.resolve( - serverTitle: serverPage.title, - queuedTitle: title, - baseTitle: baseTitle - ) { - case .alreadySynchronized: - persistedTitle = serverPage.title - case .updateTitle: - let updatedPage: DocmostEditablePage = try await apiClient.send(.updatePage(pageId: pageId, title: title)) - persistedTitle = updatedPage.title - case .keepRemoteTitle: - persistedTitle = serverPage.title - case .conflict: - throw OfflinePageUpdateReplayConflict(pageID: pageId) + private func synchronizeExistingQueuedDocument( + record: OfflineMutationRecord, + pageId: String, + using apiClient: DocmostAPIClient + ) async throws { + guard let documentSessionRegistry, let workspaceID = currentUser?.workspace.id else { return } + let key = DocumentStoreKey( + serverBaseURL: record.scope.serverBaseURL, + userID: record.scope.userID, + workspaceID: workspaceID, + pageID: pageId + ) + guard let session = documentSessionRegistry.existingSession(for: key), + try await session.hasPendingSynchronization() else { return } + let collaborationToken: CollaborationTokenResponse = try await apiClient.send(.collabToken) + guard let token = collaborationToken.token else { + throw APIError.connectionFailed("Realtime collaboration token is missing.") } - - _ = try await saveLocalEditableDraft(pageId: pageId, title: persistedTitle, document: document) + try await offlineCRDTSynchronizer.synchronize( + pageID: pageId, + session: session, + url: try collaborationWebSocketURL(), + token: token, + user: currentUser?.user + ) } - // swiftlint:disable:next function_parameter_count - private func replayPageUpdate( + private func replayPageMetadata( pageId: String, title: String, - document: ProseMirrorDocument, baseTitle: String?, - baseDocument: ProseMirrorDocument?, - scope: CacheScope, using apiClient: DocmostAPIClient ) async throws { + guard title != baseTitle else { return } let serverPage: DocmostEditablePage = try await apiClient.send(.pageInfo(pageId: pageId, format: .json)) - let page: DocmostEditablePage - switch OfflinePageUpdateReplayDecision.resolve( - serverPage: serverPage, + switch OfflinePageTitleReplayDecision.resolve( + serverTitle: serverPage.title, queuedTitle: title, - queuedDocument: document, - baseTitle: baseTitle, - baseDocument: baseDocument + baseTitle: baseTitle ) { 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 - )) + return + case .updateTitle: + let _: DocmostEditablePage = try await apiClient.send(.updatePage(pageId: pageId, title: title)) + case .keepRemoteTitle: + return case .conflict: throw OfflinePageUpdateReplayConflict(pageID: pageId) } - scheduleCacheWrite(.saveEditablePage(page, scope: scope)) } private func replayCommentCreation( @@ -577,6 +574,11 @@ extension AppState { try offlineQueue?.remove(id: id, scope: scope) } + func removeQueuedOfflineMutation(_ record: OfflineMutationRecord) async throws { + try await removeOfflineMutation(id: record.id, scope: record.scope) + await refreshOfflineMutationCount() + } + private func removeCoalescedOfflineMutations(for payload: OfflineMutationPayload, scope: CacheScope) async throws { if let offlineQueueRepository { try await offlineQueueRepository.removeCoalescedMutations(for: payload, scope: scope) @@ -675,7 +677,7 @@ extension AppState { spaceWatchStatusByID[spaceId] = true case .unwatchSpace(let spaceId): spaceWatchStatusByID[spaceId] = false - case .updatePage, .updatePageCRDT, .movePage, .movePageToSpace: + case .updatePage, .updatePageCRDT, .updatePageMetadata, .movePage, .movePageToSpace: break } } diff --git a/docmostly/App/AppState.swift b/docmostly/App/AppState.swift index 3a890586..409ecf41 100644 --- a/docmostly/App/AppState.swift +++ b/docmostly/App/AppState.swift @@ -26,6 +26,7 @@ final class AppState { @ObservationIgnored private let cookieJar: SessionCookieJar @ObservationIgnored let crdtDocumentEngineFactory: (any NativeEditorCRDTDocumentEngineFactory)? @ObservationIgnored let offlineCRDTSynchronizer: any NativeEditorOfflineCRDTSynchronizing + @ObservationIgnored var documentSessionRegistry: DocumentSessionRegistry? @ObservationIgnored var cacheRepository: CacheRepository? @ObservationIgnored var cacheReader: CacheReadRepository? @ObservationIgnored var cacheWriter: CacheWriteRepository? @@ -35,6 +36,7 @@ final class AppState { @ObservationIgnored private(set) var apiClient: DocmostAPIClient? @ObservationIgnored private var restoreTask: Task? @ObservationIgnored private var spacesLoadTask: Task? + @ObservationIgnored var sidebarReturnDestination: SidebarDestination? @ObservationIgnored private var pendingCacheWrites: [CacheWriteOperation] = [] @ObservationIgnored private var cacheWriteTask: Task? @ObservationIgnored var offlineReplayTask: Task? @@ -49,6 +51,7 @@ final class AppState { authService: AuthService? = nil, cookieJar: SessionCookieJar = SessionCookieJar(), crdtDocumentEngineFactory: (any NativeEditorCRDTDocumentEngineFactory)? = nil, + documentSessionRegistry: DocumentSessionRegistry? = nil, offlineCRDTSynchronizer: any NativeEditorOfflineCRDTSynchronizing = NativeEditorOfflineCRDTSynchronizer(), apiClient: DocmostAPIClient? = nil ) { @@ -56,6 +59,7 @@ final class AppState { self.cookieJar = cookieJar self.authService = authService ?? AuthService(cookieJar: cookieJar) self.crdtDocumentEngineFactory = crdtDocumentEngineFactory + self.documentSessionRegistry = documentSessionRegistry self.offlineCRDTSynchronizer = offlineCRDTSynchronizer self.apiClient = apiClient serverURLString = self.settingsStore.loadServerURLString() @@ -84,6 +88,14 @@ final class AppState { if offlineQueueRepository == nil, let modelContainer { offlineQueueRepository = OfflineMutationQueueRepository(modelContainer: modelContainer) } + if documentSessionRegistry == nil, + let modelContainer, + let crdtDocumentEngineFactory { + documentSessionRegistry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: modelContainer), + engineFactory: crdtDocumentEngineFactory + ) + } } #if DEBUG @@ -190,6 +202,7 @@ final class AppState { savedServerURLStrings = settingsStore.loadSavedServerURLStrings() cancelScheduledCacheWrites() cancelOfflineReplay() + documentSessionRegistry?.removeAll() apiClient = client currentUser = nil cacheScope = nil @@ -219,6 +232,7 @@ final class AppState { try? await authService.logout(client: apiClient) cancelScheduledCacheWrites() cancelOfflineReplay() + documentSessionRegistry?.removeAll() currentUser = nil cacheScope = nil pendingOfflineMutationCount = 0 diff --git a/docmostly/DocmostlyCRDTRuntime.js b/docmostly/DocmostlyCRDTRuntime.js index e4e4b86f..edd042f7 100644 --- a/docmostly/DocmostlyCRDTRuntime.js +++ b/docmostly/DocmostlyCRDTRuntime.js @@ -13314,10 +13314,26 @@ ${err.toString()}`); encodeStateAsUpdate(stateVector) { return base64FromBytes(encodeStateAsUpdate(this.ydoc, bytesFromBase64(stateVector))); } + validateUpdate(update) { + const validationDocument = new Doc(); + try { + applyUpdate(validationDocument, bytesFromBase64(update)); + return true; + } finally { + validationDocument.destroy(); + } + } applyRemoteUpdate(update) { applyUpdate(this.ydoc, bytesFromBase64(update), this.remoteOrigin); this.enqueueSnapshot(); } + currentSnapshot() { + return { + title: null, + document: yDocToProsemirrorJSON(this.ydoc, fragmentName), + updatedAt: null + }; + } integrateLocalChange(change) { this.applyDocument(change.after.title, change.after.document, this.localOrigin); } @@ -13389,7 +13405,7 @@ ${err.toString()}`); } enqueueSnapshot() { this.snapshots.push({ - title: this.title, + title: null, document: yDocToProsemirrorJSON(this.ydoc, fragmentName), updatedAt: null }); diff --git a/docmostly/Features/Editor/DocumentKernel.swift b/docmostly/Features/Editor/DocumentKernel.swift new file mode 100644 index 00000000..e4dc670c --- /dev/null +++ b/docmostly/Features/Editor/DocumentKernel.swift @@ -0,0 +1,38 @@ +import Foundation + +@MainActor +protocol DocumentKernel: AnyObject, Sendable { + var documentEngine: any NativeEditorCRDTDocumentEngine { get } + func validate(_ update: Data) async throws + func apply(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? + func snapshot() async throws -> NativeEditorCRDTDocumentSnapshot? + func encodeState() async throws -> Data +} + +@MainActor +final class NativeEditorDocumentKernel: DocumentKernel { + let documentEngine: any NativeEditorCRDTDocumentEngine + + init(documentEngine: any NativeEditorCRDTDocumentEngine) { + self.documentEngine = documentEngine + } + + func validate(_ update: Data) async throws { + try await documentEngine.validateUpdate(update) + } + + func apply(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? { + if let javaScriptEngine = documentEngine as? NativeEditorJSCRDTDocumentEngine { + return try javaScriptEngine.applyRemoteUpdateAndCaptureSnapshotSynchronously(update) + } + return try await documentEngine.applyRemoteUpdateCapturingSnapshot(update) + } + + func encodeState() async throws -> Data { + try await documentEngine.encodeDocumentState() + } + + func snapshot() async throws -> NativeEditorCRDTDocumentSnapshot? { + try await documentEngine.currentDocumentSnapshot() + } +} diff --git a/docmostly/Features/Editor/DocumentSession.swift b/docmostly/Features/Editor/DocumentSession.swift new file mode 100644 index 00000000..336d4d08 --- /dev/null +++ b/docmostly/Features/Editor/DocumentSession.swift @@ -0,0 +1,265 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class DocumentSession { + let key: DocumentStoreKey + @ObservationIgnored let kernel: any DocumentKernel + @ObservationIgnored private let localPeer: DocumentLocalPersistencePeer + @ObservationIgnored private let indexer: any DocumentUpdateIndexer + @ObservationIgnored private let compactionPolicy: DocumentCompactionPolicy + @ObservationIgnored private var snapshotContinuations: [ + UUID: AsyncStream.Continuation + ] = [:] + @ObservationIgnored private var isCompacting = false + @ObservationIgnored private var retainedDraftTitle: String? + @ObservationIgnored private var retainedDraft: ProseMirrorDocument? + @ObservationIgnored private(set) var initialSnapshot: NativeEditorCRDTDocumentSnapshot? + @ObservationIgnored private(set) var restoredLocalState = false + @ObservationIgnored private(set) var isOpen = false + @ObservationIgnored private(set) var syncCoordinator: NativeEditorCRDTSyncCoordinator? + + init( + key: DocumentStoreKey, + kernel: any DocumentKernel, + localPeer: DocumentLocalPersistencePeer, + indexer: any DocumentUpdateIndexer, + compactionPolicy: DocumentCompactionPolicy + ) { + self.key = key + self.kernel = kernel + self.localPeer = localPeer + self.indexer = indexer + self.compactionPolicy = compactionPolicy + } + + var documentEngine: any NativeEditorCRDTDocumentEngine { + kernel.documentEngine + } + + func open(title: String) async throws { + guard isOpen == false else { return } + try await migrateLegacyStateIfNeeded() + var storedState = try await localPeer.load(key) + initialSnapshot = try await restore(storedState) + retainedDraftTitle = storedState.retainedDraftTitle + retainedDraft = storedState.retainedDraft + if let retainedDraft { + if storedState.hasLocalState { + initialSnapshot = try await promoteRetainedDraft() ?? initialSnapshot + } else { + initialSnapshot = NativeEditorCRDTDocumentSnapshot( + title: retainedDraftTitle ?? title, + document: NativeEditorDocument(proseMirrorDocument: retainedDraft) + ) + } + storedState = try await localPeer.load(key) + } + restoredLocalState = storedState.hasLocalState + syncCoordinator = makeSyncCoordinator() + isOpen = true + } + + func snapshots() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream(of: NativeEditorCRDTDocumentSnapshot.self) + snapshotContinuations[id] = pair.continuation + if let initialSnapshot { + pair.continuation.yield(initialSnapshot) + } + pair.continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.snapshotContinuations[id] = nil + } + } + return pair.stream + } + + func markRemoteConnected() async { + try? await localPeer.markConnected(key) + } + + func retainDraft(title: String, document: ProseMirrorDocument) async throws { + try await localPeer.retainDraft(document, title: title, key: key) + retainedDraftTitle = title + retainedDraft = document + } + + func clearRetainedDraft() async { + try? await localPeer.clearRetainedDraft(key) + retainedDraftTitle = nil + retainedDraft = nil + } + + func hasPendingSynchronization() async throws -> Bool { + if retainedDraft != nil { + return true + } + return try await localPeer.pendingLocalUpdates(key).isEmpty == false + } +} + +private extension DocumentSession { + func makeSyncCoordinator() -> NativeEditorCRDTSyncCoordinator { + NativeEditorCRDTSyncCoordinator( + documentEngine: kernel.documentEngine, + remoteUpdateHandler: { [weak self] update in + guard let self else { return } + try await self.commitRemoteUpdate(update) + }, + localUpdateCommitter: { [weak self] update in + guard let self else { return false } + return try await self.commitLocalUpdate(update) + }, + pendingLocalUpdatesProvider: { [weak self] in + guard let self else { return [] } + return try await self.pendingLocalUpdatePayloads() + }, + localUpdateDidAcknowledge: { [weak self] update in + guard let self else { return } + try await self.localPeer.markPushed(update, key: self.key) + } + ) + } + + func commitLocalUpdate(_ update: Data, publishProjection: Bool = true) async throws -> Bool { + let committed = try await localPeer.append(update, origin: .local, key: key) + guard committed.wasInserted else { return false } + try await localPeer.clearRetainedDraft(key) + retainedDraftTitle = nil + retainedDraft = nil + if publishProjection, let snapshot = try await kernel.snapshot() { + publish(snapshot) + } + await indexer.documentUpdateCommitted(committed) + try await compactIfNeeded() + return true + } + + func commitRemoteUpdate(_ update: Data) async throws { + try await kernel.validate(update) + let committed = try await localPeer.append(update, origin: .remote, key: key) + guard committed.wasInserted else { return } + var snapshot = try await kernel.apply(update) + await indexer.documentUpdateCommitted(committed) + if retainedDraft != nil { + snapshot = try await promoteRetainedDraft() ?? snapshot + } + if let snapshot { + publish(snapshot) + } + try await compactIfNeeded() + } + + func pendingLocalUpdatePayloads() async throws -> [Data] { + try await localPeer.pendingLocalUpdates(key).map(\.payload) + } + + func compactIfNeeded() async throws { + guard isCompacting == false else { return } + let metrics = try await localPeer.metrics(key) + guard compactionPolicy.shouldCompact(metrics) else { return } + + isCompacting = true + defer { isCompacting = false } + let snapshot = try await kernel.encodeState() + try await kernel.validate(snapshot) + try await localPeer.compact(key, snapshot: snapshot, through: metrics.lastCommittedSequence) + } + + func restore(_ state: DocumentStoredState) async throws -> NativeEditorCRDTDocumentSnapshot? { + var latestSnapshot: NativeEditorCRDTDocumentSnapshot? + var restoredThroughSequence: Int64 = 0 + if let snapshot = state.snapshot { + do { + try await kernel.validate(snapshot) + latestSnapshot = try await kernel.apply(snapshot) + restoredThroughSequence = state.snapshotSequence + } catch { + if let recoverySnapshot = state.recoverySnapshot { + try await kernel.validate(recoverySnapshot) + latestSnapshot = try await kernel.apply(recoverySnapshot) + restoredThroughSequence = state.recoverySnapshotSequence + } + } + } else if let recoverySnapshot = state.recoverySnapshot { + try await kernel.validate(recoverySnapshot) + latestSnapshot = try await kernel.apply(recoverySnapshot) + restoredThroughSequence = state.recoverySnapshotSequence + } + + for update in state.updates + .filter({ $0.sequence > restoredThroughSequence }) + .sorted(by: { $0.sequence < $1.sequence }) { + try await kernel.validate(update.payload) + latestSnapshot = try await kernel.apply(update.payload) ?? latestSnapshot + } + return latestSnapshot + } + + func migrateLegacyStateIfNeeded() async throws { + guard let candidate = try await localPeer.legacyMigrationCandidate(key) else { return } + let snapshot: Data? + let pendingLocalUpdate: Data? + let retainedDraftTitle: String? + let retainedDraft: ProseMirrorDocument? + switch candidate.seed { + case .queuedCRDT(let stateUpdate): + snapshot = nil + pendingLocalUpdate = stateUpdate + retainedDraftTitle = nil + retainedDraft = nil + case .queuedProseMirror(let draftTitle, let proseMirrorDocument, let cachedStateUpdate): + snapshot = cachedStateUpdate + pendingLocalUpdate = nil + retainedDraftTitle = draftTitle + retainedDraft = proseMirrorDocument + case .cachedCRDT(let stateUpdate): + snapshot = stateUpdate + pendingLocalUpdate = nil + retainedDraftTitle = nil + retainedDraft = nil + case .none: + snapshot = nil + pendingLocalUpdate = nil + retainedDraftTitle = nil + retainedDraft = nil + } + + _ = try await localPeer.commitLegacyMigration( + key, + migration: DocumentLegacyMigrationCommit( + snapshot: snapshot, + pendingLocalUpdate: pendingLocalUpdate, + retainedDraftTitle: retainedDraftTitle, + retainedDraft: retainedDraft, + metadataTitle: candidate.metadataTitle, + metadataBaseTitle: candidate.metadataBaseTitle + ) + ) + } + + func promoteRetainedDraft() async throws -> NativeEditorCRDTDocumentSnapshot? { + guard let retainedDraft else { return nil } + let title = retainedDraftTitle ?? initialSnapshot?.title ?? "" + let committed = try await kernel.documentEngine.flushPendingLocalChangesForCommit( + title: title, + document: NativeEditorDocument(proseMirrorDocument: retainedDraft) + ) + for update in committed.updates { + _ = try await commitLocalUpdate(update, publishProjection: false) + } + try await localPeer.clearRetainedDraft(key) + retainedDraftTitle = nil + self.retainedDraft = nil + return try await kernel.snapshot() + } + + func publish(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + initialSnapshot = snapshot + for continuation in snapshotContinuations.values { + continuation.yield(snapshot) + } + } +} diff --git a/docmostly/Features/Editor/DocumentSessionRegistry.swift b/docmostly/Features/Editor/DocumentSessionRegistry.swift new file mode 100644 index 00000000..30022ccc --- /dev/null +++ b/docmostly/Features/Editor/DocumentSessionRegistry.swift @@ -0,0 +1,93 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class DocumentSessionRegistry { + @ObservationIgnored private let localPeer: DocumentLocalPersistencePeer + @ObservationIgnored private let engineFactory: any NativeEditorCRDTDocumentEngineFactory + @ObservationIgnored private let indexer: any DocumentUpdateIndexer + @ObservationIgnored private let compactionPolicy: DocumentCompactionPolicy + @ObservationIgnored private var sessions: [DocumentStoreKey: DocumentSession] = [:] + @ObservationIgnored private var creationTasks: [DocumentStoreKey: Task] = [:] + @ObservationIgnored private var generation: UInt = 0 + + init( + localPeer: DocumentLocalPersistencePeer, + engineFactory: any NativeEditorCRDTDocumentEngineFactory, + indexer: any DocumentUpdateIndexer = NoopDocumentUpdateIndexer(), + compactionPolicy: DocumentCompactionPolicy = .production + ) { + self.localPeer = localPeer + self.engineFactory = engineFactory + self.indexer = indexer + self.compactionPolicy = compactionPolicy + } + + func session( + for key: DocumentStoreKey, + title: String, + document: NativeEditorDocument + ) async throws -> DocumentSession { + if let session = sessions[key] { + return session + } + if let creationTask = creationTasks[key] { + let creationGeneration = generation + let session = try await creationTask.value + try Task.checkCancellation() + guard generation == creationGeneration else { + throw CancellationError() + } + return session + } + + let creationGeneration = generation + let creationTask = Task { @MainActor [localPeer, engineFactory, indexer, compactionPolicy] in + let engine = try await engineFactory.makeDocumentEngine( + pageID: key.pageID, + title: title, + document: document + ) + let session = DocumentSession( + key: key, + kernel: NativeEditorDocumentKernel(documentEngine: engine), + localPeer: localPeer, + indexer: indexer, + compactionPolicy: compactionPolicy + ) + try await session.open(title: title) + return session + } + creationTasks[key] = creationTask + + do { + let session = try await creationTask.value + guard generation == creationGeneration else { + throw CancellationError() + } + sessions[key] = session + creationTasks[key] = nil + try Task.checkCancellation() + return session + } catch { + if generation == creationGeneration { + creationTasks[key] = nil + } + throw error + } + } + + func existingSession(for key: DocumentStoreKey) -> DocumentSession? { + sessions[key] + } + + func removeAll() { + generation &+= 1 + for task in creationTasks.values { + task.cancel() + } + creationTasks.removeAll() + sessions.removeAll() + } +} diff --git a/docmostly/Features/Editor/NativeEditorBlockKind.swift b/docmostly/Features/Editor/NativeEditorBlockKind.swift index 9cb2f2a2..9949391c 100644 --- a/docmostly/Features/Editor/NativeEditorBlockKind.swift +++ b/docmostly/Features/Editor/NativeEditorBlockKind.swift @@ -43,15 +43,18 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { var editorFont: Font { switch self { case .heading(let level): - level == 1 ? .title : .title2 + if level == 1 { + return .title.bold() + } + return .title2.bold() case .codeBlock: - .body.monospaced() + return .body.monospaced() case .paragraph, .bulletListItem, .orderedListItem, .taskListItem, .blockquote: - .body + return .body case .table, .image, .video, .audio, .pdf, .attachment, .callout, .details, .pageBreak, .divider, .columns, .subpages, .transclusionSource, .transclusionReference, .base, .embed, .drawio, .excalidraw, .mathBlock, .unsupported: - .body + return .body } } diff --git a/docmostly/Features/Editor/NativeEditorBodyView.swift b/docmostly/Features/Editor/NativeEditorBodyView.swift index 1a99ee16..b00455a9 100644 --- a/docmostly/Features/Editor/NativeEditorBodyView.swift +++ b/docmostly/Features/Editor/NativeEditorBodyView.swift @@ -1,6 +1,7 @@ import SwiftUI struct NativeEditorBodyView: View { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @Bindable var viewModel: NativeRichEditorViewModel let focusedField: FocusState.Binding var isAuthoringEnabled = true @@ -9,6 +10,7 @@ struct NativeEditorBodyView: View { var applyCommand: ((NativeEditorCommand) -> Void)? var applyPendingRemoteUpdate: (() -> Void)? var keepPendingLocalUpdate: (() -> Void)? + var pickPageEmoji: (() -> Void)? var slashCommandFilter: (NativeEditorCommand) -> Bool = { _ in true } var showsTitle = true var showsCollaborationStatus = true @@ -18,15 +20,24 @@ struct NativeEditorBodyView: View { var body: some View { LazyVStack(alignment: .leading, spacing: 6) { 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") + HStack(alignment: .firstTextBaseline) { + if isEditingTitle, let pickPageEmoji { + NativeEditorPageTitleIconButton(icon: viewModel.icon, action: pickPageEmoji) + .disabled(authoringIsAvailable == false) + .transition(NativeEditorPageTitleIconTransition()) + } + + 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") + } + .animation(titleEditingAnimation, value: isEditingTitle) if let creatorName = viewModel.creator?.name, creatorName.isEmpty == false { NativeEditorBylineView(authorName: creatorName) @@ -88,7 +99,14 @@ struct NativeEditorBodyView: View { }, splitBlock: { characterRange in guard authoringIsAvailable else { return false } - return viewModel.splitBlock(block.id, replacing: characterRange) != nil + guard let continuationBlockID = viewModel.splitBlock( + block.id, + replacing: characterRange + ) else { + return false + } + requestBlockFocus(continuationBlockID) + return true }, insertHardBreak: { characterRange in guard authoringIsAvailable else { return false } @@ -96,7 +114,11 @@ struct NativeEditorBodyView: View { }, mergeBlockBackward: { guard authoringIsAvailable else { return false } - return viewModel.mergeBlockBackward(block.id) + guard viewModel.mergeBlockBackward(block.id) else { return false } + if let destinationBlockID = viewModel.activeBlockID { + requestBlockFocus(destinationBlockID) + } + return true }, blockChanged: { guard authoringIsAvailable else { return } @@ -149,6 +171,14 @@ struct NativeEditorBodyView: View { isAuthoringEnabled && viewModel.canEdit && viewModel.isResolvingConflict == false } + private var isEditingTitle: Bool { + focusedField.wrappedValue == .title + } + + private var titleEditingAnimation: Animation? { + accessibilityReduceMotion ? nil : .easeInOut(duration: 0.22) + } + private var activePresenceProjection: NativeEditorRemotePresenceProjection { presenceProjection ?? viewModel.remotePresenceProjection } @@ -173,6 +203,22 @@ struct NativeEditorBodyView: View { } } + private func requestBlockFocus(_ blockID: UUID) { + focusedField.wrappedValue = .block(blockID) + + Task { @MainActor in + await Task.yield() + guard + authoringIsAvailable, + viewModel.document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) + else { + return + } + viewModel.focus(blockID: blockID) + focusedField.wrappedValue = .block(blockID) + } + } + private var tableEditingActions: NativeEditorTableEditingActions { NativeEditorTableEditingActions( updateCell: { blockID, rowIndex, columnIndex, text in diff --git a/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift b/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift index f70d7468..fb53d641 100644 --- a/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift +++ b/docmostly/Features/Editor/NativeEditorCRDTDocumentEngine.swift @@ -29,14 +29,22 @@ nonisolated struct NativeEditorCRDTLocalChange: Sendable { let after: NativeEditorHistorySnapshot } +nonisolated struct NativeEditorCRDTCommittedSave: Sendable { + let result: NativeEditorCRDTSaveResult + let updates: [Data] +} + protocol NativeEditorCRDTDocumentEngine: AnyObject, Sendable { var requiresInitialRemoteSnapshot: Bool { get } func encodeStateVector() async throws -> Data func encodeStateAsUpdate(for stateVector: Data) async throws -> Data func encodeDocumentState() async throws -> Data + func validateUpdate(_ update: Data) async throws func applyRemoteUpdate(_ update: Data) async throws func applyRemoteUpdateCapturingSnapshot(_ update: Data) async throws -> NativeEditorCRDTDocumentSnapshot? + func currentDocumentSnapshot() async throws -> NativeEditorCRDTDocumentSnapshot? func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws + func integrateLocalChangeForCommit(_ change: NativeEditorCRDTLocalChange) async throws -> [Data] func resolveRemoteCursor(_ cursor: NativeEditorRemoteCursor) async throws -> NativeEditorResolvedRemoteCursor? func encodeLocalAwarenessCursor(for selection: NativeEditorLocalTextSelection) async throws -> NativeEditorAwarenessCursor? @@ -44,6 +52,8 @@ protocol NativeEditorCRDTDocumentEngine: AnyObject, Sendable { -> NativeEditorYjsSelection? func flushPendingLocalChanges(title: String, document: NativeEditorDocument) async throws -> NativeEditorCRDTSaveResult + func flushPendingLocalChangesForCommit(title: String, document: NativeEditorDocument) async throws + -> NativeEditorCRDTCommittedSave func localUpdates() async -> AsyncStream func documentSnapshots() async -> AsyncStream } @@ -67,6 +77,16 @@ extension NativeEditorCRDTDocumentEngine { return nil } + func currentDocumentSnapshot() async throws -> NativeEditorCRDTDocumentSnapshot? { + nil + } + + func validateUpdate(_ update: Data) async throws { + guard update.isEmpty == false else { + throw NativeEditorJSCRDTEngineError.invalidDataResult("validateUpdate") + } + } + func encodeDocumentState() async throws -> Data { // Yjs update-v1 encodes an empty state vector as a single zero byte. try await encodeStateAsUpdate(for: Data([0])) @@ -74,6 +94,21 @@ extension NativeEditorCRDTDocumentEngine { func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { } + func integrateLocalChangeForCommit(_ change: NativeEditorCRDTLocalChange) async throws -> [Data] { + try await integrateLocalChange(change) + return [] + } + + func flushPendingLocalChangesForCommit( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTCommittedSave { + NativeEditorCRDTCommittedSave( + result: try await flushPendingLocalChanges(title: title, document: document), + updates: [] + ) + } + func resolveRemoteCursor(_ cursor: NativeEditorRemoteCursor) async throws -> NativeEditorResolvedRemoteCursor? { nil } diff --git a/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift b/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift index a78acb75..fe8280c7 100644 --- a/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift +++ b/docmostly/Features/Editor/NativeEditorCRDTSyncCoordinator.swift @@ -10,15 +10,33 @@ actor NativeEditorCRDTSyncCoordinator { private let documentEngine: any NativeEditorCRDTDocumentEngine private let remoteUpdateHandler: (@Sendable (Data) async throws -> Void)? + private let localUpdateCommitter: @Sendable (Data) async throws -> Bool + private let pendingLocalUpdatesProvider: @Sendable () async throws -> [Data] + private let localUpdateDidAcknowledge: @Sendable (Data) async throws -> Void + private var committedLocalUpdateContinuations: [UUID: AsyncStream.Continuation] = [:] + private var rawLocalUpdateTask: Task? private var pendingLocalEchoCounts: [Data: Int] = [:] private var remoteSyncSessionBytes = 0 init( documentEngine: any NativeEditorCRDTDocumentEngine, - remoteUpdateHandler: (@Sendable (Data) async throws -> Void)? = nil + remoteUpdateHandler: (@Sendable (Data) async throws -> Void)? = nil, + localUpdateCommitter: @escaping @Sendable (Data) async throws -> Bool = { _ in true }, + pendingLocalUpdatesProvider: @escaping @Sendable () async throws -> [Data] = { [] }, + localUpdateDidAcknowledge: @escaping @Sendable (Data) async throws -> Void = { _ in } ) { self.documentEngine = documentEngine self.remoteUpdateHandler = remoteUpdateHandler + self.localUpdateCommitter = localUpdateCommitter + self.pendingLocalUpdatesProvider = pendingLocalUpdatesProvider + self.localUpdateDidAcknowledge = localUpdateDidAcknowledge + } + + deinit { + rawLocalUpdateTask?.cancel() + for continuation in committedLocalUpdateContinuations.values { + continuation.finish() + } } func makeInitialSyncMessage() async throws -> NativeEditorYjsSyncMessage { @@ -49,24 +67,59 @@ actor NativeEditorCRDTSyncCoordinator { return .update(update) } + func pendingLocalUpdates() async throws -> [Data] { + try await pendingLocalUpdatesProvider() + } + + func recordLocalUpdateAcknowledged(_ update: Data) async throws { + try await localUpdateDidAcknowledge(update) + } + func encodeLocalAwarenessCursor(for selection: NativeEditorLocalTextSelection) async throws -> NativeEditorAwarenessCursor? { try await documentEngine.encodeLocalAwarenessCursor(for: selection) } func localUpdates() async -> AsyncStream { - await documentEngine.localUpdates() + localUpdateSubscription().updates + } + + func localUpdateSubscription() -> ( + updates: AsyncStream, + cancel: @Sendable () async -> Void + ) { + startRawLocalUpdateForwardingIfNeeded() + let id = UUID() + let pair = AsyncStream.makeStream(of: Data.self) + let cancel: @Sendable () async -> Void = { [weak self] in + await self?.removeCommittedLocalUpdateContinuation(id) + } + committedLocalUpdateContinuations[id] = pair.continuation + pair.continuation.onTermination = { _ in + Task { await cancel() } + } + return (pair.stream, cancel) } func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { - try await documentEngine.integrateLocalChange(change) + let updates = try await documentEngine.integrateLocalChangeForCommit(change) + for update in updates { + try await commitAndPublishLocalUpdate(update) + } } func flushPendingLocalChanges( title: String, document: NativeEditorDocument ) async throws -> NativeEditorCRDTSaveResult { - try await documentEngine.flushPendingLocalChanges(title: title, document: document) + let committed = try await documentEngine.flushPendingLocalChangesForCommit( + title: title, + document: document + ) + for update in committed.updates { + try await commitAndPublishLocalUpdate(update) + } + return committed.result } private func consumeLocalEcho(for update: Data) -> Bool { @@ -81,6 +134,29 @@ actor NativeEditorCRDTSyncCoordinator { return true } + private func commitAndPublishLocalUpdate(_ update: Data) async throws { + if try await localUpdateCommitter(update) { + for continuation in committedLocalUpdateContinuations.values { + continuation.yield(update) + } + } + } + + private func removeCommittedLocalUpdateContinuation(_ id: UUID) { + committedLocalUpdateContinuations.removeValue(forKey: id)?.finish() + } + + private func startRawLocalUpdateForwardingIfNeeded() { + guard rawLocalUpdateTask == nil else { return } + rawLocalUpdateTask = Task { [weak self, documentEngine] in + let updates = await documentEngine.localUpdates() + for await update in updates { + guard Task.isCancelled == false else { return } + try? await self?.commitAndPublishLocalUpdate(update) + } + } + } + private func validateRemotePayload(_ data: Data) throws { guard data.count <= Self.maximumRemoteSyncPayloadBytes else { throw NativeEditorCRDTSyncCoordinatorError.remotePayloadTooLarge diff --git a/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift b/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift index 93ed2324..c8c2d4ee 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationPresenceClient.swift @@ -161,6 +161,9 @@ actor NativeEditorCollaborationPresenceClient { case .stateless(let event): yieldStatelessEvent(event, continuation: continuation) case .syncStatus(let isSynced): + if isSynced { + try? await context.syncDriver?.didReceiveSyncAcknowledgement() + } continuation.yield(.syncStatus(isSynced)) case .sync(let syncMessage): try await sendCRDTSyncReply( @@ -196,18 +199,43 @@ private extension NativeEditorCollaborationPresenceClient { continuation: AsyncThrowingStream.Continuation ) async throws { authenticatedScope = scope - try await sendInitialCRDTSync(for: scope, using: context.syncDriver) - configureLocalDocumentUpdates(for: scope, context: context) + let localUpdateSubscription: ( + updates: AsyncStream, + cancel: @Sendable () async -> Void + )? = if context.allowsLocalDocumentUpdates(for: scope), + let syncDriver = context.syncDriver { + await syncDriver.localUpdateSubscription() + } else { + nil + } + do { + try await sendInitialCRDTSync( + for: scope, + includePendingLocalUpdates: context.allowsLocalDocumentUpdates(for: scope), + using: context.syncDriver + ) + } catch { + if let localUpdateSubscription { + await localUpdateSubscription.cancel() + } + throw error + } + configureLocalDocumentUpdates( + for: scope, + context: context, + updates: localUpdateSubscription?.updates + ) try await configureLocalAwarenessUpdates(for: scope, context: context) continuation.yield(.authenticated(scope)) } func configureLocalDocumentUpdates( for scope: NativeEditorCollaborationScope, - context: NativeEditorCollaborationSessionContext + context: NativeEditorCollaborationSessionContext, + updates: AsyncStream? ) { - if context.allowsLocalDocumentUpdates(for: scope) { - startLocalUpdateSender(using: context.syncDriver) + if context.allowsLocalDocumentUpdates(for: scope), let updates { + startLocalUpdateSender(using: context.syncDriver, updates: updates) } else { stopLocalUpdateSender() } @@ -238,12 +266,16 @@ private extension NativeEditorCollaborationPresenceClient { func sendInitialCRDTSync( for scope: NativeEditorCollaborationScope, + includePendingLocalUpdates: Bool, using syncDriver: NativeEditorCollaborationSyncDriver? ) async throws { guard scope.allowsInitialDocumentSync else { return } guard let syncDriver else { return } - let frames = try await syncDriver.outboundFramesAfterAuthentication() + let frames = try await syncDriver.outboundFramesAfterAuthentication( + includePendingLocalUpdates: includePendingLocalUpdates + ) try await send(frames) + try await syncDriver.didSendOutboundFramesAfterAuthentication() } func sendCRDTSyncReply( @@ -277,17 +309,20 @@ private extension NativeEditorCollaborationPresenceClient { awarenessPruneTask = nil } - func startLocalUpdateSender(using syncDriver: NativeEditorCollaborationSyncDriver?) { + func startLocalUpdateSender( + using syncDriver: NativeEditorCollaborationSyncDriver?, + updates: AsyncStream + ) { stopLocalUpdateSender() guard let syncDriver else { return } - localUpdateTask = Task { [weak self, syncDriver] in - let updates = await syncDriver.localUpdates() - + localUpdateTask = Task { [weak self, syncDriver, updates] in for await update in updates { guard Task.isCancelled == false else { return } - let frame = await syncDriver.outboundFrame(forLocalUpdate: update) + guard let frame = await syncDriver.outboundFrameIfNeeded(forLocalUpdate: update) else { + continue + } do { try await self?.send(frame) diff --git a/docmostly/Features/Editor/NativeEditorCollaborationSyncDriver.swift b/docmostly/Features/Editor/NativeEditorCollaborationSyncDriver.swift index 0551237b..2d371d82 100644 --- a/docmostly/Features/Editor/NativeEditorCollaborationSyncDriver.swift +++ b/docmostly/Features/Editor/NativeEditorCollaborationSyncDriver.swift @@ -3,14 +3,41 @@ import Foundation actor NativeEditorCollaborationSyncDriver { private let documentName: String private let coordinator: NativeEditorCRDTSyncCoordinator + private var initialPendingUpdates: [Data] = [] + private var initialUpdatesAwaitingAcknowledgement: [Data] = [] + private var initialLiveUpdateSuppressionCounts: [Data: Int] = [:] init(documentName: String, coordinator: NativeEditorCRDTSyncCoordinator) { self.documentName = documentName self.coordinator = coordinator } - func outboundFramesAfterAuthentication() async throws -> [Data] { - [try await frame(for: coordinator.makeInitialSyncMessage())] + func outboundFramesAfterAuthentication(includePendingLocalUpdates: Bool = true) async throws -> [Data] { + initialUpdatesAwaitingAcknowledgement = [] + initialLiveUpdateSuppressionCounts = [:] + let initialMessage = try await coordinator.makeInitialSyncMessage() + let initial = frame(for: initialMessage) + guard includePendingLocalUpdates else { + initialPendingUpdates = [] + return [initial] + } + initialPendingUpdates = try await coordinator.pendingLocalUpdates() + for update in initialPendingUpdates { + initialLiveUpdateSuppressionCounts[update, default: 0] += 1 + } + var pending: [Data] = [] + pending.reserveCapacity(initialPendingUpdates.count) + for update in initialPendingUpdates { + let message = await coordinator.broadcastLocalUpdate(update) + pending.append(frame(for: message)) + } + return [initial] + pending + } + + func didSendOutboundFramesAfterAuthentication() async throws { + let updates = initialPendingUpdates + initialPendingUpdates = [] + initialUpdatesAwaitingAcknowledgement.append(contentsOf: updates) } func outboundFrames(for message: NativeEditorYjsSyncMessage) async throws -> [Data] { @@ -23,6 +50,32 @@ actor NativeEditorCollaborationSyncDriver { return frame(for: message) } + func outboundFrameIfNeeded(forLocalUpdate update: Data) async -> Data? { + if let count = initialLiveUpdateSuppressionCounts[update] { + if count == 1 { + initialLiveUpdateSuppressionCounts[update] = nil + } else { + initialLiveUpdateSuppressionCounts[update] = count - 1 + } + return nil + } + return await outboundFrame(forLocalUpdate: update) + } + + func didReceiveSyncAcknowledgement() async throws { + while let update = initialUpdatesAwaitingAcknowledgement.first { + try await coordinator.recordLocalUpdateAcknowledged(update) + initialUpdatesAwaitingAcknowledgement.removeFirst() + } + } + + func localUpdateSubscription() async -> ( + updates: AsyncStream, + cancel: @Sendable () async -> Void + ) { + await coordinator.localUpdateSubscription() + } + func localUpdates() async -> AsyncStream { await coordinator.localUpdates() } diff --git a/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift b/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift index 0b7e6730..929ddbde 100644 --- a/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift +++ b/docmostly/Features/Editor/NativeEditorJavaScriptCRDTDocumentEngine.swift @@ -114,6 +114,14 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { ) } + func validateUpdate(_ update: Data) async throws { + try Self.validateRemotePayload(update) + let result = try callRequired("validateUpdate", arguments: [update.base64EncodedString()]) + guard result.toBool() else { + throw NativeEditorJSCRDTEngineError.invalidDataResult("validateUpdate") + } + } + func applyRemoteUpdate(_ update: Data) async throws { for snapshot in try applyRemoteUpdateCapturingSnapshots(update) { snapshotContinuation.yield(snapshot) @@ -124,6 +132,15 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { try applyRemoteUpdateAndCaptureSnapshotSynchronously(update) } + func currentDocumentSnapshot() async throws -> NativeEditorCRDTDocumentSnapshot? { + let value = try callRequired("currentSnapshot") + return try decode( + NativeEditorJSCRDTRuntimeSnapshot.self, + from: value, + function: "currentSnapshot" + ).crdtSnapshot() + } + func applyRemoteUpdateAndCaptureSnapshotSynchronously( _ update: Data ) throws -> NativeEditorCRDTDocumentSnapshot? { @@ -131,18 +148,53 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { } func integrateLocalChange(_ change: NativeEditorCRDTLocalChange) async throws { + _ = try integrateLocalChangeAndCaptureUpdates(change, publishUpdates: true) + } + + func integrateLocalChangeForCommit(_ change: NativeEditorCRDTLocalChange) async throws -> [Data] { + try integrateLocalChangeAndCaptureUpdates(change, publishUpdates: false) + } + + private func integrateLocalChangeAndCaptureUpdates( + _ change: NativeEditorCRDTLocalChange, + publishUpdates: Bool + ) throws -> [Data] { let payload = RuntimeLocalChange( before: RuntimeHistorySnapshot(snapshot: change.before), after: RuntimeHistorySnapshot(snapshot: change.after) ) _ = try callRequired("integrateLocalChange", arguments: [Self.javaScriptValue(from: payload, in: context)]) - try drainRuntimeOutputs() + return try drainRuntimeOutputs(publishUpdates: publishUpdates) } func flushPendingLocalChanges( title: String, document: NativeEditorDocument ) async throws -> NativeEditorCRDTSaveResult { + let committed = try await flushPendingLocalChangesAndCaptureUpdates( + title: title, + document: document, + publishUpdates: true + ) + return committed.result + } + + func flushPendingLocalChangesForCommit( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTCommittedSave { + try await flushPendingLocalChangesAndCaptureUpdates( + title: title, + document: document, + publishUpdates: false + ) + } + + private func flushPendingLocalChangesAndCaptureUpdates( + title: String, + document: NativeEditorDocument, + publishUpdates: Bool + ) async throws -> NativeEditorCRDTCommittedSave { let result = try decode( NativeEditorJSCRDTRuntimeSaveResult.self, from: callRequired( @@ -151,12 +203,15 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { ), function: "flushPendingLocalChanges" ) - try drainRuntimeOutputs() + let updates = try drainRuntimeOutputs(publishUpdates: publishUpdates) - return NativeEditorCRDTSaveResult( - title: result.title, - updatedAt: try NativeEditorJSCRDTDateParser.date(from: result.updatedAt), - documentStateUpdate: try await encodeDocumentState() + return NativeEditorCRDTCommittedSave( + result: NativeEditorCRDTSaveResult( + title: result.title, + updatedAt: try NativeEditorJSCRDTDateParser.date(from: result.updatedAt), + documentStateUpdate: try await encodeDocumentState() + ), + updates: updates ) } @@ -168,11 +223,12 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { snapshotStream } - private func drainRuntimeOutputs() throws { - try drainLocalUpdates() + private func drainRuntimeOutputs(publishUpdates: Bool) throws -> [Data] { + let updates = try drainLocalUpdates(publish: publishUpdates) for snapshot in try takeDocumentSnapshots() { snapshotContinuation.yield(snapshot) } + return updates } private func applyRemoteUpdateCapturingSnapshots( @@ -180,19 +236,22 @@ final class NativeEditorJSCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { ) throws -> [NativeEditorCRDTDocumentSnapshot] { try Self.validateRemotePayload(update) _ = try callRequired("applyRemoteUpdate", arguments: [update.base64EncodedString()]) - try drainLocalUpdates() + _ = try drainLocalUpdates(publish: false) return try takeDocumentSnapshots() } - private func drainLocalUpdates() throws { - guard let value = try callOptional("drainLocalUpdates") else { return } + private func drainLocalUpdates(publish: Bool) throws -> [Data] { + guard let value = try callOptional("drainLocalUpdates") else { return [] } let updates = try decode([String].self, from: value, function: "drainLocalUpdates") - for update in updates { + return try updates.map { update in guard let data = Data(base64Encoded: update) else { throw NativeEditorJSCRDTEngineError.invalidDataResult("drainLocalUpdates") } - localUpdateContinuation.yield(data) + if publish { + localUpdateContinuation.yield(data) + } + return data } } @@ -371,7 +430,9 @@ private extension NativeEditorJSCRDTDocumentEngine { [ "encodeStateVector", "encodeStateAsUpdate", + "validateUpdate", "applyRemoteUpdate", + "currentSnapshot", "integrateLocalChange", "flushPendingLocalChanges", "resolveRemoteCursor", diff --git a/docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift b/docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift index b28fae95..6acda70e 100644 --- a/docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift +++ b/docmostly/Features/Editor/NativeEditorOfflineCRDTSynchronizer.swift @@ -20,7 +20,7 @@ nonisolated enum NativeEditorOfflineCRDTSyncError: LocalizedError, Equatable, Se protocol NativeEditorOfflineCRDTSynchronizing: Sendable { func synchronize( pageID: String, - engine: any NativeEditorCRDTDocumentEngine, + session: DocumentSession, url: URL, token: String, user: DocmostUser? @@ -38,14 +38,18 @@ actor NativeEditorOfflineCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing func synchronize( pageID: String, - engine: any NativeEditorCRDTDocumentEngine, + session: DocumentSession, url: URL, token: String, user: DocmostUser? ) async throws { + guard try await session.hasPendingSynchronization() else { return } + guard let coordinator = await session.syncCoordinator else { + throw APIError.connectionFailed("The local document session could not start collaboration.") + } + let client = NativeEditorCollaborationPresenceClient(urlSession: urlSession) let document = NativeEditorCollaborationDocument(pageID: pageID) - let coordinator = NativeEditorCRDTSyncCoordinator(documentEngine: engine) let syncDriver = NativeEditorCollaborationSyncDriver( documentName: document.name, coordinator: coordinator @@ -60,7 +64,7 @@ actor NativeEditorOfflineCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing ) do { - try await waitForSynchronization(events: events) + try await waitForSynchronization(events: events, session: session) await client.disconnect() } catch { await client.disconnect() @@ -69,7 +73,8 @@ actor NativeEditorOfflineCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing } private func waitForSynchronization( - events: AsyncThrowingStream + events: AsyncThrowingStream, + session: DocumentSession ) async throws { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { @@ -82,7 +87,9 @@ actor NativeEditorOfflineCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing } hasWriteAccess = true case .syncStatus(true) where hasWriteAccess: - return + if try await session.hasPendingSynchronization() == false { + return + } case .awareness, .stateless, .syncStatus: continue } diff --git a/docmostly/Features/Editor/NativeEditorPageTitleIconButton.swift b/docmostly/Features/Editor/NativeEditorPageTitleIconButton.swift new file mode 100644 index 00000000..46032184 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorPageTitleIconButton.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct NativeEditorPageTitleIconButton: View { + let icon: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + Label { + Text(icon == nil ? "Add page emoji" : "Change page emoji") + } icon: { + if let icon, icon.isEmpty == false { + Text(icon) + } else { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + } + } + .labelStyle(.iconOnly) + .font(.largeTitle) + } + .buttonStyle(.plain) + .accessibilityLabel(icon == nil ? "Add page emoji" : "Change page emoji") + .accessibilityHint("Opens the emoji picker") + } +} diff --git a/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift b/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift new file mode 100644 index 00000000..8be8146c --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct NativeEditorPageTitleIconTransition: Transition { + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : 8) + .opacity(phase.isIdentity ? 1 : 0) + } +} diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift index ede814e9..0aa243b7 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift @@ -426,7 +426,13 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { private func platformFont(for kind: NativeEditorBlockKind) -> UIFont { switch kind { case .heading(let level): - return UIFont.preferredFont(forTextStyle: level == 1 ? .title1 : .title2) + let font = UIFont.preferredFont(forTextStyle: level == 1 ? .title1 : .title2) + var traits = font.fontDescriptor.symbolicTraits + traits.insert(.traitBold) + guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { + return font + } + return UIFont(descriptor: descriptor, size: font.pointSize) case .codeBlock: let bodyFont = UIFont.preferredFont(forTextStyle: .body) let baseFont = UIFont.monospacedSystemFont(ofSize: bodyFont.pointSize, weight: .regular) diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift index d71286ba..399e13ed 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift @@ -464,10 +464,11 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { private func platformFont(for kind: NativeEditorBlockKind) -> NSFont { switch kind { case .heading(let level): - return NSFont.preferredFont( + let font = NSFont.preferredFont( forTextStyle: level == 1 ? .title1 : .title2, options: [:] ) + return NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) case .codeBlock: let bodyFont = NSFont.preferredFont(forTextStyle: .body, options: [:]) return NSFont.monospacedSystemFont(ofSize: bodyFont.pointSize, weight: .regular) diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift index a960d363..4121433d 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift @@ -205,6 +205,9 @@ extension NativeRichEditorViewModel { } func crdtDocumentSnapshots() async -> AsyncStream { + if let documentSession { + return documentSession.snapshots() + } guard let crdtDocumentEngine else { let (stream, continuation) = AsyncStream.makeStream(of: NativeEditorCRDTDocumentSnapshot.self) continuation.finish() @@ -323,19 +326,25 @@ extension NativeRichEditorViewModel { crdtDocumentEngine = engine crdtSyncCoordinator = makeCRDTSyncCoordinator(for: engine) isCRDTEngineReadyForLocalChanges = restoredLocalState || engine.requiresInitialRemoteSnapshot == false + updateEditAccess() } - func persistCurrentCRDTState(appState: AppState) async { - await waitForStableCRDTLocalChangeBarrier() - guard let crdtDocumentEngine else { return } - - do { - let update = try await crdtDocumentEngine.encodeDocumentState() - try await appState.persistCRDTStateUpdate(pageID: currentPageID, update: update) - } catch is CancellationError { - return - } catch { - appState.statusMessage = "Could not cache the collaborative document: " + error.localizedDescription + func configureDocumentSession( + _ session: DocumentSession, + restoredLocalState: Bool = false + ) { + documentSession = session + crdtDocumentEngine = session.documentEngine + crdtSyncCoordinator = session.syncCoordinator + isCRDTEngineReadyForLocalChanges = restoredLocalState || + session.documentEngine.requiresInitialRemoteSnapshot == false + updateEditAccess() + if let initialSnapshot = session.initialSnapshot { + if isCRDTEngineReadyForLocalChanges { + applyInitialCRDTDocumentSnapshot(initialSnapshot) + } else { + applyRetainedDraftProjection(initialSnapshot) + } } } @@ -416,6 +425,18 @@ extension NativeRichEditorViewModel { ) } + func markDocumentRemotePeerConnected() async { + await documentSession?.markRemoteConnected() + } + + func retainCurrentDocumentDraft(title: String, document: ProseMirrorDocument) async throws { + try await documentSession?.retainDraft(title: title, document: document) + } + + func clearRetainedDocumentDraft() async { + await documentSession?.clearRetainedDraft() + } + func refreshResolvedRemoteCursors() async { guard let crdtDocumentEngine else { if resolvedRemoteCursors.isEmpty == false { @@ -528,6 +549,7 @@ extension NativeRichEditorViewModel { private func applyInitialCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { isCRDTEngineReadyForLocalChanges = true + updateEditAccess() guard isDirty else { applyCleanCRDTDocumentSnapshot(snapshot) @@ -573,6 +595,16 @@ extension NativeRichEditorViewModel { deferCRDTDocumentSnapshot(snapshot) } + private func applyRetainedDraftProjection(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + if let snapshotTitle = snapshot.title { + title = snapshotTitle + } + document = snapshot.document + retainedReadOnlyDraftSnapshot = makeHistorySnapshot() + hasDurablyPersistedLocalCRDTDraft = true + isDirty = true + } + private func applyCleanCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { if let snapshotTitle = snapshot.title { title = snapshotTitle diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+PageDetails.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+PageDetails.swift index 961b4f5d..34556ff1 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+PageDetails.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+PageDetails.swift @@ -5,6 +5,7 @@ extension NativeRichEditorViewModel { _ page: DocmostEditablePage, fallbackLastUpdatedBy: DocmostPagePerson? = nil ) { + icon = page.icon creator = page.creator lastUpdatedBy = page.lastUpdatedBy ?? fallbackLastUpdatedBy ?? lastUpdatedBy createdAt = page.createdAt ?? createdAt diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index c799c755..74d098e4 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -9,6 +9,7 @@ import SwiftUI final class NativeRichEditorViewModel { let pageID: String var title: String + var icon: String? var document = NativeEditorDocument() { didSet { rebuildResolvedRemoteCursorIndex() @@ -85,6 +86,7 @@ final class NativeRichEditorViewModel { @ObservationIgnored var lastKnownSnapshot: NativeEditorHistorySnapshot? @ObservationIgnored var isApplyingHistory = false @ObservationIgnored var crdtDocumentEngine: (any NativeEditorCRDTDocumentEngine)? + @ObservationIgnored var documentSession: DocumentSession? @ObservationIgnored var crdtSyncCoordinator: NativeEditorCRDTSyncCoordinator? @ObservationIgnored var crdtLocalChangeTask: Task? @ObservationIgnored var activeSaveTask: Task? @@ -99,6 +101,7 @@ final class NativeRichEditorViewModel { ) { self.pageID = pageID title = initialTitle + icon = nil editablePageID = pageID editablePageSlugID = pageID lastSavedTitle = initialTitle @@ -241,18 +244,18 @@ final class NativeRichEditorViewModel { let snapshotTitle = title let snapshotDocument = document let snapshotBaseTitle = lastSavedTitle - let snapshotBaseDocument = lastSavedDocument - let capturedAt = Date.now saveErrorMessage = nil do { + try await retainCurrentDocumentDraft( + title: snapshotTitle, + document: snapshotDocument.proseMirrorDocument + ) let persistence = try await appState.persistDeferredCollaborativeDraft( pageId: editablePageID, title: snapshotTitle.trimmingCharacters(in: .whitespacesAndNewlines), documentSnapshot: snapshotDocument.proseMirrorDocument, - baseTitle: snapshotBaseTitle, - baseDocument: snapshotBaseDocument.proseMirrorDocument, - snapshotCapturedAt: capturedAt + baseTitle: snapshotBaseTitle ) if let page = persistence.page { editablePageID = page.id @@ -405,8 +408,9 @@ final class NativeRichEditorViewModel { visibleBlockControlsID = nil } - private func updateEditAccess() { - let nextCanEdit = pageAllowsEditing && collaborationAllowsEditing + func updateEditAccess() { + let crdtAllowsEditing = crdtDocumentEngine == nil || isCRDTEngineReadyForLocalChanges + let nextCanEdit = pageAllowsEditing && collaborationAllowsEditing && crdtAllowsEditing guard canEdit != nextCanEdit else { return } canEdit = nextCanEdit @@ -429,7 +433,6 @@ private extension NativeRichEditorViewModel { let baseDocument: NativeEditorDocument let localEditRevision: UInt let savedBaselineRevision: UInt - let capturedAt: Date let requiresLocalOnlyCRDTPersistence: Bool let crdtFlushTask: Task? @@ -451,7 +454,6 @@ private extension NativeRichEditorViewModel { baseDocument: lastSavedDocument, localEditRevision: localEditRevision, savedBaselineRevision: savedBaselineRevision, - capturedAt: .now, requiresLocalOnlyCRDTPersistence: requiresLocalOnlyCRDTPersistence, crdtFlushTask: requiresLocalOnlyCRDTPersistence ? nil : enqueueCRDTSnapshotFlush( title: snapshotTitle.trimmingCharacters(in: .whitespacesAndNewlines), @@ -463,13 +465,15 @@ private extension NativeRichEditorViewModel { func persist(snapshot: SaveSnapshot, appState: AppState) async -> Bool { do { if snapshot.requiresLocalOnlyCRDTPersistence { + try await retainCurrentDocumentDraft( + title: snapshot.trimmedTitle, + document: snapshot.document.proseMirrorDocument + ) 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 + baseTitle: snapshot.baseTitle ) if let page = persistence.page { editablePageID = page.id @@ -487,17 +491,11 @@ private extension NativeRichEditorViewModel { let flushedTitle = result.title ?? snapshot.trimmedTitle if appState.hasPageUpdatePersistence { - guard let crdtStateUpdate = result.documentStateUpdate, - crdtStateUpdate.isEmpty == false else { - throw APIError.connectionFailed("Collaborative save did not produce durable Yjs state.") - } let persistence = try await appState.updateCollaborativePageTitle( pageId: snapshot.pageID, title: flushedTitle, documentSnapshot: snapshot.document.proseMirrorDocument, - crdtStateUpdate: crdtStateUpdate, - baseTitle: snapshot.baseTitle, - snapshotCapturedAt: snapshot.capturedAt + baseTitle: snapshot.baseTitle ) if let page = persistence.page { editablePageID = page.id diff --git a/docmostly/Features/Favorites/FavoritesViewModel.swift b/docmostly/Features/Favorites/FavoritesViewModel.swift index 93fd4547..79d9d1ad 100644 --- a/docmostly/Features/Favorites/FavoritesViewModel.swift +++ b/docmostly/Features/Favorites/FavoritesViewModel.swift @@ -7,6 +7,8 @@ final class FavoritesViewModel { private static let pageSize = 30 private var pages = CursorPageAccumulator() + @ObservationIgnored private var loadRequestID: UUID? + @ObservationIgnored private var paginationGeneration: UInt = 0 var isLoading = false var isLoadingNextPage = false var mutatingFavoriteIDs: Set = [] @@ -35,34 +37,64 @@ final class FavoritesViewModel { } func load(appState: AppState) async { - guard isLoading == false else { return } + await load { + try await appState.loadFavorites(limit: Self.pageSize) + } + } + + func load( + operation: () async throws -> PaginatedResponse + ) async { + let requestID = UUID() + paginationGeneration &+= 1 + isLoadingNextPage = false + loadRequestID = requestID isLoading = true errorMessage = nil nextPageErrorMessage = nil - defer { isLoading = false } + defer { + if loadRequestID == requestID { + isLoading = false + } + } do { - let response = try await appState.loadFavorites(limit: Self.pageSize) + let response = try await operation() + guard loadRequestID == requestID, Task.isCancelled == false else { return } applyInitialPage(response) - } catch is CancellationError { - return } catch { + guard loadRequestID == requestID, Task.isCancelled == false else { return } + guard Self.isCancelledLoadError(error) == false else { return } errorMessage = error.localizedDescription } } func loadNextPage(appState: AppState) async { + await loadNextPage { + try await appState.loadFavorites(cursor: $0, limit: Self.pageSize) + } + } + + func loadNextPage( + operation: (String) async throws -> PaginatedResponse + ) async { guard isLoading == false, isLoadingNextPage == false, let cursor = pages.nextCursor else { return } + let generation = paginationGeneration isLoadingNextPage = true nextPageErrorMessage = nil - defer { isLoadingNextPage = false } + defer { + if paginationGeneration == generation { + isLoadingNextPage = false + } + } do { - let response = try await appState.loadFavorites(cursor: cursor, limit: Self.pageSize) + let response = try await operation(cursor) + guard paginationGeneration == generation, Task.isCancelled == false else { return } applyNextPage(response, requestedCursor: cursor) - } catch is CancellationError { - return } catch { + guard paginationGeneration == generation, Task.isCancelled == false else { return } + guard Self.isCancelledLoadError(error) == false else { return } nextPageErrorMessage = error.localizedDescription } } @@ -104,13 +136,21 @@ final class FavoritesViewModel { do { try await operation() - } catch is CancellationError { - pages.restore(removal.item, at: removal.index) } catch { pages.restore(removal.item, at: removal.index) + guard Self.isCancelledLoadError(error) == false else { return } errorMessage = error.localizedDescription } } + + static func isCancelledLoadError(_ error: any Error) -> Bool { + if error is CancellationError { + return true + } + + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled + } } nonisolated struct FavoriteSectionGroup: Identifiable, Sendable { diff --git a/docmostly/Features/PageReader/EmojiCatalog.swift b/docmostly/Features/PageReader/EmojiCatalog.swift new file mode 100644 index 00000000..32350f81 --- /dev/null +++ b/docmostly/Features/PageReader/EmojiCatalog.swift @@ -0,0 +1,47 @@ +import Foundation + +nonisolated enum EmojiCatalog { + static let sections: [EmojiCatalogSection] = loadSections() + + static func parse(_ source: String) -> [EmojiCatalogSection] { + var sections: [EmojiCatalogSection] = [] + var currentName: String? + var currentItems: [EmojiCatalogItem] = [] + + func appendCurrentSection() { + guard let currentName, currentItems.isEmpty == false else { return } + sections.append(EmojiCatalogSection(name: currentName, items: currentItems)) + } + + for sourceLine in source.split(whereSeparator: \Character.isNewline) { + let line = String(sourceLine) + if line.hasPrefix("# group: ") { + appendCurrentSection() + currentName = String(line.dropFirst("# group: ".count)) + currentItems = [] + continue + } + + let fields = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false) + guard fields.count == 2 else { continue } + currentItems.append(EmojiCatalogItem(emoji: String(fields[0]), name: String(fields[1]))) + } + + appendCurrentSection() + return sections + } + + private static func loadSections() -> [EmojiCatalogSection] { + let rootURL = Bundle.main.url(forResource: "emoji-16.0", withExtension: "txt") + let resourcesURL = Bundle.main.url( + forResource: "emoji-16.0", + withExtension: "txt", + subdirectory: "Resources" + ) + guard let url = rootURL ?? resourcesURL, + let source = try? String(contentsOf: url, encoding: .utf8) else { + return [] + } + return parse(source) + } +} diff --git a/docmostly/Features/PageReader/EmojiCatalogItem.swift b/docmostly/Features/PageReader/EmojiCatalogItem.swift new file mode 100644 index 00000000..0747896c --- /dev/null +++ b/docmostly/Features/PageReader/EmojiCatalogItem.swift @@ -0,0 +1,15 @@ +import Foundation + +nonisolated struct EmojiCatalogItem: Identifiable, Hashable, Sendable { + let emoji: String + let name: String + + var id: String { emoji } +} + +nonisolated struct EmojiCatalogSection: Identifiable, Hashable, Sendable { + let name: String + let items: [EmojiCatalogItem] + + var id: String { name } +} diff --git a/docmostly/Features/PageReader/PageEmojiPickerSheet.swift b/docmostly/Features/PageReader/PageEmojiPickerSheet.swift new file mode 100644 index 00000000..b973638d --- /dev/null +++ b/docmostly/Features/PageReader/PageEmojiPickerSheet.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct PageEmojiPickerSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var viewModel = PageEmojiPickerViewModel() + @FocusState private var isSearchFocused: Bool + @ScaledMetric(relativeTo: .title2) private var minimumCellSize: CGFloat = 44 + + let editorViewModel: NativeRichEditorViewModel + + var body: some View { + NavigationStack { + ScrollView { + if viewModel.visibleSections.isEmpty { + ContentUnavailableView.search(text: viewModel.searchText) + } else { + LazyVStack(alignment: .leading) { + ForEach(viewModel.visibleSections) { section in + PageEmojiPickerSectionView( + section: section, + selectedEmoji: editorViewModel.icon, + minimumCellSize: minimumCellSize, + isDisabled: viewModel.isSaving, + select: select + ) + } + } + .padding() + } + } + .navigationTitle("Choose Emoji") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .searchable(text: $viewModel.searchText, prompt: "Search emoji") + .searchFocused($isSearchFocused) + .defaultFocus($isSearchFocused, true) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .overlay { + if viewModel.isSaving { + ProgressView("Saving emoji") + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + } + } + .alert("Could Not Change Emoji", isPresented: errorIsPresented) { + Button("OK", role: .cancel) { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } + .presentationDetents([.large]) + } + + private var errorIsPresented: Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { isPresented in + if isPresented == false { + viewModel.errorMessage = nil + } + } + ) + } + + private func select(_ item: EmojiCatalogItem) { + guard viewModel.beginSaving() else { return } + + Task { + do { + let page = try await appState.updatePageIcon( + pageId: editorViewModel.currentPageID, + icon: item.emoji + ) + editorViewModel.icon = page.icon ?? item.emoji + viewModel.finishSaving() + dismiss() + } catch { + viewModel.finishSaving(error: error) + } + } + } +} + +private struct PageEmojiPickerSectionView: View { + let section: EmojiCatalogSection + let selectedEmoji: String? + let minimumCellSize: CGFloat + let isDisabled: Bool + let select: (EmojiCatalogItem) -> Void + + var body: some View { + Section { + LazyVGrid(columns: [GridItem(.adaptive(minimum: minimumCellSize))]) { + ForEach(section.items) { item in + Button { + select(item) + } label: { + Text(item.emoji) + .font(.title) + .frame(maxWidth: .infinity, minHeight: minimumCellSize) + .background( + selectedEmoji == item.emoji ? Color.accentColor.opacity(0.16) : .clear, + in: .rect(cornerRadius: 8) + ) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(item.name) + .accessibilityAddTraits(selectedEmoji == item.emoji ? .isSelected : []) + } + } + } header: { + Text(section.name) + .font(.headline) + .padding(.top) + } + } +} diff --git a/docmostly/Features/PageReader/PageEmojiPickerViewModel.swift b/docmostly/Features/PageReader/PageEmojiPickerViewModel.swift new file mode 100644 index 00000000..1b66ee2b --- /dev/null +++ b/docmostly/Features/PageReader/PageEmojiPickerViewModel.swift @@ -0,0 +1,53 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class PageEmojiPickerViewModel { + var searchText = "" { + didSet { + updateVisibleSections() + } + } + private(set) var visibleSections: [EmojiCatalogSection] + private(set) var isSaving = false + var errorMessage: String? + + private let allSections: [EmojiCatalogSection] + + init(sections: [EmojiCatalogSection] = EmojiCatalog.sections) { + allSections = sections + visibleSections = sections + } + + func beginSaving() -> Bool { + guard isSaving == false else { return false } + isSaving = true + errorMessage = nil + return true + } + + func finishSaving(error: (any Error)? = nil) { + isSaving = false + errorMessage = error?.localizedDescription + } + + private func updateVisibleSections() { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.isEmpty == false else { + visibleSections = allSections + return + } + + visibleSections = allSections.compactMap { section in + let items = if section.name.localizedStandardContains(query) { + section.items + } else { + section.items.filter { item in + item.emoji == query || item.name.localizedStandardContains(query) + } + } + return items.isEmpty ? nil : EmojiCatalogSection(name: section.name, items: items) + } + } +} diff --git a/docmostly/Features/PageReader/PageReaderMetadataView.swift b/docmostly/Features/PageReader/PageReaderMetadataView.swift index 508b267a..38092e83 100644 --- a/docmostly/Features/PageReader/PageReaderMetadataView.swift +++ b/docmostly/Features/PageReader/PageReaderMetadataView.swift @@ -2,13 +2,20 @@ import SwiftUI struct PageReaderMetadataView: View { let breadcrumbs: [DocmostPage] + let currentPageID: String + let currentPageIcon: String? let labels: [DocmostLabel] let selectPage: (DocmostPage) -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { if breadcrumbs.isEmpty == false { - PageBreadcrumbTrailView(breadcrumbs: breadcrumbs, selectPage: selectPage) + PageBreadcrumbTrailView( + breadcrumbs: breadcrumbs, + currentPageID: currentPageID, + currentPageIcon: currentPageIcon, + selectPage: selectPage + ) } if labels.isEmpty == false { @@ -20,14 +27,24 @@ struct PageReaderMetadataView: View { private struct PageBreadcrumbTrailView: View { let breadcrumbs: [DocmostPage] + let currentPageID: String + let currentPageIcon: String? let selectPage: (DocmostPage) -> Void var body: some View { ScrollView(.horizontal) { HStack(spacing: 4) { ForEach(breadcrumbs.enumerated(), id: \.element.id) { index, page in - Button(page.title.isEmpty ? "Untitled" : page.title) { + Button { selectPage(page) + } label: { + HStack(spacing: 4) { + if let icon = icon(for: page), icon.isEmpty == false { + Text(icon) + .accessibilityHidden(true) + } + Text(page.title.isEmpty ? "Untitled" : page.title) + } } .buttonStyle(.borderless) .font(.caption) @@ -43,6 +60,10 @@ private struct PageBreadcrumbTrailView: View { } .scrollIndicators(.hidden) } + + private func icon(for page: DocmostPage) -> String? { + page.id == currentPageID ? currentPageIcon : page.icon + } } private struct PageLabelChipsView: View { diff --git a/docmostly/Features/PageReader/PageReaderView+Actions.swift b/docmostly/Features/PageReader/PageReaderView+Actions.swift index f6a5f5db..343ccb32 100644 --- a/docmostly/Features/PageReader/PageReaderView+Actions.swift +++ b/docmostly/Features/PageReader/PageReaderView+Actions.swift @@ -12,6 +12,11 @@ extension PageReaderView { isShowingMentionPicker = true } + func showEmojiPicker() { + guard readerMode == .edit, editorViewModel?.canEdit == true else { return } + isShowingEmojiPicker = true + } + func applyEditorCommand(_ command: NativeEditorCommand) { guard command.requiresServerBackedBaseCreation else { editorViewModel?.applySlashCommand(command) diff --git a/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift b/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift index c08f488a..c1b34acd 100644 --- a/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift +++ b/docmostly/Features/PageReader/PageReaderView+CollaborationPresence.swift @@ -21,7 +21,6 @@ extension PageReaderView { for await snapshot in snapshots { guard Task.isCancelled == false else { return } editorViewModel.applyCRDTDocumentSnapshot(snapshot) - await editorViewModel.persistCurrentCRDTState(appState: appState) await editorViewModel.refreshResolvedRemoteCursors() } } @@ -89,7 +88,7 @@ extension PageReaderView { ) async { editorViewModel.applyCollaborationSyncStatus(isSynced: isSynced) if isSynced { - await editorViewModel.persistCurrentCRDTState(appState: appState) + await editorViewModel.markDocumentRemotePeerConnected() } if isSynced, editorViewModel.usesCRDTDocumentEngine == false { editorViewModel.markCollaborationUnavailable("Native CRDT runtime is unavailable.") diff --git a/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift b/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift index 1e5b5d10..fa1b2edc 100644 --- a/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift +++ b/docmostly/Features/PageReader/PageReaderView+CollaborationResolution.swift @@ -86,7 +86,6 @@ extension PageReaderView { title: editorViewModel.title, document: editorViewModel.document.proseMirrorDocument, remoteBaseTitle: remoteTitle, - remoteBaseDocument: remoteSnapshot.document.proseMirrorDocument, replacingThrough: cutoff ) guard result != .newerPendingUpdatePreserved else { @@ -102,8 +101,10 @@ extension PageReaderView { "A newer remote version arrived while resolving this conflict. Review it and try again." return } + try await editorViewModel.waitForPendingCRDTLocalChange() } + await editorViewModel.clearRetainedDocumentDraft() editorViewModel.saveErrorMessage = nil } catch { editorViewModel.saveErrorMessage = diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index 7986d927..dcdeee3f 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -33,6 +33,7 @@ struct PageReaderView: View { @State var isConfirmingPageTrash = false @State var isShowingLabelEditor = false @State var isShowingMoveToSpace = false + @State var isShowingEmojiPicker = false @State var pendingInlineCommentID: String? @State var pendingInlineCommentDraft: CommentBody? @State var pendingInlineCommentYjsSelection: NativeEditorYjsSelection? @@ -76,6 +77,8 @@ struct PageReaderView: View { } else { PageReaderMetadataView( breadcrumbs: viewModel.breadcrumbs, + currentPageID: editorViewModel.currentPageID, + currentPageIcon: editorViewModel.icon, labels: viewModel.labels, selectPage: selectBreadcrumb ) @@ -91,7 +94,8 @@ struct PageReaderView: View { }, keepPendingLocalUpdate: { resolvePendingRemoteUpdate(.keepLocal) - } + }, + pickPageEmoji: showEmojiPicker ) AttachmentLinksView( links: viewModel.attachmentLinks, @@ -207,6 +211,11 @@ struct PageReaderView: View { } } } + .sheet(isPresented: $isShowingEmojiPicker) { + if let editorViewModel { + PageEmojiPickerSheet(editorViewModel: editorViewModel) + } + } .sheet(isPresented: $isShowingPageHistory) { if let editorViewModel { PageHistorySheet( diff --git a/docmostly/Features/PageTree/PageBrowserHomeView.swift b/docmostly/Features/PageTree/PageBrowserHomeView.swift index 9c716cb6..4e482543 100644 --- a/docmostly/Features/PageTree/PageBrowserHomeView.swift +++ b/docmostly/Features/PageTree/PageBrowserHomeView.swift @@ -58,7 +58,9 @@ struct PageBrowserHomeView: View { PageBrowserTaskKey( spaceID: space.id, scope: viewModel.selectedScope, - pageDiscoveryRevision: appState.pageDiscoveryRevision + pageDiscoveryRevision: appState.pageDiscoveryRevision, + favoriteRevision: appState.favoriteRevision, + initializedSpaceID: nil ) } } diff --git a/docmostly/Features/PageTree/PageBrowserMetrics.swift b/docmostly/Features/PageTree/PageBrowserMetrics.swift index bcc23696..1b7574ba 100644 --- a/docmostly/Features/PageTree/PageBrowserMetrics.swift +++ b/docmostly/Features/PageTree/PageBrowserMetrics.swift @@ -6,6 +6,8 @@ enum PageBrowserMetrics { static let headerInsets = EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16) static let rowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) static let switchInsets = EdgeInsets(top: 8, leading: 12, bottom: 10, trailing: 12) + static let sidebarScopeSwitchSidePadding: CGFloat = 20 + static let scopeSwitchVerticalOverflowPadding: CGFloat = 12 static let railLimit = 12 static let railHorizontalPadding: CGFloat = 16 static let railSectionSpacing: CGFloat = 12 diff --git a/docmostly/Features/PageTree/PageBrowserScopeLabel.swift b/docmostly/Features/PageTree/PageBrowserScopeLabel.swift index f60eff91..5509154b 100644 --- a/docmostly/Features/PageTree/PageBrowserScopeLabel.swift +++ b/docmostly/Features/PageTree/PageBrowserScopeLabel.swift @@ -5,18 +5,13 @@ struct PageBrowserScopeLabel: View { let isSelected: Bool var body: some View { - VStack(spacing: 8) { - Label(scope.title, systemImage: scope.systemImage) - .font(.callout) - .foregroundStyle(isSelected ? .primary : .secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - - Capsule() - .fill(isSelected ? Color.primary : Color.clear) - .frame(height: 2) - } - .padding(.top, 6) - .contentShape(.rect) + Label(scope.title, systemImage: scope.systemImage) + .font(.callout) + .foregroundStyle(isSelected ? Color.white : Color.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .contentShape(.capsule) } } diff --git a/docmostly/Features/PageTree/PageBrowserScopeSwitch.swift b/docmostly/Features/PageTree/PageBrowserScopeSwitch.swift index ede46bd0..8f5c3522 100644 --- a/docmostly/Features/PageTree/PageBrowserScopeSwitch.swift +++ b/docmostly/Features/PageTree/PageBrowserScopeSwitch.swift @@ -5,23 +5,53 @@ struct PageBrowserScopeSwitch: View { var body: some View { ScrollView(.horizontal) { - HStack(spacing: 18) { - ForEach(PageBrowserScope.allCases) { scope in - Button { - viewModel.selectedScope = scope - } label: { - PageBrowserScopeLabel( - scope: scope, - isSelected: viewModel.selectedScope == scope - ) - } - .buttonStyle(.plain) - .accessibilityAddTraits(viewModel.selectedScope == scope ? .isSelected : []) - } + GlassEffectContainer(spacing: 8) { + PageBrowserScopePills(viewModel: viewModel) } - .padding(.horizontal, 4) + .padding(.vertical, PageBrowserMetrics.scopeSwitchVerticalOverflowPadding) } + .padding(.vertical, -PageBrowserMetrics.scopeSwitchVerticalOverflowPadding) .scrollIndicators(.hidden) .accessibilityElement(children: .contain) } } + +private struct PageBrowserScopePills: View { + @Bindable var viewModel: PageBrowserViewModel + + var body: some View { + HStack(spacing: 8) { + ForEach(PageBrowserScope.allCases) { scope in + PageBrowserScopePill( + scope: scope, + isSelected: viewModel.selectedScope == scope + ) { + viewModel.selectedScope = scope + } + } + } + } +} + +private struct PageBrowserScopePill: View { + let scope: PageBrowserScope + let isSelected: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + PageBrowserScopeLabel(scope: scope, isSelected: isSelected) + } + .buttonStyle(.plain) + .glassEffect(glass, in: .capsule) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var glass: Glass { + if isSelected { + .regular.tint(.black).interactive() + } else { + .regular.interactive() + } + } +} diff --git a/docmostly/Features/PageTree/PageBrowserTaskKey.swift b/docmostly/Features/PageTree/PageBrowserTaskKey.swift index 14107458..35f0d7a5 100644 --- a/docmostly/Features/PageTree/PageBrowserTaskKey.swift +++ b/docmostly/Features/PageTree/PageBrowserTaskKey.swift @@ -4,4 +4,6 @@ struct PageBrowserTaskKey: Hashable { let spaceID: String let scope: PageBrowserScope let pageDiscoveryRevision: Int + let favoriteRevision: Int + let initializedSpaceID: String? } diff --git a/docmostly/Features/PageTree/PageBrowserViewModel.swift b/docmostly/Features/PageTree/PageBrowserViewModel.swift index cb68615c..bbee6719 100644 --- a/docmostly/Features/PageTree/PageBrowserViewModel.swift +++ b/docmostly/Features/PageTree/PageBrowserViewModel.swift @@ -4,7 +4,7 @@ import Observation @MainActor @Observable final class PageBrowserViewModel { - static let defaultPageLimit = 50 + nonisolated static let defaultPageLimit = 50 var selectedScope: PageBrowserScope = .recentlyUpdated private(set) var items: [PageBrowserItem] = [] @@ -16,6 +16,36 @@ final class PageBrowserViewModel { space: DocmostSpace, provider: any PageBrowserProviding, limit: Int = PageBrowserViewModel.defaultPageLimit + ) async { + await load( + spaceID: space.id, + spaceNamesByID: [space.id: space.name], + provider: provider, + limit: limit + ) + } + + func load( + spaces: [DocmostSpace], + provider: any PageBrowserProviding, + limit: Int = PageBrowserViewModel.defaultPageLimit + ) async { + await load( + spaceID: nil, + spaceNamesByID: Dictionary( + spaces.map { ($0.id, $0.name) }, + uniquingKeysWith: { existingName, _ in existingName } + ), + provider: provider, + limit: limit + ) + } + + private func load( + spaceID: String?, + spaceNamesByID: [String: String], + provider: any PageBrowserProviding, + limit: Int ) async { let requestedScope = selectedScope let loadID = UUID() @@ -33,29 +63,42 @@ final class PageBrowserViewModel { switch requestedScope { case .recentlyUpdated: let response = try await provider.loadRecentPages( - spaceId: space.id, + spaceId: spaceID, cursor: nil, limit: limit ) - loadedItems = response.items.map { PageBrowserItem(page: $0, fallbackSpaceName: space.name) } + loadedItems = response.items.map { + PageBrowserItem( + page: $0, + fallbackSpaceName: spaceNamesByID[$0.spaceId] ?? "Unknown Space" + ) + } case .favorites: let response = try await provider.loadFavorites( type: .page, - spaceId: space.id, + spaceId: spaceID, cursor: nil, limit: limit ) loadedItems = response.items.compactMap { - PageBrowserItem(favorite: $0, fallbackSpaceName: space.name) + PageBrowserItem( + favorite: $0, + fallbackSpaceName: $0.page.flatMap { spaceNamesByID[$0.spaceId] } ?? "Unknown Space" + ) } case .createdByMe: let response = try await provider.loadCreatedByPages( userId: provider.currentPageBrowserUserID, - spaceId: space.id, + spaceId: spaceID, cursor: nil, limit: limit ) - loadedItems = response.items.map { PageBrowserItem(page: $0, fallbackSpaceName: space.name) } + loadedItems = response.items.map { + PageBrowserItem( + page: $0, + fallbackSpaceName: spaceNamesByID[$0.spaceId] ?? "Unknown Space" + ) + } } guard activeLoadID == loadID, selectedScope == requestedScope, Task.isCancelled == false else { return } diff --git a/docmostly/Features/PageTree/PageTreeNodeArray.swift b/docmostly/Features/PageTree/PageTreeNodeArray.swift index 762bba67..45daa179 100644 --- a/docmostly/Features/PageTree/PageTreeNodeArray.swift +++ b/docmostly/Features/PageTree/PageTreeNodeArray.swift @@ -41,7 +41,18 @@ nonisolated extension Array where Element == PageTreeNode { func movePayload(sourceID: String, operation: PageTreeDropOperation) throws -> PageTreeMovePayload { let moveResult = try moving(sourceID: sourceID, operation: operation) - guard let info = moveResult.tree.siblingsInfo(for: sourceID) else { + let info: PageTreeSiblingsInfo + if let insertedInfo = moveResult.tree.siblingsInfo(for: sourceID) { + info = insertedInfo + } else if case .makeChild(let targetID) = operation, + let target = node(id: targetID), + target.isChildrenLoaded == false { + let position = try PagePositionKeyGenerator.key( + between: target.children.last?.position, + and: nil + ) + return PageTreeMovePayload(pageId: sourceID, parentPageId: targetID, position: position) + } else { throw PageTreeError.missingMoveResult } @@ -112,6 +123,10 @@ nonisolated extension Array where Element == PageTreeNode { return map { existing in var nextNode = existing if existing.id == parentPageId { + guard nextNode.isChildrenLoaded else { + nextNode.hasChildren = true + return nextNode + } let insertionIndex = Swift.min(Swift.max(index, 0), nextNode.children.count) nextNode.children.insert(insertedNode, at: insertionIndex) nextNode.hasChildren = true diff --git a/docmostly/Features/PageTree/PageTreeView.swift b/docmostly/Features/PageTree/PageTreeView.swift index bb8948ed..27a2bb0b 100644 --- a/docmostly/Features/PageTree/PageTreeView.swift +++ b/docmostly/Features/PageTree/PageTreeView.swift @@ -10,6 +10,7 @@ struct PageTreeView: View { @State private var moveRequest: PageTreeNode? @State private var copyRequest: PageTreeNode? @State private var isShowingTrash = false + @State private var initializedBrowserSpaceID: String? let space: DocmostSpace @@ -27,7 +28,7 @@ struct PageTreeView: View { } else { RecentPagesRailView( items: browserViewModel.items, - isLoading: browserViewModel.isLoading, + isLoading: browserViewModel.isLoading || initializedBrowserSpaceID != space.id, errorMessage: browserViewModel.errorMessage, isOffline: appState.isOffline ) @@ -113,9 +114,10 @@ struct PageTreeView: View { await refreshPages() } .task(id: space.id) { - await refreshTreeState() + await loadInitialSpaceState() } .task(id: pageBrowserTaskKey) { + guard initializedBrowserSpaceID == space.id else { return } await refreshBrowser() } .task(id: searchTaskKey) { @@ -170,7 +172,9 @@ struct PageTreeView: View { PageBrowserTaskKey( spaceID: space.id, scope: browserViewModel.selectedScope, - pageDiscoveryRevision: appState.pageDiscoveryRevision + pageDiscoveryRevision: appState.pageDiscoveryRevision, + favoriteRevision: appState.favoriteRevision, + initializedSpaceID: initializedBrowserSpaceID ) } @@ -244,7 +248,7 @@ struct PageTreeView: View { } private func showSpaceSettings() { - appState.selectSidebarUtilityDestination(.settings) + appState.selectSidebarUtilityDestination(.settings, returningTo: .space(space.id)) } private func refreshPages() async { @@ -254,6 +258,16 @@ struct PageTreeView: View { await loadTreeState } + private func loadInitialSpaceState() async { + initializedBrowserSpaceID = nil + await viewModel.loadRoot(spaceId: space.id, appState: appState) + guard Task.isCancelled == false else { return } + + initializedBrowserSpaceID = space.id + + await viewModel.loadSpaceActionState(spaceId: space.id, appState: appState) + } + private func refreshBrowser() async { browserViewModel.selectedScope = .recentlyUpdated await browserViewModel.load( diff --git a/docmostly/Features/PageTree/PageTreeViewModel.swift b/docmostly/Features/PageTree/PageTreeViewModel.swift index 33162e28..ceced5c0 100644 --- a/docmostly/Features/PageTree/PageTreeViewModel.swift +++ b/docmostly/Features/PageTree/PageTreeViewModel.swift @@ -30,13 +30,47 @@ final class PageTreeViewModel { do { let pages = try await appState.loadSidebarPages(spaceId: spaceId) - nodes = pages.map(PageTreeNode.init(page:)).sortedByPosition() + var refreshedNodes = pages + .map(PageTreeNode.init(page:)) + .sortedByPosition() + for index in refreshedNodes.indices { + refreshedNodes[index] = try await reloadExpandedSubtree( + refreshedNodes[index], + appState: appState + ) + } + nodes = refreshedNodes rebuildVisibleNodes() } catch { errorMessage = error.localizedDescription } } + private func reloadExpandedSubtree( + _ node: PageTreeNode, + appState: AppState + ) async throws -> PageTreeNode { + guard expandedIDs.contains(node.id) else { return node } + + var refreshedNode = node + guard node.hasChildren else { + refreshedNode.children = [] + refreshedNode.isChildrenLoaded = true + return refreshedNode + } + + var children = try await appState.loadSidebarPages(spaceId: node.spaceId, pageId: node.id) + .map(PageTreeNode.init(page:)) + .sortedByPosition() + for index in children.indices { + children[index] = try await reloadExpandedSubtree(children[index], appState: appState) + } + refreshedNode.children = children + refreshedNode.hasChildren = children.isEmpty == false + refreshedNode.isChildrenLoaded = true + return refreshedNode + } + func clearPages() { nodes = [] expandedIDs = [] @@ -121,6 +155,7 @@ final class PageTreeViewModel { let childNodes = children.map(PageTreeNode.init(page:)).sortedByPosition() nodes.updateNode(id: node.id) { existing in existing.children = childNodes + existing.hasChildren = childNodes.isEmpty == false existing.isChildrenLoaded = true } rebuildVisibleNodes() @@ -243,7 +278,7 @@ final class PageTreeViewModel { nodes.updateNode(id: parentPageId) { existing in existing.children = children.map(PageTreeNode.init(page:)).sortedByPosition() existing.isChildrenLoaded = true - existing.hasChildren = true + existing.hasChildren = existing.children.isEmpty == false } rebuildVisibleNodes() } diff --git a/docmostly/Features/Settings/SettingsManagementViewModel.swift b/docmostly/Features/Settings/SettingsManagementViewModel.swift index f20cca6b..a760129f 100644 --- a/docmostly/Features/Settings/SettingsManagementViewModel.swift +++ b/docmostly/Features/Settings/SettingsManagementViewModel.swift @@ -7,6 +7,7 @@ final class SettingsManagementViewModel { var accountDraft = AccountSettingsDraft() var workspaceDraft = WorkspaceSettingsDraft() var workspace: DocmostWorkspace? + var workspaceEntitlements: DocmostWorkspaceEntitlements? var workspaceMembers: [DocmostUser] = [] var workspaceInvitations: [DocmostWorkspaceInvitation] = [] var groups: [DocmostGroup] = [] @@ -25,6 +26,34 @@ final class SettingsManagementViewModel { currentUserRole == "owner" } + var hasWorkspaceChanges: Bool { + guard let workspace else { return false } + return workspaceDraft.hasChanges( + comparedTo: workspace, + availableFeatures: availableWorkspaceFeatures + ) + } + + var hasUnavailableSecurityFeatures: Bool { + [ + DocmostWorkspaceFeature.sharingControls, + .apiKeys, + .retention + ].contains { hasWorkspaceFeature($0) == false } + } + + var hasUnavailableWorkspaceFeatures: Bool { + [ + DocmostWorkspaceFeature.templates, + .artificialIntelligence, + .mcp + ].contains { hasWorkspaceFeature($0) == false } + } + + var hasUnavailableLicensedSettings: Bool { + hasUnavailableSecurityFeatures || hasUnavailableWorkspaceFeatures + } + func seed(from appState: AppState) { guard let currentUser = appState.currentUser else { return } currentUserRole = currentUser.user.role @@ -34,10 +63,13 @@ final class SettingsManagementViewModel { } func loadWorkspace(appState: AppState) async { + workspaceEntitlements = nil await load { + async let entitlements = try? await appState.loadWorkspaceEntitlements() let workspace = try await appState.loadWorkspaceInfo() self.workspace = workspace workspaceDraft = WorkspaceSettingsDraft(workspace: workspace) + workspaceEntitlements = await entitlements } } @@ -66,7 +98,10 @@ final class SettingsManagementViewModel { return false } - let update = workspaceDraft.update(comparedTo: workspace) + let update = workspaceDraft.update( + comparedTo: workspace, + availableFeatures: availableWorkspaceFeatures + ) guard update.hasChanges else { return true } return await save(successMessage: "Workspace updated.") { @@ -196,6 +231,14 @@ final class SettingsManagementViewModel { statusMessage = nil } + func hasWorkspaceFeature(_ feature: DocmostWorkspaceFeature) -> Bool { + workspaceEntitlements?.contains(feature) == true + } + + private var availableWorkspaceFeatures: Set { + Set(DocmostWorkspaceFeature.allCases.filter(hasWorkspaceFeature)) + } + private func load(_ operation: () async throws -> Void) async { isLoading = true clearMessages() diff --git a/docmostly/Features/Settings/WorkspaceSettingsDraft.swift b/docmostly/Features/Settings/WorkspaceSettingsDraft.swift index 3f6f01bf..65d3e4c7 100644 --- a/docmostly/Features/Settings/WorkspaceSettingsDraft.swift +++ b/docmostly/Features/Settings/WorkspaceSettingsDraft.swift @@ -51,6 +51,13 @@ nonisolated struct WorkspaceSettingsDraft: Equatable, Sendable { update(comparedTo: workspace).hasChanges } + func hasChanges( + comparedTo workspace: DocmostWorkspace, + availableFeatures: Set + ) -> Bool { + update(comparedTo: workspace, availableFeatures: availableFeatures).hasChanges + } + func update(comparedTo workspace: DocmostWorkspace) -> WorkspaceUpdate { let original = WorkspaceSettingsDraft(workspace: workspace) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -67,4 +74,33 @@ nonisolated struct WorkspaceSettingsDraft: Equatable, Sendable { allowMemberTemplates: allowMemberTemplates == original.allowMemberTemplates ? nil : allowMemberTemplates ) } + + func update( + comparedTo workspace: DocmostWorkspace, + availableFeatures: Set + ) -> WorkspaceUpdate { + var update = update(comparedTo: workspace) + + if availableFeatures.contains(.sharingControls) == false { + update.disablePublicSharing = nil + } + if availableFeatures.contains(.apiKeys) == false { + update.restrictApiToAdmins = nil + } + if availableFeatures.contains(.retention) == false { + update.trashRetentionDays = nil + } + if availableFeatures.contains(.templates) == false { + update.allowMemberTemplates = nil + } + if availableFeatures.contains(.artificialIntelligence) == false { + update.aiSearch = nil + update.generativeAi = nil + } + if availableFeatures.contains(.mcp) == false { + update.mcpEnabled = nil + } + + return update + } } diff --git a/docmostly/Features/Settings/WorkspaceSettingsView.swift b/docmostly/Features/Settings/WorkspaceSettingsView.swift index 3d9c1e99..9ffd8f25 100644 --- a/docmostly/Features/Settings/WorkspaceSettingsView.swift +++ b/docmostly/Features/Settings/WorkspaceSettingsView.swift @@ -28,20 +28,37 @@ struct WorkspaceSettingsView: View { Section("Security") { Toggle("Disable public sharing", isOn: $viewModel.workspaceDraft.disablePublicSharing) + .disabled(viewModel.hasWorkspaceFeature(.sharingControls) == false) Toggle("Restrict API keys to admins", isOn: $viewModel.workspaceDraft.restrictApiToAdmins) + .disabled(viewModel.hasWorkspaceFeature(.apiKeys) == false) Stepper( "Trash retention: \(viewModel.workspaceDraft.trashRetentionDays.formatted(.number)) days", value: $viewModel.workspaceDraft.trashRetentionDays, in: 1...365 ) + .disabled(viewModel.hasWorkspaceFeature(.retention) == false) } .disabled(viewModel.canManageWorkspace == false) - Section("Workspace Features") { + Section { Toggle("Member templates", isOn: $viewModel.workspaceDraft.allowMemberTemplates) + .disabled(viewModel.hasWorkspaceFeature(.templates) == false) Toggle("AI search", isOn: $viewModel.workspaceDraft.aiSearch) + .disabled(viewModel.hasWorkspaceFeature(.artificialIntelligence) == false) Toggle("Generative AI", isOn: $viewModel.workspaceDraft.generativeAi) + .disabled(viewModel.hasWorkspaceFeature(.artificialIntelligence) == false) Toggle("MCP", isOn: $viewModel.workspaceDraft.mcpEnabled) + .disabled(viewModel.hasWorkspaceFeature(.mcp) == false) + } header: { + Text("Workspace Features") + } footer: { + if viewModel.isLoading == false { + if viewModel.workspaceEntitlements == nil { + Text("Feature availability could not be verified. Licensed controls are unavailable.") + } else if viewModel.hasUnavailableLicensedSettings { + Text("Unavailable settings require a compatible workspace plan or license.") + } + } } .disabled(viewModel.canManageWorkspace == false) @@ -68,10 +85,10 @@ struct WorkspaceSettingsView: View { } private var canSave: Bool { - guard let workspace = viewModel.workspace else { return false } + guard viewModel.workspace != nil else { return false } return viewModel.canManageWorkspace && viewModel.workspaceDraft.validationMessage == nil && - viewModel.workspaceDraft.hasChanges(comparedTo: workspace) && + viewModel.hasWorkspaceChanges && viewModel.isSaving == false } diff --git a/docmostly/Features/Spaces/SidebarPageBrowserSection.swift b/docmostly/Features/Spaces/SidebarPageBrowserSection.swift new file mode 100644 index 00000000..9900ef67 --- /dev/null +++ b/docmostly/Features/Spaces/SidebarPageBrowserSection.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SidebarPageBrowserSection: View { + @Bindable var viewModel: PageBrowserViewModel + + var body: some View { + Group { + PageBrowserScopeSwitch(viewModel: viewModel) + .contentMargins( + .horizontal, + PageBrowserMetrics.sidebarScopeSwitchSidePadding, + for: .scrollContent + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + + Section { + if viewModel.isLoading && viewModel.items.isEmpty { + ProgressView(viewModel.selectedScope.loadingTitle) + .frame(maxWidth: .infinity) + } else if viewModel.items.isEmpty { + ContentUnavailableView( + viewModel.selectedScope.emptyTitle, + systemImage: viewModel.selectedScope.emptySystemImage + ) + } else { + ForEach(viewModel.items) { item in + PageOpenLink(target: PageOpenTarget(item: item)) { + PageBrowserRowView(item: item) + } + .listRowInsets(PageBrowserMetrics.rowInsets) + } + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(DocmostlyTheme.destructive) + } + } + } + } +} diff --git a/docmostly/Features/Spaces/SidebarRootView.swift b/docmostly/Features/Spaces/SidebarRootView.swift index 5b5467b8..78f79177 100644 --- a/docmostly/Features/Spaces/SidebarRootView.swift +++ b/docmostly/Features/Spaces/SidebarRootView.swift @@ -3,27 +3,10 @@ import SwiftUI struct SidebarRootView: View { @Environment(AppState.self) private var appState @Environment(NotificationStore.self) private var notificationStore + @State private var pageBrowserViewModel = PageBrowserViewModel() var body: some View { List(selection: sidebarSelection) { - Section { - NavigationLink(value: SidebarDestination.favorites) { - Label("Favorites", systemImage: "star") - } - NavigationLink(value: SidebarDestination.notifications) { - 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) - } - } - Section("Spaces") { ForEach(appState.spaces) { space in NavigationLink(value: SidebarDestination.space(space.id)) { @@ -37,6 +20,8 @@ struct SidebarRootView: View { } } + SidebarPageBrowserSection(viewModel: pageBrowserViewModel) + if appState.isOffline { OfflineBadgeView(text: "Offline") .listRowSeparator(.hidden) @@ -49,8 +34,13 @@ struct SidebarRootView: View { .toolbar(content: toolbarContent) .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) .refreshable { - await appState.loadSpaces() + _ = await appState.loadSpaces() + await loadPageBrowser() } + .task(id: pageBrowserTaskKey) { + await loadPageBrowser() + } + .pageOpenDestination() } private var sidebarSelection: Binding { @@ -69,16 +59,57 @@ struct SidebarRootView: View { #endif } + private var pageBrowserTaskKey: SidebarPageBrowserTaskKey { + SidebarPageBrowserTaskKey( + spaceIDs: appState.spaces.map(\.id), + scope: pageBrowserViewModel.selectedScope, + pageDiscoveryRevision: appState.pageDiscoveryRevision, + favoriteRevision: appState.favoriteRevision + ) + } + + private func loadPageBrowser() async { + await pageBrowserViewModel.load(spaces: appState.spaces, provider: appState) + } + @ToolbarContentBuilder private func toolbarContent() -> some ToolbarContent { #if os(iOS) ToolbarItem(placement: .topBarLeading) { WorkspaceAccountMenu() } + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Favorites", systemImage: "star") { + appState.selectSidebarUtilityDestination(.favorites) + } + + Button( + "Notifications", + systemImage: notificationStore.unreadCount > 0 ? "bell.badge" : "bell" + ) { + appState.selectSidebarUtilityDestination(.notifications) + } + .accessibilityValue(notificationAccessibilityValue) + } #else ToolbarItem(placement: .primaryAction) { WorkspaceAccountMenu() } #endif } + + private var notificationAccessibilityValue: String { + if notificationStore.unreadCount == 0 { + return "No unread notifications" + } + + return "\(notificationStore.unreadCount) unread" + } +} + +private struct SidebarPageBrowserTaskKey: Hashable { + let spaceIDs: [String] + let scope: PageBrowserScope + let pageDiscoveryRevision: Int + let favoriteRevision: Int } diff --git a/docmostly/Networking/DocmostWorkspaceEntitlements.swift b/docmostly/Networking/DocmostWorkspaceEntitlements.swift new file mode 100644 index 00000000..c55fde55 --- /dev/null +++ b/docmostly/Networking/DocmostWorkspaceEntitlements.swift @@ -0,0 +1,11 @@ +import Foundation + +nonisolated struct DocmostWorkspaceEntitlements: Decodable, Equatable, Sendable { + let cloud: Bool + let tier: String + let features: Set + + func contains(_ feature: DocmostWorkspaceFeature) -> Bool { + features.contains(feature.rawValue) + } +} diff --git a/docmostly/Networking/DocmostWorkspaceFeature.swift b/docmostly/Networking/DocmostWorkspaceFeature.swift new file mode 100644 index 00000000..b047efe3 --- /dev/null +++ b/docmostly/Networking/DocmostWorkspaceFeature.swift @@ -0,0 +1,10 @@ +import Foundation + +nonisolated enum DocmostWorkspaceFeature: String, CaseIterable, Hashable, Sendable { + case apiKeys = "api:keys" + case artificialIntelligence = "ai" + case mcp + case retention + case sharingControls = "sharing:controls" + case templates +} diff --git a/docmostly/Networking/Endpoint.swift b/docmostly/Networking/Endpoint.swift index 24f4e9b7..4d5bf0f1 100644 --- a/docmostly/Networking/Endpoint.swift +++ b/docmostly/Networking/Endpoint.swift @@ -111,6 +111,7 @@ nonisolated enum Endpoint: Sendable { case updatePage( pageId: String, title: String? = nil, + icon: String? = nil, content: ProseMirrorDocument? = nil, format: ContentFormat = .json, operation: ContentOperation = .replace @@ -141,6 +142,7 @@ nonisolated enum Endpoint: Sendable { case pageRestrictionInfo(pageId: String) case pagePermissions(pageId: String, cursor: String? = nil, limit: Int = 50) case workspaceInfo + case workspaceEntitlements case updateWorkspace(WorkspaceUpdate) case workspaceMembers(query: String? = nil, cursor: String? = nil, limit: Int = 50) case deactivateWorkspaceMember(userId: String) @@ -319,6 +321,8 @@ nonisolated enum Endpoint: Sendable { "pages/permissions" case .workspaceInfo: "workspace/info" + case .workspaceEntitlements: + "workspace/entitlements" case .updateWorkspace: "workspace/update" case .workspaceMembers: @@ -364,7 +368,7 @@ nonisolated enum Endpoint: Sendable { // swiftlint:disable cyclomatic_complexity function_body_length private func bodyData() throws -> Data? { switch self { - case .workspacePublic, .logout, .collabToken, .currentUser, .workspaceInfo, + case .workspacePublic, .logout, .collabToken, .currentUser, .workspaceInfo, .workspaceEntitlements, .unreadNotificationCount, .markAllNotificationsRead, .watchedSpaceIds: return nil case .login(let email, let password): @@ -523,7 +527,7 @@ nonisolated enum Endpoint: Sendable { )) case .createBase(let parentPageId, let template): return try encode(CreateBaseRequest(parentPageId: parentPageId, template: template)) - case .updatePage(let pageId, let title, let content, let format, let operation): + case .updatePage(let pageId, let title, let icon, let content, let format, let operation): let hasContent: Bool if case .some = content { hasContent = true @@ -533,6 +537,7 @@ nonisolated enum Endpoint: Sendable { return try encode(UpdatePageRequest( pageId: pageId, title: title, + icon: icon, content: content, operation: hasContent ? operation.rawValue : nil, format: hasContent ? format.rawValue : nil @@ -872,6 +877,7 @@ nonisolated private struct CreateBaseRequest: Encodable { nonisolated private struct UpdatePageRequest: Encodable { let pageId: String let title: String? + let icon: String? let content: ProseMirrorDocument? let operation: String? let format: String? diff --git a/docmostly/Persistence/CacheRepository.swift b/docmostly/Persistence/CacheRepository.swift index 3c14f32d..a1579918 100644 --- a/docmostly/Persistence/CacheRepository.swift +++ b/docmostly/Persistence/CacheRepository.swift @@ -460,3 +460,59 @@ nonisolated final class CacheRepository { } } } + +nonisolated extension CacheRepository { + func upsertEditablePageMetadata(_ page: DocmostEditablePage, scope: CacheScope) throws { + if let cachedPage = try loadPage(idOrSlugId: page.id, scope: scope) { + cachedPage.updateMetadata(editablePage: page) + } else { + context.insert(CachedPage(editablePageMetadata: page, scope: scope)) + } + + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + let pageID = page.id + let descriptor = FetchDescriptor( + predicate: #Predicate { item in + item.cacheServerBaseURL == serverBaseURL && + item.cacheUserID == userID && + item.id == pageID + } + ) + for item in try context.fetch(descriptor) where item.icon != page.icon { + item.icon = page.icon + item.cachedAt = .now + } + try saveIfNeeded(true) + } + + func updatePageIcon(pageID: String, icon: String?, updatedAt: Date?, scope: CacheScope) throws { + var hasChanges = false + if let page = try loadPage(idOrSlugId: pageID, scope: scope) { + if page.icon != icon || (updatedAt != nil && page.updatedAt != updatedAt) { + page.icon = icon + if let updatedAt { + page.updatedAt = updatedAt + } + page.cachedAt = .now + hasChanges = true + } + } + + let serverBaseURL = scope.serverBaseURL + let userID = scope.userID + let descriptor = FetchDescriptor( + predicate: #Predicate { item in + item.cacheServerBaseURL == serverBaseURL && + item.cacheUserID == userID && + item.id == pageID + } + ) + for item in try context.fetch(descriptor) where item.icon != icon { + item.icon = icon + item.cachedAt = .now + hasChanges = true + } + try saveIfNeeded(hasChanges) + } +} diff --git a/docmostly/Persistence/CacheWriteRepository.swift b/docmostly/Persistence/CacheWriteRepository.swift index a92127b2..385b9cee 100644 --- a/docmostly/Persistence/CacheWriteRepository.swift +++ b/docmostly/Persistence/CacheWriteRepository.swift @@ -6,6 +6,8 @@ nonisolated enum CacheWriteOperation: Sendable { case savePageTree(spaceId: String, parentPageId: String?, pages: [DocmostPage], scope: CacheScope) case savePage(DocmostPage, htmlContent: String, scope: CacheScope) case saveEditablePage(DocmostEditablePage, scope: CacheScope) + case upsertEditablePageMetadata(DocmostEditablePage, scope: CacheScope) + case updatePageIcon(pageID: String, icon: String?, updatedAt: Date?, scope: CacheScope) case saveAttachmentLinks(pageId: String, links: [DocmostAttachmentLink], scope: CacheScope) case markOpened(idOrSlugId: String, scope: CacheScope) case clearAll @@ -69,6 +71,10 @@ nonisolated extension CacheWriteOperation { try repository.savePage(page, htmlContent: htmlContent, scope: scope) case let .saveEditablePage(page, scope): try repository.saveEditablePage(page, scope: scope) + case let .upsertEditablePageMetadata(page, scope): + try repository.upsertEditablePageMetadata(page, scope: scope) + case let .updatePageIcon(pageID, icon, updatedAt, scope): + try repository.updatePageIcon(pageID: pageID, icon: icon, updatedAt: updatedAt, scope: scope) case let .saveAttachmentLinks(pageId, links, scope): try repository.saveAttachmentLinks(links, pageId: pageId, scope: scope) case let .markOpened(idOrSlugId, scope): diff --git a/docmostly/Persistence/CachedPage.swift b/docmostly/Persistence/CachedPage.swift index d773fe65..03092212 100644 --- a/docmostly/Persistence/CachedPage.swift +++ b/docmostly/Persistence/CachedPage.swift @@ -65,6 +65,22 @@ final class CachedPage { lastOpenedAt = cachedAt } + init(editablePageMetadata: DocmostEditablePage, scope: CacheScope, cachedAt: Date = Date.now) { + cacheServerBaseURL = scope.serverBaseURL + cacheUserID = scope.userID + id = editablePageMetadata.id + slugId = editablePageMetadata.slugId + title = editablePageMetadata.title + proseMirrorJSONData = try? JSONEncoder().encode(ProseMirrorDocument()) + icon = editablePageMetadata.icon + spaceId = editablePageMetadata.spaceId + updatedAt = editablePageMetadata.updatedAt + canEdit = editablePageMetadata.permissions?.canEdit + hasRestriction = editablePageMetadata.permissions?.hasRestriction + self.cachedAt = cachedAt + lastOpenedAt = cachedAt + } + func update(page: DocmostPage, htmlContent: String) { id = page.id slugId = page.slugId @@ -116,6 +132,22 @@ final class CachedPage { cachedAt = Date.now } + func updateMetadata(editablePage: DocmostEditablePage) { + id = editablePage.id + slugId = editablePage.slugId + title = editablePage.title + icon = editablePage.icon + spaceId = editablePage.spaceId + if let updatedAt = editablePage.updatedAt { + self.updatedAt = updatedAt + } + if let permissions = editablePage.permissions { + canEdit = permissions.canEdit + hasRestriction = permissions.hasRestriction + } + cachedAt = .now + } + func updateLocalDraft(title: String, document: ProseMirrorDocument) throws { let data = try JSONEncoder().encode(document) self.title = title diff --git a/docmostly/Persistence/DocmostlyModelContainer.swift b/docmostly/Persistence/DocmostlyModelContainer.swift index 8cc7479c..3d8a823c 100644 --- a/docmostly/Persistence/DocmostlyModelContainer.swift +++ b/docmostly/Persistence/DocmostlyModelContainer.swift @@ -7,6 +7,9 @@ enum DocmostlyModelContainer { CachedPageTreeItem.self, CachedPage.self, CachedCRDTDocument.self, + StoredDocument.self, + StoredDocumentUpdate.self, + StoredDocumentPeerState.self, CachedAttachment.self, QueuedOfflineMutation.self ]) diff --git a/docmostly/Persistence/DocumentCompactionPolicy.swift b/docmostly/Persistence/DocumentCompactionPolicy.swift new file mode 100644 index 00000000..08cd2170 --- /dev/null +++ b/docmostly/Persistence/DocumentCompactionPolicy.swift @@ -0,0 +1,16 @@ +import Foundation + +nonisolated struct DocumentCompactionPolicy: Equatable, Sendable { + static let production = DocumentCompactionPolicy(updateCount: 100, byteCount: 1_000_000) + + let updateCount: Int + let byteCount: Int + + func shouldCompact(_ metrics: DocumentStoreMetrics) -> Bool { + metrics.uncompactedUpdateCount >= updateCount || metrics.uncompactedByteCount >= byteCount + } +} + +protocol DocumentCompactionFaultInjector: Sendable { + func beforeCompactionCommit() async throws +} diff --git a/docmostly/Persistence/DocumentLegacyMigrationCandidate.swift b/docmostly/Persistence/DocumentLegacyMigrationCandidate.swift new file mode 100644 index 00000000..bbc88d23 --- /dev/null +++ b/docmostly/Persistence/DocumentLegacyMigrationCandidate.swift @@ -0,0 +1,27 @@ +import Foundation + +nonisolated enum DocumentLegacyMigrationSeed: Equatable, Sendable { + case queuedCRDT(stateUpdate: Data) + case queuedProseMirror( + title: String, + document: ProseMirrorDocument, + cachedStateUpdate: Data? + ) + case cachedCRDT(stateUpdate: Data) + case none +} + +nonisolated struct DocumentLegacyMigrationCandidate: Equatable, Sendable { + let seed: DocumentLegacyMigrationSeed + let metadataTitle: String? + let metadataBaseTitle: String? +} + +nonisolated struct DocumentLegacyMigrationCommit: Equatable, Sendable { + let snapshot: Data? + let pendingLocalUpdate: Data? + let retainedDraftTitle: String? + let retainedDraft: ProseMirrorDocument? + let metadataTitle: String? + let metadataBaseTitle: String? +} diff --git a/docmostly/Persistence/DocumentLocalPersistencePeer.swift b/docmostly/Persistence/DocumentLocalPersistencePeer.swift new file mode 100644 index 00000000..fb6753be --- /dev/null +++ b/docmostly/Persistence/DocumentLocalPersistencePeer.swift @@ -0,0 +1,550 @@ +import CryptoKit +import Foundation +import SwiftData + +actor DocumentLocalPersistencePeer { + static let migrationVersion = 1 + + private let modelContainer: ModelContainer + private let compactionFaultInjector: (any DocumentCompactionFaultInjector)? + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init( + modelContainer: ModelContainer, + compactionFaultInjector: (any DocumentCompactionFaultInjector)? = nil + ) { + self.modelContainer = modelContainer + self.compactionFaultInjector = compactionFaultInjector + } + + func load(_ key: DocumentStoreKey) throws -> DocumentStoredState { + let context = ModelContext(modelContainer) + guard let document = try fetchDocument(key, in: context) else { + return DocumentStoredState( + snapshot: nil, + recoverySnapshot: nil, + snapshotSequence: 0, + recoverySnapshotSequence: 0, + updates: [], + lastCommittedSequence: 0, + localClock: 0, + remoteClock: 0, + migrationVersion: 0, + retainedDraftTitle: nil, + retainedDraft: nil + ) + } + + let earliestSnapshotSequence = if document.recoverySnapshot == nil { + document.snapshotSequence + } else { + min(document.snapshotSequence, document.recoverySnapshotSequence) + } + let updates = try fetchUpdates(key, in: context) + .filter { $0.sequence > earliestSnapshotSequence && $0.payload != nil } + .compactMap(Self.storedUpdate) + return DocumentStoredState( + snapshot: document.snapshot, + recoverySnapshot: document.recoverySnapshot, + snapshotSequence: document.snapshotSequence, + recoverySnapshotSequence: document.recoverySnapshotSequence, + updates: updates, + lastCommittedSequence: max(0, document.nextSequence - 1), + localClock: document.localClock, + remoteClock: document.remoteClock, + migrationVersion: document.migrationVersion, + retainedDraftTitle: document.retainedDraftTitle, + retainedDraft: document.retainedDraftData.flatMap { try? decoder.decode( + ProseMirrorDocument.self, + from: $0 + ) } + ) + } + + func retainDraft( + _ document: ProseMirrorDocument, + title: String, + key: DocumentStoreKey + ) throws { + let context = ModelContext(modelContainer) + let storedDocument = try fetchOrInsertDocument(key, in: context) + storedDocument.retainedDraftTitle = title + storedDocument.retainedDraftData = try encoder.encode(document) + storedDocument.retainedDraftUpdatedAt = .now + storedDocument.updatedAt = .now + try context.save() + } + + func clearRetainedDraft(_ key: DocumentStoreKey) throws { + let context = ModelContext(modelContainer) + guard let document = try fetchDocument(key, in: context) else { return } + guard document.retainedDraftData != nil || document.retainedDraftTitle != nil else { return } + document.retainedDraftTitle = nil + document.retainedDraftData = nil + document.retainedDraftUpdatedAt = nil + document.updatedAt = .now + try context.save() + } + + func append( + _ payload: Data, + origin: StoredDocumentUpdateOrigin, + key: DocumentStoreKey + ) throws -> CommittedDocumentUpdate { + let digest = Self.digest(payload) + let context = ModelContext(modelContainer) + if let existing = try fetchUpdates(key, digest: digest, in: context).first { + return CommittedDocumentUpdate( + key: key, + sequence: existing.sequence, + clock: existing.clock, + origin: existing.origin, + digest: digest, + payload: payload, + wasInserted: false + ) + } + + let document = try fetchOrInsertDocument(key, in: context) + let sequence = document.nextSequence + document.nextSequence += 1 + let clock: Int64 + switch origin { + case .local, .migration: + document.localClock += 1 + clock = document.localClock + case .remote: + document.remoteClock += 1 + clock = document.remoteClock + } + + let update = StoredDocumentUpdate( + key: key, + sequence: sequence, + clock: clock, + origin: origin, + digest: digest, + payload: payload, + isPushed: origin == .remote + ) + context.insert(update) + document.updatedAt = .now + if origin == .remote { + let peer = try fetchOrInsertPeerState(key, in: context) + peer.advertisedRemoteClock = max(peer.advertisedRemoteClock, clock) + peer.pulledRemoteClock = max(peer.pulledRemoteClock, clock) + peer.updatedAt = .now + } + try context.save() + + return CommittedDocumentUpdate( + key: key, + sequence: sequence, + clock: clock, + origin: origin, + digest: digest, + payload: payload, + wasInserted: true + ) + } + + func pendingLocalUpdates(_ key: DocumentStoreKey) throws -> [DocumentStoredUpdate] { + try fetchUpdates(key, in: ModelContext(modelContainer)) + .filter { update in + (update.origin == .local || update.origin == .migration) && + update.isPushed == false && + update.payload != nil + } + .compactMap(Self.storedUpdate) + } + + func markPushed(_ payload: Data, key: DocumentStoreKey) throws { + let context = ModelContext(modelContainer) + let digest = Self.digest(payload) + let matches = try fetchUpdates(key, digest: digest, in: context) + guard matches.isEmpty == false else { return } + let safelyCoveredSequence: Int64 + if let document = try fetchDocument(key, in: context), + document.snapshot != nil, + document.recoverySnapshot != nil { + safelyCoveredSequence = min(document.snapshotSequence, document.recoverySnapshotSequence) + } else { + safelyCoveredSequence = 0 + } + + let maximumSequence = matches.reduce(Int64(0)) { result, update in + update.isPushed = true + if update.sequence <= safelyCoveredSequence { + update.payload = nil + } + return max(result, update.sequence) + } + let peer = try fetchOrInsertPeerState(key, in: context) + peer.pushedLocalSequence = max(peer.pushedLocalSequence, maximumSequence) + peer.updatedAt = .now + try context.save() + } + + func markConnected(_ key: DocumentStoreKey) throws { + let context = ModelContext(modelContainer) + let peer = try fetchOrInsertPeerState(key, in: context) + peer.lastConnectedAt = .now + peer.updatedAt = .now + try context.save() + } + + func metrics(_ key: DocumentStoreKey) throws -> DocumentStoreMetrics { + let context = ModelContext(modelContainer) + let document = try fetchDocument(key, in: context) + let updates = try fetchUpdates(key, in: context).filter { update in + update.sequence > (document?.snapshotSequence ?? 0) && update.payload != nil + } + return DocumentStoreMetrics( + uncompactedUpdateCount: updates.count, + uncompactedByteCount: updates.reduce(0) { $0 + ($1.payload?.count ?? 0) }, + lastCommittedSequence: max(0, (document?.nextSequence ?? 1) - 1) + ) + } + + func compact(_ key: DocumentStoreKey, snapshot: Data, through sequence: Int64) async throws { + guard snapshot.isEmpty == false else { return } + let context = ModelContext(modelContainer) + guard let document = try fetchDocument(key, in: context) else { return } + guard sequence >= document.snapshotSequence else { return } + + let updates = try fetchUpdates(key, in: context) + if let previousSnapshot = document.snapshot { + document.recoverySnapshot = previousSnapshot + document.recoverySnapshotSequence = document.snapshotSequence + } else { + document.recoverySnapshot = snapshot + document.recoverySnapshotSequence = sequence + } + document.snapshot = snapshot + document.snapshotSequence = sequence + document.compactedAt = .now + document.updatedAt = .now + let safelyCoveredSequence = min(document.snapshotSequence, document.recoverySnapshotSequence) + for update in updates + where update.sequence <= safelyCoveredSequence && (update.isPushed || update.origin == .remote) { + update.payload = nil + } + + try await compactionFaultInjector?.beforeCompactionCommit() + try context.save() + } + + func legacyMigrationCandidate(_ key: DocumentStoreKey) throws -> DocumentLegacyMigrationCandidate? { + let context = ModelContext(modelContainer) + if let document = try fetchDocument(key, in: context), + document.migrationVersion >= Self.migrationVersion { + return nil + } + + let queued = try legacyPageMutations(key, in: context) + let latestPayload = queued.sorted { $0.updatedAt < $1.updatedAt }.last.flatMap { mutation in + try? decoder.decode(OfflineMutationPayload.self, from: mutation.payloadData) + } + let metadata = Self.metadata(from: latestPayload) + + if case .updatePageCRDT(_, _, _, let stateUpdate, _)? = latestPayload { + return DocumentLegacyMigrationCandidate( + seed: .queuedCRDT(stateUpdate: stateUpdate), + metadataTitle: metadata.title, + metadataBaseTitle: metadata.baseTitle + ) + } + if case .updatePage(_, let title, let document, _, _)? = latestPayload { + let cachedStateUpdate = try fetchLegacyCRDT(key, in: context)?.stateUpdate + return DocumentLegacyMigrationCandidate( + seed: .queuedProseMirror( + title: title, + document: document, + cachedStateUpdate: cachedStateUpdate?.isEmpty == false ? cachedStateUpdate : nil + ), + metadataTitle: metadata.title, + metadataBaseTitle: metadata.baseTitle + ) + } + if let cached = try fetchLegacyCRDT(key, in: context), cached.stateUpdate.isEmpty == false { + return DocumentLegacyMigrationCandidate( + seed: .cachedCRDT(stateUpdate: cached.stateUpdate), + metadataTitle: metadata.title, + metadataBaseTitle: metadata.baseTitle + ) + } + return DocumentLegacyMigrationCandidate( + seed: .none, + metadataTitle: metadata.title, + metadataBaseTitle: metadata.baseTitle + ) + } + + @discardableResult + func commitLegacyMigration( + _ key: DocumentStoreKey, + migration: DocumentLegacyMigrationCommit + ) throws -> Bool { + let context = ModelContext(modelContainer) + let document = try fetchOrInsertDocument(key, in: context) + guard document.migrationVersion < Self.migrationVersion else { return false } + + let existingUpdates = try fetchUpdates(key, in: context) + let alreadySeeded = document.snapshot != nil || existingUpdates.isEmpty == false + if alreadySeeded == false { + if let snapshot = migration.snapshot, snapshot.isEmpty == false { + document.snapshot = snapshot + document.recoverySnapshot = snapshot + document.recoverySnapshotSequence = document.snapshotSequence + } + if let pendingLocalUpdate = migration.pendingLocalUpdate, pendingLocalUpdate.isEmpty == false { + document.localClock += 1 + let update = StoredDocumentUpdate( + key: key, + sequence: document.nextSequence, + clock: document.localClock, + origin: .migration, + digest: Self.digest(pendingLocalUpdate), + payload: pendingLocalUpdate, + isPushed: false + ) + document.nextSequence += 1 + context.insert(update) + } + if let retainedDraft = migration.retainedDraft { + document.retainedDraftTitle = migration.retainedDraftTitle + document.retainedDraftData = try encoder.encode(retainedDraft) + document.retainedDraftUpdatedAt = .now + } + } + + try replaceLegacyBodyMutations( + key, + title: migration.metadataTitle, + baseTitle: migration.metadataBaseTitle, + in: context + ) + if let cached = try fetchLegacyCRDT(key, in: context) { + context.delete(cached) + } + document.migrationVersion = Self.migrationVersion + document.updatedAt = .now + try context.save() + return true + } +} + +private extension DocumentLocalPersistencePeer { + struct LegacyMetadata { + let title: String? + let baseTitle: String? + } + + static func storedUpdate(_ update: StoredDocumentUpdate) -> DocumentStoredUpdate? { + guard let payload = update.payload else { return nil } + return DocumentStoredUpdate( + sequence: update.sequence, + clock: update.clock, + origin: update.origin, + digest: update.digest, + payload: payload, + isPushed: update.isPushed + ) + } + + static func digest(_ data: Data) -> String { + let digits = Array("0123456789abcdef".utf8) + let bytes = SHA256.hash(data: data).flatMap { byte in + [digits[Int(byte >> 4)], digits[Int(byte & 0x0f)]] + } + return String(bytes: bytes, encoding: .utf8) ?? "" + } + + static func metadata(from payload: OfflineMutationPayload?) -> LegacyMetadata { + guard let payload else { + return LegacyMetadata(title: nil, baseTitle: nil) + } + switch payload { + case .updatePage(_, let title, _, let baseTitle, _), + .updatePageCRDT(_, let title, _, _, let baseTitle), + .updatePageMetadata(_, let title, let baseTitle): + return LegacyMetadata(title: title, baseTitle: baseTitle) + default: + return LegacyMetadata(title: nil, baseTitle: nil) + } + } + + func fetchDocument(_ key: DocumentStoreKey, in context: ModelContext) throws -> StoredDocument? { + let schemaVersion = key.schemaVersion + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let workspaceID = key.workspaceID + let pageID = key.pageID + var descriptor = FetchDescriptor(predicate: #Predicate { document in + document.schemaVersion == schemaVersion && + document.serverBaseURL == serverBaseURL && + document.userID == userID && + document.workspaceID == workspaceID && + document.pageID == pageID + }) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + func fetchOrInsertDocument(_ key: DocumentStoreKey, in context: ModelContext) throws -> StoredDocument { + if let document = try fetchDocument(key, in: context) { + return document + } + let document = StoredDocument(key: key) + context.insert(document) + return document + } + + func fetchUpdates( + _ key: DocumentStoreKey, + digest: String? = nil, + in context: ModelContext + ) throws -> [StoredDocumentUpdate] { + let schemaVersion = key.schemaVersion + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let workspaceID = key.workspaceID + let pageID = key.pageID + let descriptor: FetchDescriptor + if let digest { + descriptor = FetchDescriptor( + predicate: #Predicate { update in + update.schemaVersion == schemaVersion && + update.serverBaseURL == serverBaseURL && + update.userID == userID && + update.workspaceID == workspaceID && + update.pageID == pageID && + update.digest == digest + }, + sortBy: [SortDescriptor(\.sequence)] + ) + } else { + descriptor = FetchDescriptor( + predicate: #Predicate { update in + update.schemaVersion == schemaVersion && + update.serverBaseURL == serverBaseURL && + update.userID == userID && + update.workspaceID == workspaceID && + update.pageID == pageID + }, + sortBy: [SortDescriptor(\.sequence)] + ) + } + return try context.fetch(descriptor) + } + + func fetchOrInsertPeerState( + _ key: DocumentStoreKey, + in context: ModelContext + ) throws -> StoredDocumentPeerState { + let schemaVersion = key.schemaVersion + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let workspaceID = key.workspaceID + let pageID = key.pageID + let peerID = "collaboration" + var descriptor = FetchDescriptor(predicate: #Predicate { peer in + peer.schemaVersion == schemaVersion && + peer.serverBaseURL == serverBaseURL && + peer.userID == userID && + peer.workspaceID == workspaceID && + peer.pageID == pageID && + peer.peerID == peerID + }) + descriptor.fetchLimit = 1 + if let peer = try context.fetch(descriptor).first { + return peer + } + let peer = StoredDocumentPeerState(key: key, peerID: peerID) + context.insert(peer) + return peer + } + + func fetchLegacyCRDT(_ key: DocumentStoreKey, in context: ModelContext) throws -> CachedCRDTDocument? { + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let pageID = key.pageID + var descriptor = FetchDescriptor(predicate: #Predicate { document in + document.cacheServerBaseURL == serverBaseURL && + document.cacheUserID == userID && + document.pageId == pageID + }) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + func legacyPageMutations( + _ key: DocumentStoreKey, + in context: ModelContext + ) throws -> [QueuedOfflineMutation] { + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let kind = OfflineMutationKind.updatePage.rawValue + let mutations = try context.fetch(FetchDescriptor( + predicate: #Predicate { mutation in + mutation.cacheServerBaseURL == serverBaseURL && + mutation.cacheUserID == userID && + mutation.kindRaw == kind + } + )) + return mutations.filter { mutation in + guard let payload = try? decoder.decode(OfflineMutationPayload.self, from: mutation.payloadData) else { + return false + } + switch payload { + case .updatePage(let pageID, _, _, _, _), + .updatePageCRDT(let pageID, _, _, _, _), + .updatePageMetadata(let pageID, _, _): + return pageID == key.pageID + default: + return false + } + } + } + + func replaceLegacyBodyMutations( + _ key: DocumentStoreKey, + title: String?, + baseTitle: String?, + in context: ModelContext + ) throws { + let mutations = try legacyPageMutations(key, in: context) + let bodyMutations = mutations.filter { mutation in + guard let payload = try? decoder.decode(OfflineMutationPayload.self, from: mutation.payloadData) else { + return false + } + switch payload { + case .updatePage, .updatePageCRDT: + return true + default: + return false + } + } + guard bodyMutations.isEmpty == false else { return } + + let retained = bodyMutations.min { $0.replayOrder < $1.replayOrder } + if let retained, let title { + let payload = OfflineMutationPayload.updatePageMetadata( + pageId: key.pageID, + title: title, + baseTitle: baseTitle + ) + retained.kindRaw = payload.kind.rawValue + retained.coalescingKey = payload.coalescingKey + retained.payloadData = try encoder.encode(payload) + retained.updatedAt = .now + } else if let retained { + context.delete(retained) + } + + for mutation in bodyMutations where mutation.id != retained?.id { + context.delete(mutation) + } + } +} diff --git a/docmostly/Persistence/DocumentStoreKey.swift b/docmostly/Persistence/DocumentStoreKey.swift new file mode 100644 index 00000000..8444b7c1 --- /dev/null +++ b/docmostly/Persistence/DocumentStoreKey.swift @@ -0,0 +1,25 @@ +import Foundation + +nonisolated struct DocumentStoreKey: Codable, Hashable, Sendable { + static let currentSchemaVersion = 1 + + let schemaVersion: Int + let serverBaseURL: String + let userID: String + let workspaceID: String + let pageID: String + + init( + schemaVersion: Int = Self.currentSchemaVersion, + serverBaseURL: String, + userID: String, + workspaceID: String, + pageID: String + ) { + self.schemaVersion = schemaVersion + self.serverBaseURL = serverBaseURL + self.userID = userID + self.workspaceID = workspaceID + self.pageID = pageID + } +} diff --git a/docmostly/Persistence/DocumentStoreState.swift b/docmostly/Persistence/DocumentStoreState.swift new file mode 100644 index 00000000..07dbb786 --- /dev/null +++ b/docmostly/Persistence/DocumentStoreState.swift @@ -0,0 +1,44 @@ +import Foundation + +nonisolated struct DocumentStoredUpdate: Equatable, Sendable { + let sequence: Int64 + let clock: Int64 + let origin: StoredDocumentUpdateOrigin + let digest: String + let payload: Data + let isPushed: Bool +} + +nonisolated struct DocumentStoredState: Equatable, Sendable { + let snapshot: Data? + let recoverySnapshot: Data? + let snapshotSequence: Int64 + let recoverySnapshotSequence: Int64 + let updates: [DocumentStoredUpdate] + let lastCommittedSequence: Int64 + let localClock: Int64 + let remoteClock: Int64 + let migrationVersion: Int + let retainedDraftTitle: String? + let retainedDraft: ProseMirrorDocument? + + var hasLocalState: Bool { + snapshot != nil || recoverySnapshot != nil || updates.isEmpty == false + } +} + +nonisolated struct CommittedDocumentUpdate: Equatable, Sendable { + let key: DocumentStoreKey + let sequence: Int64 + let clock: Int64 + let origin: StoredDocumentUpdateOrigin + let digest: String + let payload: Data + let wasInserted: Bool +} + +nonisolated struct DocumentStoreMetrics: Equatable, Sendable { + let uncompactedUpdateCount: Int + let uncompactedByteCount: Int + let lastCommittedSequence: Int64 +} diff --git a/docmostly/Persistence/DocumentUpdateIndexer.swift b/docmostly/Persistence/DocumentUpdateIndexer.swift new file mode 100644 index 00000000..e3a6a913 --- /dev/null +++ b/docmostly/Persistence/DocumentUpdateIndexer.swift @@ -0,0 +1,9 @@ +import Foundation + +protocol DocumentUpdateIndexer: Sendable { + func documentUpdateCommitted(_ update: CommittedDocumentUpdate) async +} + +actor NoopDocumentUpdateIndexer: DocumentUpdateIndexer { + func documentUpdateCommitted(_ update: CommittedDocumentUpdate) { } +} diff --git a/docmostly/Persistence/OfflineMutationPayload.swift b/docmostly/Persistence/OfflineMutationPayload.swift index 68940882..ac0a61ed 100644 --- a/docmostly/Persistence/OfflineMutationPayload.swift +++ b/docmostly/Persistence/OfflineMutationPayload.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable:next type_body_length nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { case updatePage( pageId: String, @@ -15,6 +16,7 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { stateUpdate: Data, baseTitle: String? = nil ) + case updatePageMetadata(pageId: String, title: String, baseTitle: String? = nil) case createComment( localId: String, pageId: String, @@ -39,6 +41,7 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case updatePage case updatePageCRDT + case updatePageMetadata case createComment case resolveComment case addPageLabels @@ -81,7 +84,14 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.updatePageCRDT) { + if container.contains(.updatePageMetadata) { + let payload = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .updatePageMetadata) + self = try .updatePageMetadata( + pageId: payload.decode(String.self, forKey: .pageId), + title: payload.decode(String.self, forKey: .title), + baseTitle: payload.decodeIfPresent(String.self, forKey: .baseTitle) + ) + } else if container.contains(.updatePageCRDT) { let payload = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .updatePageCRDT) self = try .updatePageCRDT( pageId: payload.decode(String.self, forKey: .pageId), @@ -184,11 +194,16 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { } } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { + case .updatePageMetadata(let pageId, let title, let baseTitle): + var payload = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .updatePageMetadata) + try payload.encode(pageId, forKey: .pageId) + try payload.encode(title, forKey: .title) + try payload.encodeIfPresent(baseTitle, forKey: .baseTitle) case .updatePageCRDT(let pageId, let title, let document, let stateUpdate, let baseTitle): var payload = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .updatePageCRDT) try payload.encode(pageId, forKey: .pageId) @@ -271,7 +286,7 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { var kind: OfflineMutationKind { switch self { - case .updatePage, .updatePageCRDT: + case .updatePage, .updatePageCRDT, .updatePageMetadata: .updatePage case .createComment: .createComment @@ -302,7 +317,8 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { var coalescingKey: String? { switch self { - case .updatePage(let pageId, _, _, _, _), + case .updatePageMetadata(let pageId, _, _), + .updatePage(let pageId, _, _, _, _), .updatePageCRDT(let pageId, _, _, _, _): "\(kind.rawValue):\(pageId)" case .resolveComment(let commentId, _, _): @@ -337,7 +353,7 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { .movePage, .movePageToSpace: true - case .updatePage, .updatePageCRDT, .createComment, .addPageLabels: + case .updatePage, .updatePageCRDT, .updatePageMetadata, .createComment, .addPageLabels: false } } @@ -346,6 +362,8 @@ nonisolated enum OfflineMutationPayload: Codable, Equatable, Sendable { guard mappings.isEmpty == false else { return self } switch self { + case .updatePageMetadata: + return self case .updatePageCRDT(let pageId, let title, let document, let stateUpdate, let baseTitle): var patchedDocument = document var didReplace = false diff --git a/docmostly/Persistence/OfflineMutationQueue.swift b/docmostly/Persistence/OfflineMutationQueue.swift index 2cff4a43..671c4542 100644 --- a/docmostly/Persistence/OfflineMutationQueue.swift +++ b/docmostly/Persistence/OfflineMutationQueue.swift @@ -13,6 +13,7 @@ nonisolated enum OfflinePageUpdateAcknowledgementResult: Equatable, Sendable { case newerPendingUpdatePreserved } +// swiftlint:disable:next type_body_length nonisolated final class OfflineMutationQueue { private let context: ModelContext private let encoder = JSONEncoder() @@ -316,6 +317,12 @@ nonisolated final class OfflineMutationQueue { let existingPayload = try decoder.decode(OfflineMutationPayload.self, from: existingMutation.payloadData) switch payload { + case .updatePageMetadata(let pageId, let title, _): + return .updatePageMetadata( + pageId: pageId, + title: title, + baseTitle: existingPayload.pageUpdateBaseTitle + ) case .updatePage(let pageId, let title, let document, _, let baseDocument): let oldestBaseDocument = if case .updatePage(_, _, _, _, let document) = existingPayload { document @@ -562,7 +569,8 @@ nonisolated extension OfflineMutationQueue { nonisolated private extension OfflineMutationPayload { var pageUpdateBaseTitle: String? { switch self { - case .updatePage(_, _, _, let baseTitle, _), + case .updatePageMetadata(_, _, let baseTitle), + .updatePage(_, _, _, let baseTitle, _), .updatePageCRDT(_, _, _, _, let baseTitle): baseTitle default: diff --git a/docmostly/Persistence/StoredDocument.swift b/docmostly/Persistence/StoredDocument.swift new file mode 100644 index 00000000..486fb9d9 --- /dev/null +++ b/docmostly/Persistence/StoredDocument.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +@Model +final class StoredDocument { + var schemaVersion: Int = DocumentStoreKey.currentSchemaVersion + var serverBaseURL: String = "" + var userID: String = "" + var workspaceID: String = "" + var pageID: String = "" + var snapshot: Data? + var recoverySnapshot: Data? + var snapshotSequence: Int64 = 0 + var recoverySnapshotSequence: Int64 = 0 + var nextSequence: Int64 = 1 + var localClock: Int64 = 0 + var remoteClock: Int64 = 0 + var migrationVersion: Int = 0 + var retainedDraftTitle: String? + var retainedDraftData: Data? + var retainedDraftUpdatedAt: Date? + var compactedAt: Date? + var updatedAt: Date = Date.now + + init(key: DocumentStoreKey) { + schemaVersion = key.schemaVersion + serverBaseURL = key.serverBaseURL + userID = key.userID + workspaceID = key.workspaceID + pageID = key.pageID + } +} diff --git a/docmostly/Persistence/StoredDocumentPeerState.swift b/docmostly/Persistence/StoredDocumentPeerState.swift new file mode 100644 index 00000000..210c58f8 --- /dev/null +++ b/docmostly/Persistence/StoredDocumentPeerState.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +@Model +final class StoredDocumentPeerState { + var schemaVersion: Int = DocumentStoreKey.currentSchemaVersion + var serverBaseURL: String = "" + var userID: String = "" + var workspaceID: String = "" + var pageID: String = "" + var peerID: String = "collaboration" + var advertisedRemoteClock: Int64 = 0 + var pulledRemoteClock: Int64 = 0 + var pushedLocalSequence: Int64 = 0 + var lastConnectedAt: Date? + var updatedAt: Date = Date.now + + init(key: DocumentStoreKey, peerID: String = "collaboration") { + schemaVersion = key.schemaVersion + serverBaseURL = key.serverBaseURL + userID = key.userID + workspaceID = key.workspaceID + pageID = key.pageID + self.peerID = peerID + } +} diff --git a/docmostly/Persistence/StoredDocumentUpdate.swift b/docmostly/Persistence/StoredDocumentUpdate.swift new file mode 100644 index 00000000..d78b2812 --- /dev/null +++ b/docmostly/Persistence/StoredDocumentUpdate.swift @@ -0,0 +1,52 @@ +import Foundation +import SwiftData + +nonisolated enum StoredDocumentUpdateOrigin: String, Codable, Sendable { + case local + case remote + case migration +} + +@Model +final class StoredDocumentUpdate { + var schemaVersion: Int = DocumentStoreKey.currentSchemaVersion + var serverBaseURL: String = "" + var userID: String = "" + var workspaceID: String = "" + var pageID: String = "" + var sequence: Int64 = 0 + var clock: Int64 = 0 + var originRaw: String = StoredDocumentUpdateOrigin.local.rawValue + var digest: String = "" + var payload: Data? + var isPushed: Bool = false + var committedAt: Date = Date.now + + init( + key: DocumentStoreKey, + sequence: Int64, + clock: Int64, + origin: StoredDocumentUpdateOrigin, + digest: String, + payload: Data, + isPushed: Bool, + committedAt: Date = Date.now + ) { + schemaVersion = key.schemaVersion + serverBaseURL = key.serverBaseURL + userID = key.userID + workspaceID = key.workspaceID + pageID = key.pageID + self.sequence = sequence + self.clock = clock + originRaw = origin.rawValue + self.digest = digest + self.payload = payload + self.isPushed = isPushed + self.committedAt = committedAt + } + + var origin: StoredDocumentUpdateOrigin { + StoredDocumentUpdateOrigin(rawValue: originRaw) ?? .local + } +} diff --git a/docmostly/Resources/emoji-16.0.txt b/docmostly/Resources/emoji-16.0.txt new file mode 100644 index 00000000..ec2bae5c --- /dev/null +++ b/docmostly/Resources/emoji-16.0.txt @@ -0,0 +1,3794 @@ +# Derived from Unicode Emoji 16.0 emoji-test.txt. +# Copyright © 1991-2024 Unicode, Inc. All rights reserved. +# Distributed under the Unicode License v3: https://www.unicode.org/license.txt +# group: Smileys & Emotion +😀 grinning face +😃 grinning face with big eyes +😄 grinning face with smiling eyes +😁 beaming face with smiling eyes +😆 grinning squinting face +😅 grinning face with sweat +🤣 rolling on the floor laughing +😂 face with tears of joy +🙂 slightly smiling face +🙃 upside-down face +🫠 melting face +😉 winking face +😊 smiling face with smiling eyes +😇 smiling face with halo +🥰 smiling face with hearts +😍 smiling face with heart-eyes +🤩 star-struck +😘 face blowing a kiss +😗 kissing face +☺️ smiling face +😚 kissing face with closed eyes +😙 kissing face with smiling eyes +🥲 smiling face with tear +😋 face savoring food +😛 face with tongue +😜 winking face with tongue +🤪 zany face +😝 squinting face with tongue +🤑 money-mouth face +🤗 smiling face with open hands +🤭 face with hand over mouth +🫢 face with open eyes and hand over mouth +🫣 face with peeking eye +🤫 shushing face +🤔 thinking face +🫡 saluting face +🤐 zipper-mouth face +🤨 face with raised eyebrow +😐 neutral face +😑 expressionless face +😶 face without mouth +🫥 dotted line face +😶‍🌫️ face in clouds +😏 smirking face +😒 unamused face +🙄 face with rolling eyes +😬 grimacing face +😮‍💨 face exhaling +🤥 lying face +🫨 shaking face +🙂‍↔️ head shaking horizontally +🙂‍↕️ head shaking vertically +😌 relieved face +😔 pensive face +😪 sleepy face +🤤 drooling face +😴 sleeping face +🫩 face with bags under eyes +😷 face with medical mask +🤒 face with thermometer +🤕 face with head-bandage +🤢 nauseated face +🤮 face vomiting +🤧 sneezing face +🥵 hot face +🥶 cold face +🥴 woozy face +😵 face with crossed-out eyes +😵‍💫 face with spiral eyes +🤯 exploding head +🤠 cowboy hat face +🥳 partying face +🥸 disguised face +😎 smiling face with sunglasses +🤓 nerd face +🧐 face with monocle +😕 confused face +🫤 face with diagonal mouth +😟 worried face +🙁 slightly frowning face +☹️ frowning face +😮 face with open mouth +😯 hushed face +😲 astonished face +😳 flushed face +🥺 pleading face +🥹 face holding back tears +😦 frowning face with open mouth +😧 anguished face +😨 fearful face +😰 anxious face with sweat +😥 sad but relieved face +😢 crying face +😭 loudly crying face +😱 face screaming in fear +😖 confounded face +😣 persevering face +😞 disappointed face +😓 downcast face with sweat +😩 weary face +😫 tired face +🥱 yawning face +😤 face with steam from nose +😡 enraged face +😠 angry face +🤬 face with symbols on mouth +😈 smiling face with horns +👿 angry face with horns +💀 skull +☠️ skull and crossbones +💩 pile of poo +🤡 clown face +👹 ogre +👺 goblin +👻 ghost +👽 alien +👾 alien monster +🤖 robot +😺 grinning cat +😸 grinning cat with smiling eyes +😹 cat with tears of joy +😻 smiling cat with heart-eyes +😼 cat with wry smile +😽 kissing cat +🙀 weary cat +😿 crying cat +😾 pouting cat +🙈 see-no-evil monkey +🙉 hear-no-evil monkey +🙊 speak-no-evil monkey +💌 love letter +💘 heart with arrow +💝 heart with ribbon +💖 sparkling heart +💗 growing heart +💓 beating heart +💞 revolving hearts +💕 two hearts +💟 heart decoration +❣️ heart exclamation +💔 broken heart +❤️‍🔥 heart on fire +❤️‍🩹 mending heart +❤️ red heart +🩷 pink heart +🧡 orange heart +💛 yellow heart +💚 green heart +💙 blue heart +🩵 light blue heart +💜 purple heart +🤎 brown heart +🖤 black heart +🩶 grey heart +🤍 white heart +💋 kiss mark +💯 hundred points +💢 anger symbol +💥 collision +💫 dizzy +💦 sweat droplets +💨 dashing away +🕳️ hole +💬 speech balloon +👁️‍🗨️ eye in speech bubble +🗨️ left speech bubble +🗯️ right anger bubble +💭 thought balloon +💤 ZZZ +# group: People & Body +👋 waving hand +👋🏻 waving hand: light skin tone +👋🏼 waving hand: medium-light skin tone +👋🏽 waving hand: medium skin tone +👋🏾 waving hand: medium-dark skin tone +👋🏿 waving hand: dark skin tone +🤚 raised back of hand +🤚🏻 raised back of hand: light skin tone +🤚🏼 raised back of hand: medium-light skin tone +🤚🏽 raised back of hand: medium skin tone +🤚🏾 raised back of hand: medium-dark skin tone +🤚🏿 raised back of hand: dark skin tone +🖐️ hand with fingers splayed +🖐🏻 hand with fingers splayed: light skin tone +🖐🏼 hand with fingers splayed: medium-light skin tone +🖐🏽 hand with fingers splayed: medium skin tone +🖐🏾 hand with fingers splayed: medium-dark skin tone +🖐🏿 hand with fingers splayed: dark skin tone +✋ raised hand +✋🏻 raised hand: light skin tone +✋🏼 raised hand: medium-light skin tone +✋🏽 raised hand: medium skin tone +✋🏾 raised hand: medium-dark skin tone +✋🏿 raised hand: dark skin tone +🖖 vulcan salute +🖖🏻 vulcan salute: light skin tone +🖖🏼 vulcan salute: medium-light skin tone +🖖🏽 vulcan salute: medium skin tone +🖖🏾 vulcan salute: medium-dark skin tone +🖖🏿 vulcan salute: dark skin tone +🫱 rightwards hand +🫱🏻 rightwards hand: light skin tone +🫱🏼 rightwards hand: medium-light skin tone +🫱🏽 rightwards hand: medium skin tone +🫱🏾 rightwards hand: medium-dark skin tone +🫱🏿 rightwards hand: dark skin tone +🫲 leftwards hand +🫲🏻 leftwards hand: light skin tone +🫲🏼 leftwards hand: medium-light skin tone +🫲🏽 leftwards hand: medium skin tone +🫲🏾 leftwards hand: medium-dark skin tone +🫲🏿 leftwards hand: dark skin tone +🫳 palm down hand +🫳🏻 palm down hand: light skin tone +🫳🏼 palm down hand: medium-light skin tone +🫳🏽 palm down hand: medium skin tone +🫳🏾 palm down hand: medium-dark skin tone +🫳🏿 palm down hand: dark skin tone +🫴 palm up hand +🫴🏻 palm up hand: light skin tone +🫴🏼 palm up hand: medium-light skin tone +🫴🏽 palm up hand: medium skin tone +🫴🏾 palm up hand: medium-dark skin tone +🫴🏿 palm up hand: dark skin tone +🫷 leftwards pushing hand +🫷🏻 leftwards pushing hand: light skin tone +🫷🏼 leftwards pushing hand: medium-light skin tone +🫷🏽 leftwards pushing hand: medium skin tone +🫷🏾 leftwards pushing hand: medium-dark skin tone +🫷🏿 leftwards pushing hand: dark skin tone +🫸 rightwards pushing hand +🫸🏻 rightwards pushing hand: light skin tone +🫸🏼 rightwards pushing hand: medium-light skin tone +🫸🏽 rightwards pushing hand: medium skin tone +🫸🏾 rightwards pushing hand: medium-dark skin tone +🫸🏿 rightwards pushing hand: dark skin tone +👌 OK hand +👌🏻 OK hand: light skin tone +👌🏼 OK hand: medium-light skin tone +👌🏽 OK hand: medium skin tone +👌🏾 OK hand: medium-dark skin tone +👌🏿 OK hand: dark skin tone +🤌 pinched fingers +🤌🏻 pinched fingers: light skin tone +🤌🏼 pinched fingers: medium-light skin tone +🤌🏽 pinched fingers: medium skin tone +🤌🏾 pinched fingers: medium-dark skin tone +🤌🏿 pinched fingers: dark skin tone +🤏 pinching hand +🤏🏻 pinching hand: light skin tone +🤏🏼 pinching hand: medium-light skin tone +🤏🏽 pinching hand: medium skin tone +🤏🏾 pinching hand: medium-dark skin tone +🤏🏿 pinching hand: dark skin tone +✌️ victory hand +✌🏻 victory hand: light skin tone +✌🏼 victory hand: medium-light skin tone +✌🏽 victory hand: medium skin tone +✌🏾 victory hand: medium-dark skin tone +✌🏿 victory hand: dark skin tone +🤞 crossed fingers +🤞🏻 crossed fingers: light skin tone +🤞🏼 crossed fingers: medium-light skin tone +🤞🏽 crossed fingers: medium skin tone +🤞🏾 crossed fingers: medium-dark skin tone +🤞🏿 crossed fingers: dark skin tone +🫰 hand with index finger and thumb crossed +🫰🏻 hand with index finger and thumb crossed: light skin tone +🫰🏼 hand with index finger and thumb crossed: medium-light skin tone +🫰🏽 hand with index finger and thumb crossed: medium skin tone +🫰🏾 hand with index finger and thumb crossed: medium-dark skin tone +🫰🏿 hand with index finger and thumb crossed: dark skin tone +🤟 love-you gesture +🤟🏻 love-you gesture: light skin tone +🤟🏼 love-you gesture: medium-light skin tone +🤟🏽 love-you gesture: medium skin tone +🤟🏾 love-you gesture: medium-dark skin tone +🤟🏿 love-you gesture: dark skin tone +🤘 sign of the horns +🤘🏻 sign of the horns: light skin tone +🤘🏼 sign of the horns: medium-light skin tone +🤘🏽 sign of the horns: medium skin tone +🤘🏾 sign of the horns: medium-dark skin tone +🤘🏿 sign of the horns: dark skin tone +🤙 call me hand +🤙🏻 call me hand: light skin tone +🤙🏼 call me hand: medium-light skin tone +🤙🏽 call me hand: medium skin tone +🤙🏾 call me hand: medium-dark skin tone +🤙🏿 call me hand: dark skin tone +👈 backhand index pointing left +👈🏻 backhand index pointing left: light skin tone +👈🏼 backhand index pointing left: medium-light skin tone +👈🏽 backhand index pointing left: medium skin tone +👈🏾 backhand index pointing left: medium-dark skin tone +👈🏿 backhand index pointing left: dark skin tone +👉 backhand index pointing right +👉🏻 backhand index pointing right: light skin tone +👉🏼 backhand index pointing right: medium-light skin tone +👉🏽 backhand index pointing right: medium skin tone +👉🏾 backhand index pointing right: medium-dark skin tone +👉🏿 backhand index pointing right: dark skin tone +👆 backhand index pointing up +👆🏻 backhand index pointing up: light skin tone +👆🏼 backhand index pointing up: medium-light skin tone +👆🏽 backhand index pointing up: medium skin tone +👆🏾 backhand index pointing up: medium-dark skin tone +👆🏿 backhand index pointing up: dark skin tone +🖕 middle finger +🖕🏻 middle finger: light skin tone +🖕🏼 middle finger: medium-light skin tone +🖕🏽 middle finger: medium skin tone +🖕🏾 middle finger: medium-dark skin tone +🖕🏿 middle finger: dark skin tone +👇 backhand index pointing down +👇🏻 backhand index pointing down: light skin tone +👇🏼 backhand index pointing down: medium-light skin tone +👇🏽 backhand index pointing down: medium skin tone +👇🏾 backhand index pointing down: medium-dark skin tone +👇🏿 backhand index pointing down: dark skin tone +☝️ index pointing up +☝🏻 index pointing up: light skin tone +☝🏼 index pointing up: medium-light skin tone +☝🏽 index pointing up: medium skin tone +☝🏾 index pointing up: medium-dark skin tone +☝🏿 index pointing up: dark skin tone +🫵 index pointing at the viewer +🫵🏻 index pointing at the viewer: light skin tone +🫵🏼 index pointing at the viewer: medium-light skin tone +🫵🏽 index pointing at the viewer: medium skin tone +🫵🏾 index pointing at the viewer: medium-dark skin tone +🫵🏿 index pointing at the viewer: dark skin tone +👍 thumbs up +👍🏻 thumbs up: light skin tone +👍🏼 thumbs up: medium-light skin tone +👍🏽 thumbs up: medium skin tone +👍🏾 thumbs up: medium-dark skin tone +👍🏿 thumbs up: dark skin tone +👎 thumbs down +👎🏻 thumbs down: light skin tone +👎🏼 thumbs down: medium-light skin tone +👎🏽 thumbs down: medium skin tone +👎🏾 thumbs down: medium-dark skin tone +👎🏿 thumbs down: dark skin tone +✊ raised fist +✊🏻 raised fist: light skin tone +✊🏼 raised fist: medium-light skin tone +✊🏽 raised fist: medium skin tone +✊🏾 raised fist: medium-dark skin tone +✊🏿 raised fist: dark skin tone +👊 oncoming fist +👊🏻 oncoming fist: light skin tone +👊🏼 oncoming fist: medium-light skin tone +👊🏽 oncoming fist: medium skin tone +👊🏾 oncoming fist: medium-dark skin tone +👊🏿 oncoming fist: dark skin tone +🤛 left-facing fist +🤛🏻 left-facing fist: light skin tone +🤛🏼 left-facing fist: medium-light skin tone +🤛🏽 left-facing fist: medium skin tone +🤛🏾 left-facing fist: medium-dark skin tone +🤛🏿 left-facing fist: dark skin tone +🤜 right-facing fist +🤜🏻 right-facing fist: light skin tone +🤜🏼 right-facing fist: medium-light skin tone +🤜🏽 right-facing fist: medium skin tone +🤜🏾 right-facing fist: medium-dark skin tone +🤜🏿 right-facing fist: dark skin tone +👏 clapping hands +👏🏻 clapping hands: light skin tone +👏🏼 clapping hands: medium-light skin tone +👏🏽 clapping hands: medium skin tone +👏🏾 clapping hands: medium-dark skin tone +👏🏿 clapping hands: dark skin tone +🙌 raising hands +🙌🏻 raising hands: light skin tone +🙌🏼 raising hands: medium-light skin tone +🙌🏽 raising hands: medium skin tone +🙌🏾 raising hands: medium-dark skin tone +🙌🏿 raising hands: dark skin tone +🫶 heart hands +🫶🏻 heart hands: light skin tone +🫶🏼 heart hands: medium-light skin tone +🫶🏽 heart hands: medium skin tone +🫶🏾 heart hands: medium-dark skin tone +🫶🏿 heart hands: dark skin tone +👐 open hands +👐🏻 open hands: light skin tone +👐🏼 open hands: medium-light skin tone +👐🏽 open hands: medium skin tone +👐🏾 open hands: medium-dark skin tone +👐🏿 open hands: dark skin tone +🤲 palms up together +🤲🏻 palms up together: light skin tone +🤲🏼 palms up together: medium-light skin tone +🤲🏽 palms up together: medium skin tone +🤲🏾 palms up together: medium-dark skin tone +🤲🏿 palms up together: dark skin tone +🤝 handshake +🤝🏻 handshake: light skin tone +🤝🏼 handshake: medium-light skin tone +🤝🏽 handshake: medium skin tone +🤝🏾 handshake: medium-dark skin tone +🤝🏿 handshake: dark skin tone +🫱🏻‍🫲🏼 handshake: light skin tone, medium-light skin tone +🫱🏻‍🫲🏽 handshake: light skin tone, medium skin tone +🫱🏻‍🫲🏾 handshake: light skin tone, medium-dark skin tone +🫱🏻‍🫲🏿 handshake: light skin tone, dark skin tone +🫱🏼‍🫲🏻 handshake: medium-light skin tone, light skin tone +🫱🏼‍🫲🏽 handshake: medium-light skin tone, medium skin tone +🫱🏼‍🫲🏾 handshake: medium-light skin tone, medium-dark skin tone +🫱🏼‍🫲🏿 handshake: medium-light skin tone, dark skin tone +🫱🏽‍🫲🏻 handshake: medium skin tone, light skin tone +🫱🏽‍🫲🏼 handshake: medium skin tone, medium-light skin tone +🫱🏽‍🫲🏾 handshake: medium skin tone, medium-dark skin tone +🫱🏽‍🫲🏿 handshake: medium skin tone, dark skin tone +🫱🏾‍🫲🏻 handshake: medium-dark skin tone, light skin tone +🫱🏾‍🫲🏼 handshake: medium-dark skin tone, medium-light skin tone +🫱🏾‍🫲🏽 handshake: medium-dark skin tone, medium skin tone +🫱🏾‍🫲🏿 handshake: medium-dark skin tone, dark skin tone +🫱🏿‍🫲🏻 handshake: dark skin tone, light skin tone +🫱🏿‍🫲🏼 handshake: dark skin tone, medium-light skin tone +🫱🏿‍🫲🏽 handshake: dark skin tone, medium skin tone +🫱🏿‍🫲🏾 handshake: dark skin tone, medium-dark skin tone +🙏 folded hands +🙏🏻 folded hands: light skin tone +🙏🏼 folded hands: medium-light skin tone +🙏🏽 folded hands: medium skin tone +🙏🏾 folded hands: medium-dark skin tone +🙏🏿 folded hands: dark skin tone +✍️ writing hand +✍🏻 writing hand: light skin tone +✍🏼 writing hand: medium-light skin tone +✍🏽 writing hand: medium skin tone +✍🏾 writing hand: medium-dark skin tone +✍🏿 writing hand: dark skin tone +💅 nail polish +💅🏻 nail polish: light skin tone +💅🏼 nail polish: medium-light skin tone +💅🏽 nail polish: medium skin tone +💅🏾 nail polish: medium-dark skin tone +💅🏿 nail polish: dark skin tone +🤳 selfie +🤳🏻 selfie: light skin tone +🤳🏼 selfie: medium-light skin tone +🤳🏽 selfie: medium skin tone +🤳🏾 selfie: medium-dark skin tone +🤳🏿 selfie: dark skin tone +💪 flexed biceps +💪🏻 flexed biceps: light skin tone +💪🏼 flexed biceps: medium-light skin tone +💪🏽 flexed biceps: medium skin tone +💪🏾 flexed biceps: medium-dark skin tone +💪🏿 flexed biceps: dark skin tone +🦾 mechanical arm +🦿 mechanical leg +🦵 leg +🦵🏻 leg: light skin tone +🦵🏼 leg: medium-light skin tone +🦵🏽 leg: medium skin tone +🦵🏾 leg: medium-dark skin tone +🦵🏿 leg: dark skin tone +🦶 foot +🦶🏻 foot: light skin tone +🦶🏼 foot: medium-light skin tone +🦶🏽 foot: medium skin tone +🦶🏾 foot: medium-dark skin tone +🦶🏿 foot: dark skin tone +👂 ear +👂🏻 ear: light skin tone +👂🏼 ear: medium-light skin tone +👂🏽 ear: medium skin tone +👂🏾 ear: medium-dark skin tone +👂🏿 ear: dark skin tone +🦻 ear with hearing aid +🦻🏻 ear with hearing aid: light skin tone +🦻🏼 ear with hearing aid: medium-light skin tone +🦻🏽 ear with hearing aid: medium skin tone +🦻🏾 ear with hearing aid: medium-dark skin tone +🦻🏿 ear with hearing aid: dark skin tone +👃 nose +👃🏻 nose: light skin tone +👃🏼 nose: medium-light skin tone +👃🏽 nose: medium skin tone +👃🏾 nose: medium-dark skin tone +👃🏿 nose: dark skin tone +🧠 brain +🫀 anatomical heart +🫁 lungs +🦷 tooth +🦴 bone +👀 eyes +👁️ eye +👅 tongue +👄 mouth +🫦 biting lip +👶 baby +👶🏻 baby: light skin tone +👶🏼 baby: medium-light skin tone +👶🏽 baby: medium skin tone +👶🏾 baby: medium-dark skin tone +👶🏿 baby: dark skin tone +🧒 child +🧒🏻 child: light skin tone +🧒🏼 child: medium-light skin tone +🧒🏽 child: medium skin tone +🧒🏾 child: medium-dark skin tone +🧒🏿 child: dark skin tone +👦 boy +👦🏻 boy: light skin tone +👦🏼 boy: medium-light skin tone +👦🏽 boy: medium skin tone +👦🏾 boy: medium-dark skin tone +👦🏿 boy: dark skin tone +👧 girl +👧🏻 girl: light skin tone +👧🏼 girl: medium-light skin tone +👧🏽 girl: medium skin tone +👧🏾 girl: medium-dark skin tone +👧🏿 girl: dark skin tone +🧑 person +🧑🏻 person: light skin tone +🧑🏼 person: medium-light skin tone +🧑🏽 person: medium skin tone +🧑🏾 person: medium-dark skin tone +🧑🏿 person: dark skin tone +👱 person: blond hair +👱🏻 person: light skin tone, blond hair +👱🏼 person: medium-light skin tone, blond hair +👱🏽 person: medium skin tone, blond hair +👱🏾 person: medium-dark skin tone, blond hair +👱🏿 person: dark skin tone, blond hair +👨 man +👨🏻 man: light skin tone +👨🏼 man: medium-light skin tone +👨🏽 man: medium skin tone +👨🏾 man: medium-dark skin tone +👨🏿 man: dark skin tone +🧔 person: beard +🧔🏻 person: light skin tone, beard +🧔🏼 person: medium-light skin tone, beard +🧔🏽 person: medium skin tone, beard +🧔🏾 person: medium-dark skin tone, beard +🧔🏿 person: dark skin tone, beard +🧔‍♂️ man: beard +🧔🏻‍♂️ man: light skin tone, beard +🧔🏼‍♂️ man: medium-light skin tone, beard +🧔🏽‍♂️ man: medium skin tone, beard +🧔🏾‍♂️ man: medium-dark skin tone, beard +🧔🏿‍♂️ man: dark skin tone, beard +🧔‍♀️ woman: beard +🧔🏻‍♀️ woman: light skin tone, beard +🧔🏼‍♀️ woman: medium-light skin tone, beard +🧔🏽‍♀️ woman: medium skin tone, beard +🧔🏾‍♀️ woman: medium-dark skin tone, beard +🧔🏿‍♀️ woman: dark skin tone, beard +👨‍🦰 man: red hair +👨🏻‍🦰 man: light skin tone, red hair +👨🏼‍🦰 man: medium-light skin tone, red hair +👨🏽‍🦰 man: medium skin tone, red hair +👨🏾‍🦰 man: medium-dark skin tone, red hair +👨🏿‍🦰 man: dark skin tone, red hair +👨‍🦱 man: curly hair +👨🏻‍🦱 man: light skin tone, curly hair +👨🏼‍🦱 man: medium-light skin tone, curly hair +👨🏽‍🦱 man: medium skin tone, curly hair +👨🏾‍🦱 man: medium-dark skin tone, curly hair +👨🏿‍🦱 man: dark skin tone, curly hair +👨‍🦳 man: white hair +👨🏻‍🦳 man: light skin tone, white hair +👨🏼‍🦳 man: medium-light skin tone, white hair +👨🏽‍🦳 man: medium skin tone, white hair +👨🏾‍🦳 man: medium-dark skin tone, white hair +👨🏿‍🦳 man: dark skin tone, white hair +👨‍🦲 man: bald +👨🏻‍🦲 man: light skin tone, bald +👨🏼‍🦲 man: medium-light skin tone, bald +👨🏽‍🦲 man: medium skin tone, bald +👨🏾‍🦲 man: medium-dark skin tone, bald +👨🏿‍🦲 man: dark skin tone, bald +👩 woman +👩🏻 woman: light skin tone +👩🏼 woman: medium-light skin tone +👩🏽 woman: medium skin tone +👩🏾 woman: medium-dark skin tone +👩🏿 woman: dark skin tone +👩‍🦰 woman: red hair +👩🏻‍🦰 woman: light skin tone, red hair +👩🏼‍🦰 woman: medium-light skin tone, red hair +👩🏽‍🦰 woman: medium skin tone, red hair +👩🏾‍🦰 woman: medium-dark skin tone, red hair +👩🏿‍🦰 woman: dark skin tone, red hair +🧑‍🦰 person: red hair +🧑🏻‍🦰 person: light skin tone, red hair +🧑🏼‍🦰 person: medium-light skin tone, red hair +🧑🏽‍🦰 person: medium skin tone, red hair +🧑🏾‍🦰 person: medium-dark skin tone, red hair +🧑🏿‍🦰 person: dark skin tone, red hair +👩‍🦱 woman: curly hair +👩🏻‍🦱 woman: light skin tone, curly hair +👩🏼‍🦱 woman: medium-light skin tone, curly hair +👩🏽‍🦱 woman: medium skin tone, curly hair +👩🏾‍🦱 woman: medium-dark skin tone, curly hair +👩🏿‍🦱 woman: dark skin tone, curly hair +🧑‍🦱 person: curly hair +🧑🏻‍🦱 person: light skin tone, curly hair +🧑🏼‍🦱 person: medium-light skin tone, curly hair +🧑🏽‍🦱 person: medium skin tone, curly hair +🧑🏾‍🦱 person: medium-dark skin tone, curly hair +🧑🏿‍🦱 person: dark skin tone, curly hair +👩‍🦳 woman: white hair +👩🏻‍🦳 woman: light skin tone, white hair +👩🏼‍🦳 woman: medium-light skin tone, white hair +👩🏽‍🦳 woman: medium skin tone, white hair +👩🏾‍🦳 woman: medium-dark skin tone, white hair +👩🏿‍🦳 woman: dark skin tone, white hair +🧑‍🦳 person: white hair +🧑🏻‍🦳 person: light skin tone, white hair +🧑🏼‍🦳 person: medium-light skin tone, white hair +🧑🏽‍🦳 person: medium skin tone, white hair +🧑🏾‍🦳 person: medium-dark skin tone, white hair +🧑🏿‍🦳 person: dark skin tone, white hair +👩‍🦲 woman: bald +👩🏻‍🦲 woman: light skin tone, bald +👩🏼‍🦲 woman: medium-light skin tone, bald +👩🏽‍🦲 woman: medium skin tone, bald +👩🏾‍🦲 woman: medium-dark skin tone, bald +👩🏿‍🦲 woman: dark skin tone, bald +🧑‍🦲 person: bald +🧑🏻‍🦲 person: light skin tone, bald +🧑🏼‍🦲 person: medium-light skin tone, bald +🧑🏽‍🦲 person: medium skin tone, bald +🧑🏾‍🦲 person: medium-dark skin tone, bald +🧑🏿‍🦲 person: dark skin tone, bald +👱‍♀️ woman: blond hair +👱🏻‍♀️ woman: light skin tone, blond hair +👱🏼‍♀️ woman: medium-light skin tone, blond hair +👱🏽‍♀️ woman: medium skin tone, blond hair +👱🏾‍♀️ woman: medium-dark skin tone, blond hair +👱🏿‍♀️ woman: dark skin tone, blond hair +👱‍♂️ man: blond hair +👱🏻‍♂️ man: light skin tone, blond hair +👱🏼‍♂️ man: medium-light skin tone, blond hair +👱🏽‍♂️ man: medium skin tone, blond hair +👱🏾‍♂️ man: medium-dark skin tone, blond hair +👱🏿‍♂️ man: dark skin tone, blond hair +🧓 older person +🧓🏻 older person: light skin tone +🧓🏼 older person: medium-light skin tone +🧓🏽 older person: medium skin tone +🧓🏾 older person: medium-dark skin tone +🧓🏿 older person: dark skin tone +👴 old man +👴🏻 old man: light skin tone +👴🏼 old man: medium-light skin tone +👴🏽 old man: medium skin tone +👴🏾 old man: medium-dark skin tone +👴🏿 old man: dark skin tone +👵 old woman +👵🏻 old woman: light skin tone +👵🏼 old woman: medium-light skin tone +👵🏽 old woman: medium skin tone +👵🏾 old woman: medium-dark skin tone +👵🏿 old woman: dark skin tone +🙍 person frowning +🙍🏻 person frowning: light skin tone +🙍🏼 person frowning: medium-light skin tone +🙍🏽 person frowning: medium skin tone +🙍🏾 person frowning: medium-dark skin tone +🙍🏿 person frowning: dark skin tone +🙍‍♂️ man frowning +🙍🏻‍♂️ man frowning: light skin tone +🙍🏼‍♂️ man frowning: medium-light skin tone +🙍🏽‍♂️ man frowning: medium skin tone +🙍🏾‍♂️ man frowning: medium-dark skin tone +🙍🏿‍♂️ man frowning: dark skin tone +🙍‍♀️ woman frowning +🙍🏻‍♀️ woman frowning: light skin tone +🙍🏼‍♀️ woman frowning: medium-light skin tone +🙍🏽‍♀️ woman frowning: medium skin tone +🙍🏾‍♀️ woman frowning: medium-dark skin tone +🙍🏿‍♀️ woman frowning: dark skin tone +🙎 person pouting +🙎🏻 person pouting: light skin tone +🙎🏼 person pouting: medium-light skin tone +🙎🏽 person pouting: medium skin tone +🙎🏾 person pouting: medium-dark skin tone +🙎🏿 person pouting: dark skin tone +🙎‍♂️ man pouting +🙎🏻‍♂️ man pouting: light skin tone +🙎🏼‍♂️ man pouting: medium-light skin tone +🙎🏽‍♂️ man pouting: medium skin tone +🙎🏾‍♂️ man pouting: medium-dark skin tone +🙎🏿‍♂️ man pouting: dark skin tone +🙎‍♀️ woman pouting +🙎🏻‍♀️ woman pouting: light skin tone +🙎🏼‍♀️ woman pouting: medium-light skin tone +🙎🏽‍♀️ woman pouting: medium skin tone +🙎🏾‍♀️ woman pouting: medium-dark skin tone +🙎🏿‍♀️ woman pouting: dark skin tone +🙅 person gesturing NO +🙅🏻 person gesturing NO: light skin tone +🙅🏼 person gesturing NO: medium-light skin tone +🙅🏽 person gesturing NO: medium skin tone +🙅🏾 person gesturing NO: medium-dark skin tone +🙅🏿 person gesturing NO: dark skin tone +🙅‍♂️ man gesturing NO +🙅🏻‍♂️ man gesturing NO: light skin tone +🙅🏼‍♂️ man gesturing NO: medium-light skin tone +🙅🏽‍♂️ man gesturing NO: medium skin tone +🙅🏾‍♂️ man gesturing NO: medium-dark skin tone +🙅🏿‍♂️ man gesturing NO: dark skin tone +🙅‍♀️ woman gesturing NO +🙅🏻‍♀️ woman gesturing NO: light skin tone +🙅🏼‍♀️ woman gesturing NO: medium-light skin tone +🙅🏽‍♀️ woman gesturing NO: medium skin tone +🙅🏾‍♀️ woman gesturing NO: medium-dark skin tone +🙅🏿‍♀️ woman gesturing NO: dark skin tone +🙆 person gesturing OK +🙆🏻 person gesturing OK: light skin tone +🙆🏼 person gesturing OK: medium-light skin tone +🙆🏽 person gesturing OK: medium skin tone +🙆🏾 person gesturing OK: medium-dark skin tone +🙆🏿 person gesturing OK: dark skin tone +🙆‍♂️ man gesturing OK +🙆🏻‍♂️ man gesturing OK: light skin tone +🙆🏼‍♂️ man gesturing OK: medium-light skin tone +🙆🏽‍♂️ man gesturing OK: medium skin tone +🙆🏾‍♂️ man gesturing OK: medium-dark skin tone +🙆🏿‍♂️ man gesturing OK: dark skin tone +🙆‍♀️ woman gesturing OK +🙆🏻‍♀️ woman gesturing OK: light skin tone +🙆🏼‍♀️ woman gesturing OK: medium-light skin tone +🙆🏽‍♀️ woman gesturing OK: medium skin tone +🙆🏾‍♀️ woman gesturing OK: medium-dark skin tone +🙆🏿‍♀️ woman gesturing OK: dark skin tone +💁 person tipping hand +💁🏻 person tipping hand: light skin tone +💁🏼 person tipping hand: medium-light skin tone +💁🏽 person tipping hand: medium skin tone +💁🏾 person tipping hand: medium-dark skin tone +💁🏿 person tipping hand: dark skin tone +💁‍♂️ man tipping hand +💁🏻‍♂️ man tipping hand: light skin tone +💁🏼‍♂️ man tipping hand: medium-light skin tone +💁🏽‍♂️ man tipping hand: medium skin tone +💁🏾‍♂️ man tipping hand: medium-dark skin tone +💁🏿‍♂️ man tipping hand: dark skin tone +💁‍♀️ woman tipping hand +💁🏻‍♀️ woman tipping hand: light skin tone +💁🏼‍♀️ woman tipping hand: medium-light skin tone +💁🏽‍♀️ woman tipping hand: medium skin tone +💁🏾‍♀️ woman tipping hand: medium-dark skin tone +💁🏿‍♀️ woman tipping hand: dark skin tone +🙋 person raising hand +🙋🏻 person raising hand: light skin tone +🙋🏼 person raising hand: medium-light skin tone +🙋🏽 person raising hand: medium skin tone +🙋🏾 person raising hand: medium-dark skin tone +🙋🏿 person raising hand: dark skin tone +🙋‍♂️ man raising hand +🙋🏻‍♂️ man raising hand: light skin tone +🙋🏼‍♂️ man raising hand: medium-light skin tone +🙋🏽‍♂️ man raising hand: medium skin tone +🙋🏾‍♂️ man raising hand: medium-dark skin tone +🙋🏿‍♂️ man raising hand: dark skin tone +🙋‍♀️ woman raising hand +🙋🏻‍♀️ woman raising hand: light skin tone +🙋🏼‍♀️ woman raising hand: medium-light skin tone +🙋🏽‍♀️ woman raising hand: medium skin tone +🙋🏾‍♀️ woman raising hand: medium-dark skin tone +🙋🏿‍♀️ woman raising hand: dark skin tone +🧏 deaf person +🧏🏻 deaf person: light skin tone +🧏🏼 deaf person: medium-light skin tone +🧏🏽 deaf person: medium skin tone +🧏🏾 deaf person: medium-dark skin tone +🧏🏿 deaf person: dark skin tone +🧏‍♂️ deaf man +🧏🏻‍♂️ deaf man: light skin tone +🧏🏼‍♂️ deaf man: medium-light skin tone +🧏🏽‍♂️ deaf man: medium skin tone +🧏🏾‍♂️ deaf man: medium-dark skin tone +🧏🏿‍♂️ deaf man: dark skin tone +🧏‍♀️ deaf woman +🧏🏻‍♀️ deaf woman: light skin tone +🧏🏼‍♀️ deaf woman: medium-light skin tone +🧏🏽‍♀️ deaf woman: medium skin tone +🧏🏾‍♀️ deaf woman: medium-dark skin tone +🧏🏿‍♀️ deaf woman: dark skin tone +🙇 person bowing +🙇🏻 person bowing: light skin tone +🙇🏼 person bowing: medium-light skin tone +🙇🏽 person bowing: medium skin tone +🙇🏾 person bowing: medium-dark skin tone +🙇🏿 person bowing: dark skin tone +🙇‍♂️ man bowing +🙇🏻‍♂️ man bowing: light skin tone +🙇🏼‍♂️ man bowing: medium-light skin tone +🙇🏽‍♂️ man bowing: medium skin tone +🙇🏾‍♂️ man bowing: medium-dark skin tone +🙇🏿‍♂️ man bowing: dark skin tone +🙇‍♀️ woman bowing +🙇🏻‍♀️ woman bowing: light skin tone +🙇🏼‍♀️ woman bowing: medium-light skin tone +🙇🏽‍♀️ woman bowing: medium skin tone +🙇🏾‍♀️ woman bowing: medium-dark skin tone +🙇🏿‍♀️ woman bowing: dark skin tone +🤦 person facepalming +🤦🏻 person facepalming: light skin tone +🤦🏼 person facepalming: medium-light skin tone +🤦🏽 person facepalming: medium skin tone +🤦🏾 person facepalming: medium-dark skin tone +🤦🏿 person facepalming: dark skin tone +🤦‍♂️ man facepalming +🤦🏻‍♂️ man facepalming: light skin tone +🤦🏼‍♂️ man facepalming: medium-light skin tone +🤦🏽‍♂️ man facepalming: medium skin tone +🤦🏾‍♂️ man facepalming: medium-dark skin tone +🤦🏿‍♂️ man facepalming: dark skin tone +🤦‍♀️ woman facepalming +🤦🏻‍♀️ woman facepalming: light skin tone +🤦🏼‍♀️ woman facepalming: medium-light skin tone +🤦🏽‍♀️ woman facepalming: medium skin tone +🤦🏾‍♀️ woman facepalming: medium-dark skin tone +🤦🏿‍♀️ woman facepalming: dark skin tone +🤷 person shrugging +🤷🏻 person shrugging: light skin tone +🤷🏼 person shrugging: medium-light skin tone +🤷🏽 person shrugging: medium skin tone +🤷🏾 person shrugging: medium-dark skin tone +🤷🏿 person shrugging: dark skin tone +🤷‍♂️ man shrugging +🤷🏻‍♂️ man shrugging: light skin tone +🤷🏼‍♂️ man shrugging: medium-light skin tone +🤷🏽‍♂️ man shrugging: medium skin tone +🤷🏾‍♂️ man shrugging: medium-dark skin tone +🤷🏿‍♂️ man shrugging: dark skin tone +🤷‍♀️ woman shrugging +🤷🏻‍♀️ woman shrugging: light skin tone +🤷🏼‍♀️ woman shrugging: medium-light skin tone +🤷🏽‍♀️ woman shrugging: medium skin tone +🤷🏾‍♀️ woman shrugging: medium-dark skin tone +🤷🏿‍♀️ woman shrugging: dark skin tone +🧑‍⚕️ health worker +🧑🏻‍⚕️ health worker: light skin tone +🧑🏼‍⚕️ health worker: medium-light skin tone +🧑🏽‍⚕️ health worker: medium skin tone +🧑🏾‍⚕️ health worker: medium-dark skin tone +🧑🏿‍⚕️ health worker: dark skin tone +👨‍⚕️ man health worker +👨🏻‍⚕️ man health worker: light skin tone +👨🏼‍⚕️ man health worker: medium-light skin tone +👨🏽‍⚕️ man health worker: medium skin tone +👨🏾‍⚕️ man health worker: medium-dark skin tone +👨🏿‍⚕️ man health worker: dark skin tone +👩‍⚕️ woman health worker +👩🏻‍⚕️ woman health worker: light skin tone +👩🏼‍⚕️ woman health worker: medium-light skin tone +👩🏽‍⚕️ woman health worker: medium skin tone +👩🏾‍⚕️ woman health worker: medium-dark skin tone +👩🏿‍⚕️ woman health worker: dark skin tone +🧑‍🎓 student +🧑🏻‍🎓 student: light skin tone +🧑🏼‍🎓 student: medium-light skin tone +🧑🏽‍🎓 student: medium skin tone +🧑🏾‍🎓 student: medium-dark skin tone +🧑🏿‍🎓 student: dark skin tone +👨‍🎓 man student +👨🏻‍🎓 man student: light skin tone +👨🏼‍🎓 man student: medium-light skin tone +👨🏽‍🎓 man student: medium skin tone +👨🏾‍🎓 man student: medium-dark skin tone +👨🏿‍🎓 man student: dark skin tone +👩‍🎓 woman student +👩🏻‍🎓 woman student: light skin tone +👩🏼‍🎓 woman student: medium-light skin tone +👩🏽‍🎓 woman student: medium skin tone +👩🏾‍🎓 woman student: medium-dark skin tone +👩🏿‍🎓 woman student: dark skin tone +🧑‍🏫 teacher +🧑🏻‍🏫 teacher: light skin tone +🧑🏼‍🏫 teacher: medium-light skin tone +🧑🏽‍🏫 teacher: medium skin tone +🧑🏾‍🏫 teacher: medium-dark skin tone +🧑🏿‍🏫 teacher: dark skin tone +👨‍🏫 man teacher +👨🏻‍🏫 man teacher: light skin tone +👨🏼‍🏫 man teacher: medium-light skin tone +👨🏽‍🏫 man teacher: medium skin tone +👨🏾‍🏫 man teacher: medium-dark skin tone +👨🏿‍🏫 man teacher: dark skin tone +👩‍🏫 woman teacher +👩🏻‍🏫 woman teacher: light skin tone +👩🏼‍🏫 woman teacher: medium-light skin tone +👩🏽‍🏫 woman teacher: medium skin tone +👩🏾‍🏫 woman teacher: medium-dark skin tone +👩🏿‍🏫 woman teacher: dark skin tone +🧑‍⚖️ judge +🧑🏻‍⚖️ judge: light skin tone +🧑🏼‍⚖️ judge: medium-light skin tone +🧑🏽‍⚖️ judge: medium skin tone +🧑🏾‍⚖️ judge: medium-dark skin tone +🧑🏿‍⚖️ judge: dark skin tone +👨‍⚖️ man judge +👨🏻‍⚖️ man judge: light skin tone +👨🏼‍⚖️ man judge: medium-light skin tone +👨🏽‍⚖️ man judge: medium skin tone +👨🏾‍⚖️ man judge: medium-dark skin tone +👨🏿‍⚖️ man judge: dark skin tone +👩‍⚖️ woman judge +👩🏻‍⚖️ woman judge: light skin tone +👩🏼‍⚖️ woman judge: medium-light skin tone +👩🏽‍⚖️ woman judge: medium skin tone +👩🏾‍⚖️ woman judge: medium-dark skin tone +👩🏿‍⚖️ woman judge: dark skin tone +🧑‍🌾 farmer +🧑🏻‍🌾 farmer: light skin tone +🧑🏼‍🌾 farmer: medium-light skin tone +🧑🏽‍🌾 farmer: medium skin tone +🧑🏾‍🌾 farmer: medium-dark skin tone +🧑🏿‍🌾 farmer: dark skin tone +👨‍🌾 man farmer +👨🏻‍🌾 man farmer: light skin tone +👨🏼‍🌾 man farmer: medium-light skin tone +👨🏽‍🌾 man farmer: medium skin tone +👨🏾‍🌾 man farmer: medium-dark skin tone +👨🏿‍🌾 man farmer: dark skin tone +👩‍🌾 woman farmer +👩🏻‍🌾 woman farmer: light skin tone +👩🏼‍🌾 woman farmer: medium-light skin tone +👩🏽‍🌾 woman farmer: medium skin tone +👩🏾‍🌾 woman farmer: medium-dark skin tone +👩🏿‍🌾 woman farmer: dark skin tone +🧑‍🍳 cook +🧑🏻‍🍳 cook: light skin tone +🧑🏼‍🍳 cook: medium-light skin tone +🧑🏽‍🍳 cook: medium skin tone +🧑🏾‍🍳 cook: medium-dark skin tone +🧑🏿‍🍳 cook: dark skin tone +👨‍🍳 man cook +👨🏻‍🍳 man cook: light skin tone +👨🏼‍🍳 man cook: medium-light skin tone +👨🏽‍🍳 man cook: medium skin tone +👨🏾‍🍳 man cook: medium-dark skin tone +👨🏿‍🍳 man cook: dark skin tone +👩‍🍳 woman cook +👩🏻‍🍳 woman cook: light skin tone +👩🏼‍🍳 woman cook: medium-light skin tone +👩🏽‍🍳 woman cook: medium skin tone +👩🏾‍🍳 woman cook: medium-dark skin tone +👩🏿‍🍳 woman cook: dark skin tone +🧑‍🔧 mechanic +🧑🏻‍🔧 mechanic: light skin tone +🧑🏼‍🔧 mechanic: medium-light skin tone +🧑🏽‍🔧 mechanic: medium skin tone +🧑🏾‍🔧 mechanic: medium-dark skin tone +🧑🏿‍🔧 mechanic: dark skin tone +👨‍🔧 man mechanic +👨🏻‍🔧 man mechanic: light skin tone +👨🏼‍🔧 man mechanic: medium-light skin tone +👨🏽‍🔧 man mechanic: medium skin tone +👨🏾‍🔧 man mechanic: medium-dark skin tone +👨🏿‍🔧 man mechanic: dark skin tone +👩‍🔧 woman mechanic +👩🏻‍🔧 woman mechanic: light skin tone +👩🏼‍🔧 woman mechanic: medium-light skin tone +👩🏽‍🔧 woman mechanic: medium skin tone +👩🏾‍🔧 woman mechanic: medium-dark skin tone +👩🏿‍🔧 woman mechanic: dark skin tone +🧑‍🏭 factory worker +🧑🏻‍🏭 factory worker: light skin tone +🧑🏼‍🏭 factory worker: medium-light skin tone +🧑🏽‍🏭 factory worker: medium skin tone +🧑🏾‍🏭 factory worker: medium-dark skin tone +🧑🏿‍🏭 factory worker: dark skin tone +👨‍🏭 man factory worker +👨🏻‍🏭 man factory worker: light skin tone +👨🏼‍🏭 man factory worker: medium-light skin tone +👨🏽‍🏭 man factory worker: medium skin tone +👨🏾‍🏭 man factory worker: medium-dark skin tone +👨🏿‍🏭 man factory worker: dark skin tone +👩‍🏭 woman factory worker +👩🏻‍🏭 woman factory worker: light skin tone +👩🏼‍🏭 woman factory worker: medium-light skin tone +👩🏽‍🏭 woman factory worker: medium skin tone +👩🏾‍🏭 woman factory worker: medium-dark skin tone +👩🏿‍🏭 woman factory worker: dark skin tone +🧑‍💼 office worker +🧑🏻‍💼 office worker: light skin tone +🧑🏼‍💼 office worker: medium-light skin tone +🧑🏽‍💼 office worker: medium skin tone +🧑🏾‍💼 office worker: medium-dark skin tone +🧑🏿‍💼 office worker: dark skin tone +👨‍💼 man office worker +👨🏻‍💼 man office worker: light skin tone +👨🏼‍💼 man office worker: medium-light skin tone +👨🏽‍💼 man office worker: medium skin tone +👨🏾‍💼 man office worker: medium-dark skin tone +👨🏿‍💼 man office worker: dark skin tone +👩‍💼 woman office worker +👩🏻‍💼 woman office worker: light skin tone +👩🏼‍💼 woman office worker: medium-light skin tone +👩🏽‍💼 woman office worker: medium skin tone +👩🏾‍💼 woman office worker: medium-dark skin tone +👩🏿‍💼 woman office worker: dark skin tone +🧑‍🔬 scientist +🧑🏻‍🔬 scientist: light skin tone +🧑🏼‍🔬 scientist: medium-light skin tone +🧑🏽‍🔬 scientist: medium skin tone +🧑🏾‍🔬 scientist: medium-dark skin tone +🧑🏿‍🔬 scientist: dark skin tone +👨‍🔬 man scientist +👨🏻‍🔬 man scientist: light skin tone +👨🏼‍🔬 man scientist: medium-light skin tone +👨🏽‍🔬 man scientist: medium skin tone +👨🏾‍🔬 man scientist: medium-dark skin tone +👨🏿‍🔬 man scientist: dark skin tone +👩‍🔬 woman scientist +👩🏻‍🔬 woman scientist: light skin tone +👩🏼‍🔬 woman scientist: medium-light skin tone +👩🏽‍🔬 woman scientist: medium skin tone +👩🏾‍🔬 woman scientist: medium-dark skin tone +👩🏿‍🔬 woman scientist: dark skin tone +🧑‍💻 technologist +🧑🏻‍💻 technologist: light skin tone +🧑🏼‍💻 technologist: medium-light skin tone +🧑🏽‍💻 technologist: medium skin tone +🧑🏾‍💻 technologist: medium-dark skin tone +🧑🏿‍💻 technologist: dark skin tone +👨‍💻 man technologist +👨🏻‍💻 man technologist: light skin tone +👨🏼‍💻 man technologist: medium-light skin tone +👨🏽‍💻 man technologist: medium skin tone +👨🏾‍💻 man technologist: medium-dark skin tone +👨🏿‍💻 man technologist: dark skin tone +👩‍💻 woman technologist +👩🏻‍💻 woman technologist: light skin tone +👩🏼‍💻 woman technologist: medium-light skin tone +👩🏽‍💻 woman technologist: medium skin tone +👩🏾‍💻 woman technologist: medium-dark skin tone +👩🏿‍💻 woman technologist: dark skin tone +🧑‍🎤 singer +🧑🏻‍🎤 singer: light skin tone +🧑🏼‍🎤 singer: medium-light skin tone +🧑🏽‍🎤 singer: medium skin tone +🧑🏾‍🎤 singer: medium-dark skin tone +🧑🏿‍🎤 singer: dark skin tone +👨‍🎤 man singer +👨🏻‍🎤 man singer: light skin tone +👨🏼‍🎤 man singer: medium-light skin tone +👨🏽‍🎤 man singer: medium skin tone +👨🏾‍🎤 man singer: medium-dark skin tone +👨🏿‍🎤 man singer: dark skin tone +👩‍🎤 woman singer +👩🏻‍🎤 woman singer: light skin tone +👩🏼‍🎤 woman singer: medium-light skin tone +👩🏽‍🎤 woman singer: medium skin tone +👩🏾‍🎤 woman singer: medium-dark skin tone +👩🏿‍🎤 woman singer: dark skin tone +🧑‍🎨 artist +🧑🏻‍🎨 artist: light skin tone +🧑🏼‍🎨 artist: medium-light skin tone +🧑🏽‍🎨 artist: medium skin tone +🧑🏾‍🎨 artist: medium-dark skin tone +🧑🏿‍🎨 artist: dark skin tone +👨‍🎨 man artist +👨🏻‍🎨 man artist: light skin tone +👨🏼‍🎨 man artist: medium-light skin tone +👨🏽‍🎨 man artist: medium skin tone +👨🏾‍🎨 man artist: medium-dark skin tone +👨🏿‍🎨 man artist: dark skin tone +👩‍🎨 woman artist +👩🏻‍🎨 woman artist: light skin tone +👩🏼‍🎨 woman artist: medium-light skin tone +👩🏽‍🎨 woman artist: medium skin tone +👩🏾‍🎨 woman artist: medium-dark skin tone +👩🏿‍🎨 woman artist: dark skin tone +🧑‍✈️ pilot +🧑🏻‍✈️ pilot: light skin tone +🧑🏼‍✈️ pilot: medium-light skin tone +🧑🏽‍✈️ pilot: medium skin tone +🧑🏾‍✈️ pilot: medium-dark skin tone +🧑🏿‍✈️ pilot: dark skin tone +👨‍✈️ man pilot +👨🏻‍✈️ man pilot: light skin tone +👨🏼‍✈️ man pilot: medium-light skin tone +👨🏽‍✈️ man pilot: medium skin tone +👨🏾‍✈️ man pilot: medium-dark skin tone +👨🏿‍✈️ man pilot: dark skin tone +👩‍✈️ woman pilot +👩🏻‍✈️ woman pilot: light skin tone +👩🏼‍✈️ woman pilot: medium-light skin tone +👩🏽‍✈️ woman pilot: medium skin tone +👩🏾‍✈️ woman pilot: medium-dark skin tone +👩🏿‍✈️ woman pilot: dark skin tone +🧑‍🚀 astronaut +🧑🏻‍🚀 astronaut: light skin tone +🧑🏼‍🚀 astronaut: medium-light skin tone +🧑🏽‍🚀 astronaut: medium skin tone +🧑🏾‍🚀 astronaut: medium-dark skin tone +🧑🏿‍🚀 astronaut: dark skin tone +👨‍🚀 man astronaut +👨🏻‍🚀 man astronaut: light skin tone +👨🏼‍🚀 man astronaut: medium-light skin tone +👨🏽‍🚀 man astronaut: medium skin tone +👨🏾‍🚀 man astronaut: medium-dark skin tone +👨🏿‍🚀 man astronaut: dark skin tone +👩‍🚀 woman astronaut +👩🏻‍🚀 woman astronaut: light skin tone +👩🏼‍🚀 woman astronaut: medium-light skin tone +👩🏽‍🚀 woman astronaut: medium skin tone +👩🏾‍🚀 woman astronaut: medium-dark skin tone +👩🏿‍🚀 woman astronaut: dark skin tone +🧑‍🚒 firefighter +🧑🏻‍🚒 firefighter: light skin tone +🧑🏼‍🚒 firefighter: medium-light skin tone +🧑🏽‍🚒 firefighter: medium skin tone +🧑🏾‍🚒 firefighter: medium-dark skin tone +🧑🏿‍🚒 firefighter: dark skin tone +👨‍🚒 man firefighter +👨🏻‍🚒 man firefighter: light skin tone +👨🏼‍🚒 man firefighter: medium-light skin tone +👨🏽‍🚒 man firefighter: medium skin tone +👨🏾‍🚒 man firefighter: medium-dark skin tone +👨🏿‍🚒 man firefighter: dark skin tone +👩‍🚒 woman firefighter +👩🏻‍🚒 woman firefighter: light skin tone +👩🏼‍🚒 woman firefighter: medium-light skin tone +👩🏽‍🚒 woman firefighter: medium skin tone +👩🏾‍🚒 woman firefighter: medium-dark skin tone +👩🏿‍🚒 woman firefighter: dark skin tone +👮 police officer +👮🏻 police officer: light skin tone +👮🏼 police officer: medium-light skin tone +👮🏽 police officer: medium skin tone +👮🏾 police officer: medium-dark skin tone +👮🏿 police officer: dark skin tone +👮‍♂️ man police officer +👮🏻‍♂️ man police officer: light skin tone +👮🏼‍♂️ man police officer: medium-light skin tone +👮🏽‍♂️ man police officer: medium skin tone +👮🏾‍♂️ man police officer: medium-dark skin tone +👮🏿‍♂️ man police officer: dark skin tone +👮‍♀️ woman police officer +👮🏻‍♀️ woman police officer: light skin tone +👮🏼‍♀️ woman police officer: medium-light skin tone +👮🏽‍♀️ woman police officer: medium skin tone +👮🏾‍♀️ woman police officer: medium-dark skin tone +👮🏿‍♀️ woman police officer: dark skin tone +🕵️ detective +🕵🏻 detective: light skin tone +🕵🏼 detective: medium-light skin tone +🕵🏽 detective: medium skin tone +🕵🏾 detective: medium-dark skin tone +🕵🏿 detective: dark skin tone +🕵️‍♂️ man detective +🕵🏻‍♂️ man detective: light skin tone +🕵🏼‍♂️ man detective: medium-light skin tone +🕵🏽‍♂️ man detective: medium skin tone +🕵🏾‍♂️ man detective: medium-dark skin tone +🕵🏿‍♂️ man detective: dark skin tone +🕵️‍♀️ woman detective +🕵🏻‍♀️ woman detective: light skin tone +🕵🏼‍♀️ woman detective: medium-light skin tone +🕵🏽‍♀️ woman detective: medium skin tone +🕵🏾‍♀️ woman detective: medium-dark skin tone +🕵🏿‍♀️ woman detective: dark skin tone +💂 guard +💂🏻 guard: light skin tone +💂🏼 guard: medium-light skin tone +💂🏽 guard: medium skin tone +💂🏾 guard: medium-dark skin tone +💂🏿 guard: dark skin tone +💂‍♂️ man guard +💂🏻‍♂️ man guard: light skin tone +💂🏼‍♂️ man guard: medium-light skin tone +💂🏽‍♂️ man guard: medium skin tone +💂🏾‍♂️ man guard: medium-dark skin tone +💂🏿‍♂️ man guard: dark skin tone +💂‍♀️ woman guard +💂🏻‍♀️ woman guard: light skin tone +💂🏼‍♀️ woman guard: medium-light skin tone +💂🏽‍♀️ woman guard: medium skin tone +💂🏾‍♀️ woman guard: medium-dark skin tone +💂🏿‍♀️ woman guard: dark skin tone +🥷 ninja +🥷🏻 ninja: light skin tone +🥷🏼 ninja: medium-light skin tone +🥷🏽 ninja: medium skin tone +🥷🏾 ninja: medium-dark skin tone +🥷🏿 ninja: dark skin tone +👷 construction worker +👷🏻 construction worker: light skin tone +👷🏼 construction worker: medium-light skin tone +👷🏽 construction worker: medium skin tone +👷🏾 construction worker: medium-dark skin tone +👷🏿 construction worker: dark skin tone +👷‍♂️ man construction worker +👷🏻‍♂️ man construction worker: light skin tone +👷🏼‍♂️ man construction worker: medium-light skin tone +👷🏽‍♂️ man construction worker: medium skin tone +👷🏾‍♂️ man construction worker: medium-dark skin tone +👷🏿‍♂️ man construction worker: dark skin tone +👷‍♀️ woman construction worker +👷🏻‍♀️ woman construction worker: light skin tone +👷🏼‍♀️ woman construction worker: medium-light skin tone +👷🏽‍♀️ woman construction worker: medium skin tone +👷🏾‍♀️ woman construction worker: medium-dark skin tone +👷🏿‍♀️ woman construction worker: dark skin tone +🫅 person with crown +🫅🏻 person with crown: light skin tone +🫅🏼 person with crown: medium-light skin tone +🫅🏽 person with crown: medium skin tone +🫅🏾 person with crown: medium-dark skin tone +🫅🏿 person with crown: dark skin tone +🤴 prince +🤴🏻 prince: light skin tone +🤴🏼 prince: medium-light skin tone +🤴🏽 prince: medium skin tone +🤴🏾 prince: medium-dark skin tone +🤴🏿 prince: dark skin tone +👸 princess +👸🏻 princess: light skin tone +👸🏼 princess: medium-light skin tone +👸🏽 princess: medium skin tone +👸🏾 princess: medium-dark skin tone +👸🏿 princess: dark skin tone +👳 person wearing turban +👳🏻 person wearing turban: light skin tone +👳🏼 person wearing turban: medium-light skin tone +👳🏽 person wearing turban: medium skin tone +👳🏾 person wearing turban: medium-dark skin tone +👳🏿 person wearing turban: dark skin tone +👳‍♂️ man wearing turban +👳🏻‍♂️ man wearing turban: light skin tone +👳🏼‍♂️ man wearing turban: medium-light skin tone +👳🏽‍♂️ man wearing turban: medium skin tone +👳🏾‍♂️ man wearing turban: medium-dark skin tone +👳🏿‍♂️ man wearing turban: dark skin tone +👳‍♀️ woman wearing turban +👳🏻‍♀️ woman wearing turban: light skin tone +👳🏼‍♀️ woman wearing turban: medium-light skin tone +👳🏽‍♀️ woman wearing turban: medium skin tone +👳🏾‍♀️ woman wearing turban: medium-dark skin tone +👳🏿‍♀️ woman wearing turban: dark skin tone +👲 person with skullcap +👲🏻 person with skullcap: light skin tone +👲🏼 person with skullcap: medium-light skin tone +👲🏽 person with skullcap: medium skin tone +👲🏾 person with skullcap: medium-dark skin tone +👲🏿 person with skullcap: dark skin tone +🧕 woman with headscarf +🧕🏻 woman with headscarf: light skin tone +🧕🏼 woman with headscarf: medium-light skin tone +🧕🏽 woman with headscarf: medium skin tone +🧕🏾 woman with headscarf: medium-dark skin tone +🧕🏿 woman with headscarf: dark skin tone +🤵 person in tuxedo +🤵🏻 person in tuxedo: light skin tone +🤵🏼 person in tuxedo: medium-light skin tone +🤵🏽 person in tuxedo: medium skin tone +🤵🏾 person in tuxedo: medium-dark skin tone +🤵🏿 person in tuxedo: dark skin tone +🤵‍♂️ man in tuxedo +🤵🏻‍♂️ man in tuxedo: light skin tone +🤵🏼‍♂️ man in tuxedo: medium-light skin tone +🤵🏽‍♂️ man in tuxedo: medium skin tone +🤵🏾‍♂️ man in tuxedo: medium-dark skin tone +🤵🏿‍♂️ man in tuxedo: dark skin tone +🤵‍♀️ woman in tuxedo +🤵🏻‍♀️ woman in tuxedo: light skin tone +🤵🏼‍♀️ woman in tuxedo: medium-light skin tone +🤵🏽‍♀️ woman in tuxedo: medium skin tone +🤵🏾‍♀️ woman in tuxedo: medium-dark skin tone +🤵🏿‍♀️ woman in tuxedo: dark skin tone +👰 person with veil +👰🏻 person with veil: light skin tone +👰🏼 person with veil: medium-light skin tone +👰🏽 person with veil: medium skin tone +👰🏾 person with veil: medium-dark skin tone +👰🏿 person with veil: dark skin tone +👰‍♂️ man with veil +👰🏻‍♂️ man with veil: light skin tone +👰🏼‍♂️ man with veil: medium-light skin tone +👰🏽‍♂️ man with veil: medium skin tone +👰🏾‍♂️ man with veil: medium-dark skin tone +👰🏿‍♂️ man with veil: dark skin tone +👰‍♀️ woman with veil +👰🏻‍♀️ woman with veil: light skin tone +👰🏼‍♀️ woman with veil: medium-light skin tone +👰🏽‍♀️ woman with veil: medium skin tone +👰🏾‍♀️ woman with veil: medium-dark skin tone +👰🏿‍♀️ woman with veil: dark skin tone +🤰 pregnant woman +🤰🏻 pregnant woman: light skin tone +🤰🏼 pregnant woman: medium-light skin tone +🤰🏽 pregnant woman: medium skin tone +🤰🏾 pregnant woman: medium-dark skin tone +🤰🏿 pregnant woman: dark skin tone +🫃 pregnant man +🫃🏻 pregnant man: light skin tone +🫃🏼 pregnant man: medium-light skin tone +🫃🏽 pregnant man: medium skin tone +🫃🏾 pregnant man: medium-dark skin tone +🫃🏿 pregnant man: dark skin tone +🫄 pregnant person +🫄🏻 pregnant person: light skin tone +🫄🏼 pregnant person: medium-light skin tone +🫄🏽 pregnant person: medium skin tone +🫄🏾 pregnant person: medium-dark skin tone +🫄🏿 pregnant person: dark skin tone +🤱 breast-feeding +🤱🏻 breast-feeding: light skin tone +🤱🏼 breast-feeding: medium-light skin tone +🤱🏽 breast-feeding: medium skin tone +🤱🏾 breast-feeding: medium-dark skin tone +🤱🏿 breast-feeding: dark skin tone +👩‍🍼 woman feeding baby +👩🏻‍🍼 woman feeding baby: light skin tone +👩🏼‍🍼 woman feeding baby: medium-light skin tone +👩🏽‍🍼 woman feeding baby: medium skin tone +👩🏾‍🍼 woman feeding baby: medium-dark skin tone +👩🏿‍🍼 woman feeding baby: dark skin tone +👨‍🍼 man feeding baby +👨🏻‍🍼 man feeding baby: light skin tone +👨🏼‍🍼 man feeding baby: medium-light skin tone +👨🏽‍🍼 man feeding baby: medium skin tone +👨🏾‍🍼 man feeding baby: medium-dark skin tone +👨🏿‍🍼 man feeding baby: dark skin tone +🧑‍🍼 person feeding baby +🧑🏻‍🍼 person feeding baby: light skin tone +🧑🏼‍🍼 person feeding baby: medium-light skin tone +🧑🏽‍🍼 person feeding baby: medium skin tone +🧑🏾‍🍼 person feeding baby: medium-dark skin tone +🧑🏿‍🍼 person feeding baby: dark skin tone +👼 baby angel +👼🏻 baby angel: light skin tone +👼🏼 baby angel: medium-light skin tone +👼🏽 baby angel: medium skin tone +👼🏾 baby angel: medium-dark skin tone +👼🏿 baby angel: dark skin tone +🎅 Santa Claus +🎅🏻 Santa Claus: light skin tone +🎅🏼 Santa Claus: medium-light skin tone +🎅🏽 Santa Claus: medium skin tone +🎅🏾 Santa Claus: medium-dark skin tone +🎅🏿 Santa Claus: dark skin tone +🤶 Mrs. Claus +🤶🏻 Mrs. Claus: light skin tone +🤶🏼 Mrs. Claus: medium-light skin tone +🤶🏽 Mrs. Claus: medium skin tone +🤶🏾 Mrs. Claus: medium-dark skin tone +🤶🏿 Mrs. Claus: dark skin tone +🧑‍🎄 Mx Claus +🧑🏻‍🎄 Mx Claus: light skin tone +🧑🏼‍🎄 Mx Claus: medium-light skin tone +🧑🏽‍🎄 Mx Claus: medium skin tone +🧑🏾‍🎄 Mx Claus: medium-dark skin tone +🧑🏿‍🎄 Mx Claus: dark skin tone +🦸 superhero +🦸🏻 superhero: light skin tone +🦸🏼 superhero: medium-light skin tone +🦸🏽 superhero: medium skin tone +🦸🏾 superhero: medium-dark skin tone +🦸🏿 superhero: dark skin tone +🦸‍♂️ man superhero +🦸🏻‍♂️ man superhero: light skin tone +🦸🏼‍♂️ man superhero: medium-light skin tone +🦸🏽‍♂️ man superhero: medium skin tone +🦸🏾‍♂️ man superhero: medium-dark skin tone +🦸🏿‍♂️ man superhero: dark skin tone +🦸‍♀️ woman superhero +🦸🏻‍♀️ woman superhero: light skin tone +🦸🏼‍♀️ woman superhero: medium-light skin tone +🦸🏽‍♀️ woman superhero: medium skin tone +🦸🏾‍♀️ woman superhero: medium-dark skin tone +🦸🏿‍♀️ woman superhero: dark skin tone +🦹 supervillain +🦹🏻 supervillain: light skin tone +🦹🏼 supervillain: medium-light skin tone +🦹🏽 supervillain: medium skin tone +🦹🏾 supervillain: medium-dark skin tone +🦹🏿 supervillain: dark skin tone +🦹‍♂️ man supervillain +🦹🏻‍♂️ man supervillain: light skin tone +🦹🏼‍♂️ man supervillain: medium-light skin tone +🦹🏽‍♂️ man supervillain: medium skin tone +🦹🏾‍♂️ man supervillain: medium-dark skin tone +🦹🏿‍♂️ man supervillain: dark skin tone +🦹‍♀️ woman supervillain +🦹🏻‍♀️ woman supervillain: light skin tone +🦹🏼‍♀️ woman supervillain: medium-light skin tone +🦹🏽‍♀️ woman supervillain: medium skin tone +🦹🏾‍♀️ woman supervillain: medium-dark skin tone +🦹🏿‍♀️ woman supervillain: dark skin tone +🧙 mage +🧙🏻 mage: light skin tone +🧙🏼 mage: medium-light skin tone +🧙🏽 mage: medium skin tone +🧙🏾 mage: medium-dark skin tone +🧙🏿 mage: dark skin tone +🧙‍♂️ man mage +🧙🏻‍♂️ man mage: light skin tone +🧙🏼‍♂️ man mage: medium-light skin tone +🧙🏽‍♂️ man mage: medium skin tone +🧙🏾‍♂️ man mage: medium-dark skin tone +🧙🏿‍♂️ man mage: dark skin tone +🧙‍♀️ woman mage +🧙🏻‍♀️ woman mage: light skin tone +🧙🏼‍♀️ woman mage: medium-light skin tone +🧙🏽‍♀️ woman mage: medium skin tone +🧙🏾‍♀️ woman mage: medium-dark skin tone +🧙🏿‍♀️ woman mage: dark skin tone +🧚 fairy +🧚🏻 fairy: light skin tone +🧚🏼 fairy: medium-light skin tone +🧚🏽 fairy: medium skin tone +🧚🏾 fairy: medium-dark skin tone +🧚🏿 fairy: dark skin tone +🧚‍♂️ man fairy +🧚🏻‍♂️ man fairy: light skin tone +🧚🏼‍♂️ man fairy: medium-light skin tone +🧚🏽‍♂️ man fairy: medium skin tone +🧚🏾‍♂️ man fairy: medium-dark skin tone +🧚🏿‍♂️ man fairy: dark skin tone +🧚‍♀️ woman fairy +🧚🏻‍♀️ woman fairy: light skin tone +🧚🏼‍♀️ woman fairy: medium-light skin tone +🧚🏽‍♀️ woman fairy: medium skin tone +🧚🏾‍♀️ woman fairy: medium-dark skin tone +🧚🏿‍♀️ woman fairy: dark skin tone +🧛 vampire +🧛🏻 vampire: light skin tone +🧛🏼 vampire: medium-light skin tone +🧛🏽 vampire: medium skin tone +🧛🏾 vampire: medium-dark skin tone +🧛🏿 vampire: dark skin tone +🧛‍♂️ man vampire +🧛🏻‍♂️ man vampire: light skin tone +🧛🏼‍♂️ man vampire: medium-light skin tone +🧛🏽‍♂️ man vampire: medium skin tone +🧛🏾‍♂️ man vampire: medium-dark skin tone +🧛🏿‍♂️ man vampire: dark skin tone +🧛‍♀️ woman vampire +🧛🏻‍♀️ woman vampire: light skin tone +🧛🏼‍♀️ woman vampire: medium-light skin tone +🧛🏽‍♀️ woman vampire: medium skin tone +🧛🏾‍♀️ woman vampire: medium-dark skin tone +🧛🏿‍♀️ woman vampire: dark skin tone +🧜 merperson +🧜🏻 merperson: light skin tone +🧜🏼 merperson: medium-light skin tone +🧜🏽 merperson: medium skin tone +🧜🏾 merperson: medium-dark skin tone +🧜🏿 merperson: dark skin tone +🧜‍♂️ merman +🧜🏻‍♂️ merman: light skin tone +🧜🏼‍♂️ merman: medium-light skin tone +🧜🏽‍♂️ merman: medium skin tone +🧜🏾‍♂️ merman: medium-dark skin tone +🧜🏿‍♂️ merman: dark skin tone +🧜‍♀️ mermaid +🧜🏻‍♀️ mermaid: light skin tone +🧜🏼‍♀️ mermaid: medium-light skin tone +🧜🏽‍♀️ mermaid: medium skin tone +🧜🏾‍♀️ mermaid: medium-dark skin tone +🧜🏿‍♀️ mermaid: dark skin tone +🧝 elf +🧝🏻 elf: light skin tone +🧝🏼 elf: medium-light skin tone +🧝🏽 elf: medium skin tone +🧝🏾 elf: medium-dark skin tone +🧝🏿 elf: dark skin tone +🧝‍♂️ man elf +🧝🏻‍♂️ man elf: light skin tone +🧝🏼‍♂️ man elf: medium-light skin tone +🧝🏽‍♂️ man elf: medium skin tone +🧝🏾‍♂️ man elf: medium-dark skin tone +🧝🏿‍♂️ man elf: dark skin tone +🧝‍♀️ woman elf +🧝🏻‍♀️ woman elf: light skin tone +🧝🏼‍♀️ woman elf: medium-light skin tone +🧝🏽‍♀️ woman elf: medium skin tone +🧝🏾‍♀️ woman elf: medium-dark skin tone +🧝🏿‍♀️ woman elf: dark skin tone +🧞 genie +🧞‍♂️ man genie +🧞‍♀️ woman genie +🧟 zombie +🧟‍♂️ man zombie +🧟‍♀️ woman zombie +🧌 troll +💆 person getting massage +💆🏻 person getting massage: light skin tone +💆🏼 person getting massage: medium-light skin tone +💆🏽 person getting massage: medium skin tone +💆🏾 person getting massage: medium-dark skin tone +💆🏿 person getting massage: dark skin tone +💆‍♂️ man getting massage +💆🏻‍♂️ man getting massage: light skin tone +💆🏼‍♂️ man getting massage: medium-light skin tone +💆🏽‍♂️ man getting massage: medium skin tone +💆🏾‍♂️ man getting massage: medium-dark skin tone +💆🏿‍♂️ man getting massage: dark skin tone +💆‍♀️ woman getting massage +💆🏻‍♀️ woman getting massage: light skin tone +💆🏼‍♀️ woman getting massage: medium-light skin tone +💆🏽‍♀️ woman getting massage: medium skin tone +💆🏾‍♀️ woman getting massage: medium-dark skin tone +💆🏿‍♀️ woman getting massage: dark skin tone +💇 person getting haircut +💇🏻 person getting haircut: light skin tone +💇🏼 person getting haircut: medium-light skin tone +💇🏽 person getting haircut: medium skin tone +💇🏾 person getting haircut: medium-dark skin tone +💇🏿 person getting haircut: dark skin tone +💇‍♂️ man getting haircut +💇🏻‍♂️ man getting haircut: light skin tone +💇🏼‍♂️ man getting haircut: medium-light skin tone +💇🏽‍♂️ man getting haircut: medium skin tone +💇🏾‍♂️ man getting haircut: medium-dark skin tone +💇🏿‍♂️ man getting haircut: dark skin tone +💇‍♀️ woman getting haircut +💇🏻‍♀️ woman getting haircut: light skin tone +💇🏼‍♀️ woman getting haircut: medium-light skin tone +💇🏽‍♀️ woman getting haircut: medium skin tone +💇🏾‍♀️ woman getting haircut: medium-dark skin tone +💇🏿‍♀️ woman getting haircut: dark skin tone +🚶 person walking +🚶🏻 person walking: light skin tone +🚶🏼 person walking: medium-light skin tone +🚶🏽 person walking: medium skin tone +🚶🏾 person walking: medium-dark skin tone +🚶🏿 person walking: dark skin tone +🚶‍♂️ man walking +🚶🏻‍♂️ man walking: light skin tone +🚶🏼‍♂️ man walking: medium-light skin tone +🚶🏽‍♂️ man walking: medium skin tone +🚶🏾‍♂️ man walking: medium-dark skin tone +🚶🏿‍♂️ man walking: dark skin tone +🚶‍♀️ woman walking +🚶🏻‍♀️ woman walking: light skin tone +🚶🏼‍♀️ woman walking: medium-light skin tone +🚶🏽‍♀️ woman walking: medium skin tone +🚶🏾‍♀️ woman walking: medium-dark skin tone +🚶🏿‍♀️ woman walking: dark skin tone +🚶‍➡️ person walking facing right +🚶🏻‍➡️ person walking facing right: light skin tone +🚶🏼‍➡️ person walking facing right: medium-light skin tone +🚶🏽‍➡️ person walking facing right: medium skin tone +🚶🏾‍➡️ person walking facing right: medium-dark skin tone +🚶🏿‍➡️ person walking facing right: dark skin tone +🚶‍♀️‍➡️ woman walking facing right +🚶🏻‍♀️‍➡️ woman walking facing right: light skin tone +🚶🏼‍♀️‍➡️ woman walking facing right: medium-light skin tone +🚶🏽‍♀️‍➡️ woman walking facing right: medium skin tone +🚶🏾‍♀️‍➡️ woman walking facing right: medium-dark skin tone +🚶🏿‍♀️‍➡️ woman walking facing right: dark skin tone +🚶‍♂️‍➡️ man walking facing right +🚶🏻‍♂️‍➡️ man walking facing right: light skin tone +🚶🏼‍♂️‍➡️ man walking facing right: medium-light skin tone +🚶🏽‍♂️‍➡️ man walking facing right: medium skin tone +🚶🏾‍♂️‍➡️ man walking facing right: medium-dark skin tone +🚶🏿‍♂️‍➡️ man walking facing right: dark skin tone +🧍 person standing +🧍🏻 person standing: light skin tone +🧍🏼 person standing: medium-light skin tone +🧍🏽 person standing: medium skin tone +🧍🏾 person standing: medium-dark skin tone +🧍🏿 person standing: dark skin tone +🧍‍♂️ man standing +🧍🏻‍♂️ man standing: light skin tone +🧍🏼‍♂️ man standing: medium-light skin tone +🧍🏽‍♂️ man standing: medium skin tone +🧍🏾‍♂️ man standing: medium-dark skin tone +🧍🏿‍♂️ man standing: dark skin tone +🧍‍♀️ woman standing +🧍🏻‍♀️ woman standing: light skin tone +🧍🏼‍♀️ woman standing: medium-light skin tone +🧍🏽‍♀️ woman standing: medium skin tone +🧍🏾‍♀️ woman standing: medium-dark skin tone +🧍🏿‍♀️ woman standing: dark skin tone +🧎 person kneeling +🧎🏻 person kneeling: light skin tone +🧎🏼 person kneeling: medium-light skin tone +🧎🏽 person kneeling: medium skin tone +🧎🏾 person kneeling: medium-dark skin tone +🧎🏿 person kneeling: dark skin tone +🧎‍♂️ man kneeling +🧎🏻‍♂️ man kneeling: light skin tone +🧎🏼‍♂️ man kneeling: medium-light skin tone +🧎🏽‍♂️ man kneeling: medium skin tone +🧎🏾‍♂️ man kneeling: medium-dark skin tone +🧎🏿‍♂️ man kneeling: dark skin tone +🧎‍♀️ woman kneeling +🧎🏻‍♀️ woman kneeling: light skin tone +🧎🏼‍♀️ woman kneeling: medium-light skin tone +🧎🏽‍♀️ woman kneeling: medium skin tone +🧎🏾‍♀️ woman kneeling: medium-dark skin tone +🧎🏿‍♀️ woman kneeling: dark skin tone +🧎‍➡️ person kneeling facing right +🧎🏻‍➡️ person kneeling facing right: light skin tone +🧎🏼‍➡️ person kneeling facing right: medium-light skin tone +🧎🏽‍➡️ person kneeling facing right: medium skin tone +🧎🏾‍➡️ person kneeling facing right: medium-dark skin tone +🧎🏿‍➡️ person kneeling facing right: dark skin tone +🧎‍♀️‍➡️ woman kneeling facing right +🧎🏻‍♀️‍➡️ woman kneeling facing right: light skin tone +🧎🏼‍♀️‍➡️ woman kneeling facing right: medium-light skin tone +🧎🏽‍♀️‍➡️ woman kneeling facing right: medium skin tone +🧎🏾‍♀️‍➡️ woman kneeling facing right: medium-dark skin tone +🧎🏿‍♀️‍➡️ woman kneeling facing right: dark skin tone +🧎‍♂️‍➡️ man kneeling facing right +🧎🏻‍♂️‍➡️ man kneeling facing right: light skin tone +🧎🏼‍♂️‍➡️ man kneeling facing right: medium-light skin tone +🧎🏽‍♂️‍➡️ man kneeling facing right: medium skin tone +🧎🏾‍♂️‍➡️ man kneeling facing right: medium-dark skin tone +🧎🏿‍♂️‍➡️ man kneeling facing right: dark skin tone +🧑‍🦯 person with white cane +🧑🏻‍🦯 person with white cane: light skin tone +🧑🏼‍🦯 person with white cane: medium-light skin tone +🧑🏽‍🦯 person with white cane: medium skin tone +🧑🏾‍🦯 person with white cane: medium-dark skin tone +🧑🏿‍🦯 person with white cane: dark skin tone +🧑‍🦯‍➡️ person with white cane facing right +🧑🏻‍🦯‍➡️ person with white cane facing right: light skin tone +🧑🏼‍🦯‍➡️ person with white cane facing right: medium-light skin tone +🧑🏽‍🦯‍➡️ person with white cane facing right: medium skin tone +🧑🏾‍🦯‍➡️ person with white cane facing right: medium-dark skin tone +🧑🏿‍🦯‍➡️ person with white cane facing right: dark skin tone +👨‍🦯 man with white cane +👨🏻‍🦯 man with white cane: light skin tone +👨🏼‍🦯 man with white cane: medium-light skin tone +👨🏽‍🦯 man with white cane: medium skin tone +👨🏾‍🦯 man with white cane: medium-dark skin tone +👨🏿‍🦯 man with white cane: dark skin tone +👨‍🦯‍➡️ man with white cane facing right +👨🏻‍🦯‍➡️ man with white cane facing right: light skin tone +👨🏼‍🦯‍➡️ man with white cane facing right: medium-light skin tone +👨🏽‍🦯‍➡️ man with white cane facing right: medium skin tone +👨🏾‍🦯‍➡️ man with white cane facing right: medium-dark skin tone +👨🏿‍🦯‍➡️ man with white cane facing right: dark skin tone +👩‍🦯 woman with white cane +👩🏻‍🦯 woman with white cane: light skin tone +👩🏼‍🦯 woman with white cane: medium-light skin tone +👩🏽‍🦯 woman with white cane: medium skin tone +👩🏾‍🦯 woman with white cane: medium-dark skin tone +👩🏿‍🦯 woman with white cane: dark skin tone +👩‍🦯‍➡️ woman with white cane facing right +👩🏻‍🦯‍➡️ woman with white cane facing right: light skin tone +👩🏼‍🦯‍➡️ woman with white cane facing right: medium-light skin tone +👩🏽‍🦯‍➡️ woman with white cane facing right: medium skin tone +👩🏾‍🦯‍➡️ woman with white cane facing right: medium-dark skin tone +👩🏿‍🦯‍➡️ woman with white cane facing right: dark skin tone +🧑‍🦼 person in motorized wheelchair +🧑🏻‍🦼 person in motorized wheelchair: light skin tone +🧑🏼‍🦼 person in motorized wheelchair: medium-light skin tone +🧑🏽‍🦼 person in motorized wheelchair: medium skin tone +🧑🏾‍🦼 person in motorized wheelchair: medium-dark skin tone +🧑🏿‍🦼 person in motorized wheelchair: dark skin tone +🧑‍🦼‍➡️ person in motorized wheelchair facing right +🧑🏻‍🦼‍➡️ person in motorized wheelchair facing right: light skin tone +🧑🏼‍🦼‍➡️ person in motorized wheelchair facing right: medium-light skin tone +🧑🏽‍🦼‍➡️ person in motorized wheelchair facing right: medium skin tone +🧑🏾‍🦼‍➡️ person in motorized wheelchair facing right: medium-dark skin tone +🧑🏿‍🦼‍➡️ person in motorized wheelchair facing right: dark skin tone +👨‍🦼 man in motorized wheelchair +👨🏻‍🦼 man in motorized wheelchair: light skin tone +👨🏼‍🦼 man in motorized wheelchair: medium-light skin tone +👨🏽‍🦼 man in motorized wheelchair: medium skin tone +👨🏾‍🦼 man in motorized wheelchair: medium-dark skin tone +👨🏿‍🦼 man in motorized wheelchair: dark skin tone +👨‍🦼‍➡️ man in motorized wheelchair facing right +👨🏻‍🦼‍➡️ man in motorized wheelchair facing right: light skin tone +👨🏼‍🦼‍➡️ man in motorized wheelchair facing right: medium-light skin tone +👨🏽‍🦼‍➡️ man in motorized wheelchair facing right: medium skin tone +👨🏾‍🦼‍➡️ man in motorized wheelchair facing right: medium-dark skin tone +👨🏿‍🦼‍➡️ man in motorized wheelchair facing right: dark skin tone +👩‍🦼 woman in motorized wheelchair +👩🏻‍🦼 woman in motorized wheelchair: light skin tone +👩🏼‍🦼 woman in motorized wheelchair: medium-light skin tone +👩🏽‍🦼 woman in motorized wheelchair: medium skin tone +👩🏾‍🦼 woman in motorized wheelchair: medium-dark skin tone +👩🏿‍🦼 woman in motorized wheelchair: dark skin tone +👩‍🦼‍➡️ woman in motorized wheelchair facing right +👩🏻‍🦼‍➡️ woman in motorized wheelchair facing right: light skin tone +👩🏼‍🦼‍➡️ woman in motorized wheelchair facing right: medium-light skin tone +👩🏽‍🦼‍➡️ woman in motorized wheelchair facing right: medium skin tone +👩🏾‍🦼‍➡️ woman in motorized wheelchair facing right: medium-dark skin tone +👩🏿‍🦼‍➡️ woman in motorized wheelchair facing right: dark skin tone +🧑‍🦽 person in manual wheelchair +🧑🏻‍🦽 person in manual wheelchair: light skin tone +🧑🏼‍🦽 person in manual wheelchair: medium-light skin tone +🧑🏽‍🦽 person in manual wheelchair: medium skin tone +🧑🏾‍🦽 person in manual wheelchair: medium-dark skin tone +🧑🏿‍🦽 person in manual wheelchair: dark skin tone +🧑‍🦽‍➡️ person in manual wheelchair facing right +🧑🏻‍🦽‍➡️ person in manual wheelchair facing right: light skin tone +🧑🏼‍🦽‍➡️ person in manual wheelchair facing right: medium-light skin tone +🧑🏽‍🦽‍➡️ person in manual wheelchair facing right: medium skin tone +🧑🏾‍🦽‍➡️ person in manual wheelchair facing right: medium-dark skin tone +🧑🏿‍🦽‍➡️ person in manual wheelchair facing right: dark skin tone +👨‍🦽 man in manual wheelchair +👨🏻‍🦽 man in manual wheelchair: light skin tone +👨🏼‍🦽 man in manual wheelchair: medium-light skin tone +👨🏽‍🦽 man in manual wheelchair: medium skin tone +👨🏾‍🦽 man in manual wheelchair: medium-dark skin tone +👨🏿‍🦽 man in manual wheelchair: dark skin tone +👨‍🦽‍➡️ man in manual wheelchair facing right +👨🏻‍🦽‍➡️ man in manual wheelchair facing right: light skin tone +👨🏼‍🦽‍➡️ man in manual wheelchair facing right: medium-light skin tone +👨🏽‍🦽‍➡️ man in manual wheelchair facing right: medium skin tone +👨🏾‍🦽‍➡️ man in manual wheelchair facing right: medium-dark skin tone +👨🏿‍🦽‍➡️ man in manual wheelchair facing right: dark skin tone +👩‍🦽 woman in manual wheelchair +👩🏻‍🦽 woman in manual wheelchair: light skin tone +👩🏼‍🦽 woman in manual wheelchair: medium-light skin tone +👩🏽‍🦽 woman in manual wheelchair: medium skin tone +👩🏾‍🦽 woman in manual wheelchair: medium-dark skin tone +👩🏿‍🦽 woman in manual wheelchair: dark skin tone +👩‍🦽‍➡️ woman in manual wheelchair facing right +👩🏻‍🦽‍➡️ woman in manual wheelchair facing right: light skin tone +👩🏼‍🦽‍➡️ woman in manual wheelchair facing right: medium-light skin tone +👩🏽‍🦽‍➡️ woman in manual wheelchair facing right: medium skin tone +👩🏾‍🦽‍➡️ woman in manual wheelchair facing right: medium-dark skin tone +👩🏿‍🦽‍➡️ woman in manual wheelchair facing right: dark skin tone +🏃 person running +🏃🏻 person running: light skin tone +🏃🏼 person running: medium-light skin tone +🏃🏽 person running: medium skin tone +🏃🏾 person running: medium-dark skin tone +🏃🏿 person running: dark skin tone +🏃‍♂️ man running +🏃🏻‍♂️ man running: light skin tone +🏃🏼‍♂️ man running: medium-light skin tone +🏃🏽‍♂️ man running: medium skin tone +🏃🏾‍♂️ man running: medium-dark skin tone +🏃🏿‍♂️ man running: dark skin tone +🏃‍♀️ woman running +🏃🏻‍♀️ woman running: light skin tone +🏃🏼‍♀️ woman running: medium-light skin tone +🏃🏽‍♀️ woman running: medium skin tone +🏃🏾‍♀️ woman running: medium-dark skin tone +🏃🏿‍♀️ woman running: dark skin tone +🏃‍➡️ person running facing right +🏃🏻‍➡️ person running facing right: light skin tone +🏃🏼‍➡️ person running facing right: medium-light skin tone +🏃🏽‍➡️ person running facing right: medium skin tone +🏃🏾‍➡️ person running facing right: medium-dark skin tone +🏃🏿‍➡️ person running facing right: dark skin tone +🏃‍♀️‍➡️ woman running facing right +🏃🏻‍♀️‍➡️ woman running facing right: light skin tone +🏃🏼‍♀️‍➡️ woman running facing right: medium-light skin tone +🏃🏽‍♀️‍➡️ woman running facing right: medium skin tone +🏃🏾‍♀️‍➡️ woman running facing right: medium-dark skin tone +🏃🏿‍♀️‍➡️ woman running facing right: dark skin tone +🏃‍♂️‍➡️ man running facing right +🏃🏻‍♂️‍➡️ man running facing right: light skin tone +🏃🏼‍♂️‍➡️ man running facing right: medium-light skin tone +🏃🏽‍♂️‍➡️ man running facing right: medium skin tone +🏃🏾‍♂️‍➡️ man running facing right: medium-dark skin tone +🏃🏿‍♂️‍➡️ man running facing right: dark skin tone +💃 woman dancing +💃🏻 woman dancing: light skin tone +💃🏼 woman dancing: medium-light skin tone +💃🏽 woman dancing: medium skin tone +💃🏾 woman dancing: medium-dark skin tone +💃🏿 woman dancing: dark skin tone +🕺 man dancing +🕺🏻 man dancing: light skin tone +🕺🏼 man dancing: medium-light skin tone +🕺🏽 man dancing: medium skin tone +🕺🏾 man dancing: medium-dark skin tone +🕺🏿 man dancing: dark skin tone +🕴️ person in suit levitating +🕴🏻 person in suit levitating: light skin tone +🕴🏼 person in suit levitating: medium-light skin tone +🕴🏽 person in suit levitating: medium skin tone +🕴🏾 person in suit levitating: medium-dark skin tone +🕴🏿 person in suit levitating: dark skin tone +👯 people with bunny ears +👯‍♂️ men with bunny ears +👯‍♀️ women with bunny ears +🧖 person in steamy room +🧖🏻 person in steamy room: light skin tone +🧖🏼 person in steamy room: medium-light skin tone +🧖🏽 person in steamy room: medium skin tone +🧖🏾 person in steamy room: medium-dark skin tone +🧖🏿 person in steamy room: dark skin tone +🧖‍♂️ man in steamy room +🧖🏻‍♂️ man in steamy room: light skin tone +🧖🏼‍♂️ man in steamy room: medium-light skin tone +🧖🏽‍♂️ man in steamy room: medium skin tone +🧖🏾‍♂️ man in steamy room: medium-dark skin tone +🧖🏿‍♂️ man in steamy room: dark skin tone +🧖‍♀️ woman in steamy room +🧖🏻‍♀️ woman in steamy room: light skin tone +🧖🏼‍♀️ woman in steamy room: medium-light skin tone +🧖🏽‍♀️ woman in steamy room: medium skin tone +🧖🏾‍♀️ woman in steamy room: medium-dark skin tone +🧖🏿‍♀️ woman in steamy room: dark skin tone +🧗 person climbing +🧗🏻 person climbing: light skin tone +🧗🏼 person climbing: medium-light skin tone +🧗🏽 person climbing: medium skin tone +🧗🏾 person climbing: medium-dark skin tone +🧗🏿 person climbing: dark skin tone +🧗‍♂️ man climbing +🧗🏻‍♂️ man climbing: light skin tone +🧗🏼‍♂️ man climbing: medium-light skin tone +🧗🏽‍♂️ man climbing: medium skin tone +🧗🏾‍♂️ man climbing: medium-dark skin tone +🧗🏿‍♂️ man climbing: dark skin tone +🧗‍♀️ woman climbing +🧗🏻‍♀️ woman climbing: light skin tone +🧗🏼‍♀️ woman climbing: medium-light skin tone +🧗🏽‍♀️ woman climbing: medium skin tone +🧗🏾‍♀️ woman climbing: medium-dark skin tone +🧗🏿‍♀️ woman climbing: dark skin tone +🤺 person fencing +🏇 horse racing +🏇🏻 horse racing: light skin tone +🏇🏼 horse racing: medium-light skin tone +🏇🏽 horse racing: medium skin tone +🏇🏾 horse racing: medium-dark skin tone +🏇🏿 horse racing: dark skin tone +⛷️ skier +🏂 snowboarder +🏂🏻 snowboarder: light skin tone +🏂🏼 snowboarder: medium-light skin tone +🏂🏽 snowboarder: medium skin tone +🏂🏾 snowboarder: medium-dark skin tone +🏂🏿 snowboarder: dark skin tone +🏌️ person golfing +🏌🏻 person golfing: light skin tone +🏌🏼 person golfing: medium-light skin tone +🏌🏽 person golfing: medium skin tone +🏌🏾 person golfing: medium-dark skin tone +🏌🏿 person golfing: dark skin tone +🏌️‍♂️ man golfing +🏌🏻‍♂️ man golfing: light skin tone +🏌🏼‍♂️ man golfing: medium-light skin tone +🏌🏽‍♂️ man golfing: medium skin tone +🏌🏾‍♂️ man golfing: medium-dark skin tone +🏌🏿‍♂️ man golfing: dark skin tone +🏌️‍♀️ woman golfing +🏌🏻‍♀️ woman golfing: light skin tone +🏌🏼‍♀️ woman golfing: medium-light skin tone +🏌🏽‍♀️ woman golfing: medium skin tone +🏌🏾‍♀️ woman golfing: medium-dark skin tone +🏌🏿‍♀️ woman golfing: dark skin tone +🏄 person surfing +🏄🏻 person surfing: light skin tone +🏄🏼 person surfing: medium-light skin tone +🏄🏽 person surfing: medium skin tone +🏄🏾 person surfing: medium-dark skin tone +🏄🏿 person surfing: dark skin tone +🏄‍♂️ man surfing +🏄🏻‍♂️ man surfing: light skin tone +🏄🏼‍♂️ man surfing: medium-light skin tone +🏄🏽‍♂️ man surfing: medium skin tone +🏄🏾‍♂️ man surfing: medium-dark skin tone +🏄🏿‍♂️ man surfing: dark skin tone +🏄‍♀️ woman surfing +🏄🏻‍♀️ woman surfing: light skin tone +🏄🏼‍♀️ woman surfing: medium-light skin tone +🏄🏽‍♀️ woman surfing: medium skin tone +🏄🏾‍♀️ woman surfing: medium-dark skin tone +🏄🏿‍♀️ woman surfing: dark skin tone +🚣 person rowing boat +🚣🏻 person rowing boat: light skin tone +🚣🏼 person rowing boat: medium-light skin tone +🚣🏽 person rowing boat: medium skin tone +🚣🏾 person rowing boat: medium-dark skin tone +🚣🏿 person rowing boat: dark skin tone +🚣‍♂️ man rowing boat +🚣🏻‍♂️ man rowing boat: light skin tone +🚣🏼‍♂️ man rowing boat: medium-light skin tone +🚣🏽‍♂️ man rowing boat: medium skin tone +🚣🏾‍♂️ man rowing boat: medium-dark skin tone +🚣🏿‍♂️ man rowing boat: dark skin tone +🚣‍♀️ woman rowing boat +🚣🏻‍♀️ woman rowing boat: light skin tone +🚣🏼‍♀️ woman rowing boat: medium-light skin tone +🚣🏽‍♀️ woman rowing boat: medium skin tone +🚣🏾‍♀️ woman rowing boat: medium-dark skin tone +🚣🏿‍♀️ woman rowing boat: dark skin tone +🏊 person swimming +🏊🏻 person swimming: light skin tone +🏊🏼 person swimming: medium-light skin tone +🏊🏽 person swimming: medium skin tone +🏊🏾 person swimming: medium-dark skin tone +🏊🏿 person swimming: dark skin tone +🏊‍♂️ man swimming +🏊🏻‍♂️ man swimming: light skin tone +🏊🏼‍♂️ man swimming: medium-light skin tone +🏊🏽‍♂️ man swimming: medium skin tone +🏊🏾‍♂️ man swimming: medium-dark skin tone +🏊🏿‍♂️ man swimming: dark skin tone +🏊‍♀️ woman swimming +🏊🏻‍♀️ woman swimming: light skin tone +🏊🏼‍♀️ woman swimming: medium-light skin tone +🏊🏽‍♀️ woman swimming: medium skin tone +🏊🏾‍♀️ woman swimming: medium-dark skin tone +🏊🏿‍♀️ woman swimming: dark skin tone +⛹️ person bouncing ball +⛹🏻 person bouncing ball: light skin tone +⛹🏼 person bouncing ball: medium-light skin tone +⛹🏽 person bouncing ball: medium skin tone +⛹🏾 person bouncing ball: medium-dark skin tone +⛹🏿 person bouncing ball: dark skin tone +⛹️‍♂️ man bouncing ball +⛹🏻‍♂️ man bouncing ball: light skin tone +⛹🏼‍♂️ man bouncing ball: medium-light skin tone +⛹🏽‍♂️ man bouncing ball: medium skin tone +⛹🏾‍♂️ man bouncing ball: medium-dark skin tone +⛹🏿‍♂️ man bouncing ball: dark skin tone +⛹️‍♀️ woman bouncing ball +⛹🏻‍♀️ woman bouncing ball: light skin tone +⛹🏼‍♀️ woman bouncing ball: medium-light skin tone +⛹🏽‍♀️ woman bouncing ball: medium skin tone +⛹🏾‍♀️ woman bouncing ball: medium-dark skin tone +⛹🏿‍♀️ woman bouncing ball: dark skin tone +🏋️ person lifting weights +🏋🏻 person lifting weights: light skin tone +🏋🏼 person lifting weights: medium-light skin tone +🏋🏽 person lifting weights: medium skin tone +🏋🏾 person lifting weights: medium-dark skin tone +🏋🏿 person lifting weights: dark skin tone +🏋️‍♂️ man lifting weights +🏋🏻‍♂️ man lifting weights: light skin tone +🏋🏼‍♂️ man lifting weights: medium-light skin tone +🏋🏽‍♂️ man lifting weights: medium skin tone +🏋🏾‍♂️ man lifting weights: medium-dark skin tone +🏋🏿‍♂️ man lifting weights: dark skin tone +🏋️‍♀️ woman lifting weights +🏋🏻‍♀️ woman lifting weights: light skin tone +🏋🏼‍♀️ woman lifting weights: medium-light skin tone +🏋🏽‍♀️ woman lifting weights: medium skin tone +🏋🏾‍♀️ woman lifting weights: medium-dark skin tone +🏋🏿‍♀️ woman lifting weights: dark skin tone +🚴 person biking +🚴🏻 person biking: light skin tone +🚴🏼 person biking: medium-light skin tone +🚴🏽 person biking: medium skin tone +🚴🏾 person biking: medium-dark skin tone +🚴🏿 person biking: dark skin tone +🚴‍♂️ man biking +🚴🏻‍♂️ man biking: light skin tone +🚴🏼‍♂️ man biking: medium-light skin tone +🚴🏽‍♂️ man biking: medium skin tone +🚴🏾‍♂️ man biking: medium-dark skin tone +🚴🏿‍♂️ man biking: dark skin tone +🚴‍♀️ woman biking +🚴🏻‍♀️ woman biking: light skin tone +🚴🏼‍♀️ woman biking: medium-light skin tone +🚴🏽‍♀️ woman biking: medium skin tone +🚴🏾‍♀️ woman biking: medium-dark skin tone +🚴🏿‍♀️ woman biking: dark skin tone +🚵 person mountain biking +🚵🏻 person mountain biking: light skin tone +🚵🏼 person mountain biking: medium-light skin tone +🚵🏽 person mountain biking: medium skin tone +🚵🏾 person mountain biking: medium-dark skin tone +🚵🏿 person mountain biking: dark skin tone +🚵‍♂️ man mountain biking +🚵🏻‍♂️ man mountain biking: light skin tone +🚵🏼‍♂️ man mountain biking: medium-light skin tone +🚵🏽‍♂️ man mountain biking: medium skin tone +🚵🏾‍♂️ man mountain biking: medium-dark skin tone +🚵🏿‍♂️ man mountain biking: dark skin tone +🚵‍♀️ woman mountain biking +🚵🏻‍♀️ woman mountain biking: light skin tone +🚵🏼‍♀️ woman mountain biking: medium-light skin tone +🚵🏽‍♀️ woman mountain biking: medium skin tone +🚵🏾‍♀️ woman mountain biking: medium-dark skin tone +🚵🏿‍♀️ woman mountain biking: dark skin tone +🤸 person cartwheeling +🤸🏻 person cartwheeling: light skin tone +🤸🏼 person cartwheeling: medium-light skin tone +🤸🏽 person cartwheeling: medium skin tone +🤸🏾 person cartwheeling: medium-dark skin tone +🤸🏿 person cartwheeling: dark skin tone +🤸‍♂️ man cartwheeling +🤸🏻‍♂️ man cartwheeling: light skin tone +🤸🏼‍♂️ man cartwheeling: medium-light skin tone +🤸🏽‍♂️ man cartwheeling: medium skin tone +🤸🏾‍♂️ man cartwheeling: medium-dark skin tone +🤸🏿‍♂️ man cartwheeling: dark skin tone +🤸‍♀️ woman cartwheeling +🤸🏻‍♀️ woman cartwheeling: light skin tone +🤸🏼‍♀️ woman cartwheeling: medium-light skin tone +🤸🏽‍♀️ woman cartwheeling: medium skin tone +🤸🏾‍♀️ woman cartwheeling: medium-dark skin tone +🤸🏿‍♀️ woman cartwheeling: dark skin tone +🤼 people wrestling +🤼‍♂️ men wrestling +🤼‍♀️ women wrestling +🤽 person playing water polo +🤽🏻 person playing water polo: light skin tone +🤽🏼 person playing water polo: medium-light skin tone +🤽🏽 person playing water polo: medium skin tone +🤽🏾 person playing water polo: medium-dark skin tone +🤽🏿 person playing water polo: dark skin tone +🤽‍♂️ man playing water polo +🤽🏻‍♂️ man playing water polo: light skin tone +🤽🏼‍♂️ man playing water polo: medium-light skin tone +🤽🏽‍♂️ man playing water polo: medium skin tone +🤽🏾‍♂️ man playing water polo: medium-dark skin tone +🤽🏿‍♂️ man playing water polo: dark skin tone +🤽‍♀️ woman playing water polo +🤽🏻‍♀️ woman playing water polo: light skin tone +🤽🏼‍♀️ woman playing water polo: medium-light skin tone +🤽🏽‍♀️ woman playing water polo: medium skin tone +🤽🏾‍♀️ woman playing water polo: medium-dark skin tone +🤽🏿‍♀️ woman playing water polo: dark skin tone +🤾 person playing handball +🤾🏻 person playing handball: light skin tone +🤾🏼 person playing handball: medium-light skin tone +🤾🏽 person playing handball: medium skin tone +🤾🏾 person playing handball: medium-dark skin tone +🤾🏿 person playing handball: dark skin tone +🤾‍♂️ man playing handball +🤾🏻‍♂️ man playing handball: light skin tone +🤾🏼‍♂️ man playing handball: medium-light skin tone +🤾🏽‍♂️ man playing handball: medium skin tone +🤾🏾‍♂️ man playing handball: medium-dark skin tone +🤾🏿‍♂️ man playing handball: dark skin tone +🤾‍♀️ woman playing handball +🤾🏻‍♀️ woman playing handball: light skin tone +🤾🏼‍♀️ woman playing handball: medium-light skin tone +🤾🏽‍♀️ woman playing handball: medium skin tone +🤾🏾‍♀️ woman playing handball: medium-dark skin tone +🤾🏿‍♀️ woman playing handball: dark skin tone +🤹 person juggling +🤹🏻 person juggling: light skin tone +🤹🏼 person juggling: medium-light skin tone +🤹🏽 person juggling: medium skin tone +🤹🏾 person juggling: medium-dark skin tone +🤹🏿 person juggling: dark skin tone +🤹‍♂️ man juggling +🤹🏻‍♂️ man juggling: light skin tone +🤹🏼‍♂️ man juggling: medium-light skin tone +🤹🏽‍♂️ man juggling: medium skin tone +🤹🏾‍♂️ man juggling: medium-dark skin tone +🤹🏿‍♂️ man juggling: dark skin tone +🤹‍♀️ woman juggling +🤹🏻‍♀️ woman juggling: light skin tone +🤹🏼‍♀️ woman juggling: medium-light skin tone +🤹🏽‍♀️ woman juggling: medium skin tone +🤹🏾‍♀️ woman juggling: medium-dark skin tone +🤹🏿‍♀️ woman juggling: dark skin tone +🧘 person in lotus position +🧘🏻 person in lotus position: light skin tone +🧘🏼 person in lotus position: medium-light skin tone +🧘🏽 person in lotus position: medium skin tone +🧘🏾 person in lotus position: medium-dark skin tone +🧘🏿 person in lotus position: dark skin tone +🧘‍♂️ man in lotus position +🧘🏻‍♂️ man in lotus position: light skin tone +🧘🏼‍♂️ man in lotus position: medium-light skin tone +🧘🏽‍♂️ man in lotus position: medium skin tone +🧘🏾‍♂️ man in lotus position: medium-dark skin tone +🧘🏿‍♂️ man in lotus position: dark skin tone +🧘‍♀️ woman in lotus position +🧘🏻‍♀️ woman in lotus position: light skin tone +🧘🏼‍♀️ woman in lotus position: medium-light skin tone +🧘🏽‍♀️ woman in lotus position: medium skin tone +🧘🏾‍♀️ woman in lotus position: medium-dark skin tone +🧘🏿‍♀️ woman in lotus position: dark skin tone +🛀 person taking bath +🛀🏻 person taking bath: light skin tone +🛀🏼 person taking bath: medium-light skin tone +🛀🏽 person taking bath: medium skin tone +🛀🏾 person taking bath: medium-dark skin tone +🛀🏿 person taking bath: dark skin tone +🛌 person in bed +🛌🏻 person in bed: light skin tone +🛌🏼 person in bed: medium-light skin tone +🛌🏽 person in bed: medium skin tone +🛌🏾 person in bed: medium-dark skin tone +🛌🏿 person in bed: dark skin tone +🧑‍🤝‍🧑 people holding hands +🧑🏻‍🤝‍🧑🏻 people holding hands: light skin tone +🧑🏻‍🤝‍🧑🏼 people holding hands: light skin tone, medium-light skin tone +🧑🏻‍🤝‍🧑🏽 people holding hands: light skin tone, medium skin tone +🧑🏻‍🤝‍🧑🏾 people holding hands: light skin tone, medium-dark skin tone +🧑🏻‍🤝‍🧑🏿 people holding hands: light skin tone, dark skin tone +🧑🏼‍🤝‍🧑🏻 people holding hands: medium-light skin tone, light skin tone +🧑🏼‍🤝‍🧑🏼 people holding hands: medium-light skin tone +🧑🏼‍🤝‍🧑🏽 people holding hands: medium-light skin tone, medium skin tone +🧑🏼‍🤝‍🧑🏾 people holding hands: medium-light skin tone, medium-dark skin tone +🧑🏼‍🤝‍🧑🏿 people holding hands: medium-light skin tone, dark skin tone +🧑🏽‍🤝‍🧑🏻 people holding hands: medium skin tone, light skin tone +🧑🏽‍🤝‍🧑🏼 people holding hands: medium skin tone, medium-light skin tone +🧑🏽‍🤝‍🧑🏽 people holding hands: medium skin tone +🧑🏽‍🤝‍🧑🏾 people holding hands: medium skin tone, medium-dark skin tone +🧑🏽‍🤝‍🧑🏿 people holding hands: medium skin tone, dark skin tone +🧑🏾‍🤝‍🧑🏻 people holding hands: medium-dark skin tone, light skin tone +🧑🏾‍🤝‍🧑🏼 people holding hands: medium-dark skin tone, medium-light skin tone +🧑🏾‍🤝‍🧑🏽 people holding hands: medium-dark skin tone, medium skin tone +🧑🏾‍🤝‍🧑🏾 people holding hands: medium-dark skin tone +🧑🏾‍🤝‍🧑🏿 people holding hands: medium-dark skin tone, dark skin tone +🧑🏿‍🤝‍🧑🏻 people holding hands: dark skin tone, light skin tone +🧑🏿‍🤝‍🧑🏼 people holding hands: dark skin tone, medium-light skin tone +🧑🏿‍🤝‍🧑🏽 people holding hands: dark skin tone, medium skin tone +🧑🏿‍🤝‍🧑🏾 people holding hands: dark skin tone, medium-dark skin tone +🧑🏿‍🤝‍🧑🏿 people holding hands: dark skin tone +👭 women holding hands +👭🏻 women holding hands: light skin tone +👩🏻‍🤝‍👩🏼 women holding hands: light skin tone, medium-light skin tone +👩🏻‍🤝‍👩🏽 women holding hands: light skin tone, medium skin tone +👩🏻‍🤝‍👩🏾 women holding hands: light skin tone, medium-dark skin tone +👩🏻‍🤝‍👩🏿 women holding hands: light skin tone, dark skin tone +👩🏼‍🤝‍👩🏻 women holding hands: medium-light skin tone, light skin tone +👭🏼 women holding hands: medium-light skin tone +👩🏼‍🤝‍👩🏽 women holding hands: medium-light skin tone, medium skin tone +👩🏼‍🤝‍👩🏾 women holding hands: medium-light skin tone, medium-dark skin tone +👩🏼‍🤝‍👩🏿 women holding hands: medium-light skin tone, dark skin tone +👩🏽‍🤝‍👩🏻 women holding hands: medium skin tone, light skin tone +👩🏽‍🤝‍👩🏼 women holding hands: medium skin tone, medium-light skin tone +👭🏽 women holding hands: medium skin tone +👩🏽‍🤝‍👩🏾 women holding hands: medium skin tone, medium-dark skin tone +👩🏽‍🤝‍👩🏿 women holding hands: medium skin tone, dark skin tone +👩🏾‍🤝‍👩🏻 women holding hands: medium-dark skin tone, light skin tone +👩🏾‍🤝‍👩🏼 women holding hands: medium-dark skin tone, medium-light skin tone +👩🏾‍🤝‍👩🏽 women holding hands: medium-dark skin tone, medium skin tone +👭🏾 women holding hands: medium-dark skin tone +👩🏾‍🤝‍👩🏿 women holding hands: medium-dark skin tone, dark skin tone +👩🏿‍🤝‍👩🏻 women holding hands: dark skin tone, light skin tone +👩🏿‍🤝‍👩🏼 women holding hands: dark skin tone, medium-light skin tone +👩🏿‍🤝‍👩🏽 women holding hands: dark skin tone, medium skin tone +👩🏿‍🤝‍👩🏾 women holding hands: dark skin tone, medium-dark skin tone +👭🏿 women holding hands: dark skin tone +👫 woman and man holding hands +👫🏻 woman and man holding hands: light skin tone +👩🏻‍🤝‍👨🏼 woman and man holding hands: light skin tone, medium-light skin tone +👩🏻‍🤝‍👨🏽 woman and man holding hands: light skin tone, medium skin tone +👩🏻‍🤝‍👨🏾 woman and man holding hands: light skin tone, medium-dark skin tone +👩🏻‍🤝‍👨🏿 woman and man holding hands: light skin tone, dark skin tone +👩🏼‍🤝‍👨🏻 woman and man holding hands: medium-light skin tone, light skin tone +👫🏼 woman and man holding hands: medium-light skin tone +👩🏼‍🤝‍👨🏽 woman and man holding hands: medium-light skin tone, medium skin tone +👩🏼‍🤝‍👨🏾 woman and man holding hands: medium-light skin tone, medium-dark skin tone +👩🏼‍🤝‍👨🏿 woman and man holding hands: medium-light skin tone, dark skin tone +👩🏽‍🤝‍👨🏻 woman and man holding hands: medium skin tone, light skin tone +👩🏽‍🤝‍👨🏼 woman and man holding hands: medium skin tone, medium-light skin tone +👫🏽 woman and man holding hands: medium skin tone +👩🏽‍🤝‍👨🏾 woman and man holding hands: medium skin tone, medium-dark skin tone +👩🏽‍🤝‍👨🏿 woman and man holding hands: medium skin tone, dark skin tone +👩🏾‍🤝‍👨🏻 woman and man holding hands: medium-dark skin tone, light skin tone +👩🏾‍🤝‍👨🏼 woman and man holding hands: medium-dark skin tone, medium-light skin tone +👩🏾‍🤝‍👨🏽 woman and man holding hands: medium-dark skin tone, medium skin tone +👫🏾 woman and man holding hands: medium-dark skin tone +👩🏾‍🤝‍👨🏿 woman and man holding hands: medium-dark skin tone, dark skin tone +👩🏿‍🤝‍👨🏻 woman and man holding hands: dark skin tone, light skin tone +👩🏿‍🤝‍👨🏼 woman and man holding hands: dark skin tone, medium-light skin tone +👩🏿‍🤝‍👨🏽 woman and man holding hands: dark skin tone, medium skin tone +👩🏿‍🤝‍👨🏾 woman and man holding hands: dark skin tone, medium-dark skin tone +👫🏿 woman and man holding hands: dark skin tone +👬 men holding hands +👬🏻 men holding hands: light skin tone +👨🏻‍🤝‍👨🏼 men holding hands: light skin tone, medium-light skin tone +👨🏻‍🤝‍👨🏽 men holding hands: light skin tone, medium skin tone +👨🏻‍🤝‍👨🏾 men holding hands: light skin tone, medium-dark skin tone +👨🏻‍🤝‍👨🏿 men holding hands: light skin tone, dark skin tone +👨🏼‍🤝‍👨🏻 men holding hands: medium-light skin tone, light skin tone +👬🏼 men holding hands: medium-light skin tone +👨🏼‍🤝‍👨🏽 men holding hands: medium-light skin tone, medium skin tone +👨🏼‍🤝‍👨🏾 men holding hands: medium-light skin tone, medium-dark skin tone +👨🏼‍🤝‍👨🏿 men holding hands: medium-light skin tone, dark skin tone +👨🏽‍🤝‍👨🏻 men holding hands: medium skin tone, light skin tone +👨🏽‍🤝‍👨🏼 men holding hands: medium skin tone, medium-light skin tone +👬🏽 men holding hands: medium skin tone +👨🏽‍🤝‍👨🏾 men holding hands: medium skin tone, medium-dark skin tone +👨🏽‍🤝‍👨🏿 men holding hands: medium skin tone, dark skin tone +👨🏾‍🤝‍👨🏻 men holding hands: medium-dark skin tone, light skin tone +👨🏾‍🤝‍👨🏼 men holding hands: medium-dark skin tone, medium-light skin tone +👨🏾‍🤝‍👨🏽 men holding hands: medium-dark skin tone, medium skin tone +👬🏾 men holding hands: medium-dark skin tone +👨🏾‍🤝‍👨🏿 men holding hands: medium-dark skin tone, dark skin tone +👨🏿‍🤝‍👨🏻 men holding hands: dark skin tone, light skin tone +👨🏿‍🤝‍👨🏼 men holding hands: dark skin tone, medium-light skin tone +👨🏿‍🤝‍👨🏽 men holding hands: dark skin tone, medium skin tone +👨🏿‍🤝‍👨🏾 men holding hands: dark skin tone, medium-dark skin tone +👬🏿 men holding hands: dark skin tone +💏 kiss +💏🏻 kiss: light skin tone +💏🏼 kiss: medium-light skin tone +💏🏽 kiss: medium skin tone +💏🏾 kiss: medium-dark skin tone +💏🏿 kiss: dark skin tone +🧑🏻‍❤️‍💋‍🧑🏼 kiss: person, person, light skin tone, medium-light skin tone +🧑🏻‍❤️‍💋‍🧑🏽 kiss: person, person, light skin tone, medium skin tone +🧑🏻‍❤️‍💋‍🧑🏾 kiss: person, person, light skin tone, medium-dark skin tone +🧑🏻‍❤️‍💋‍🧑🏿 kiss: person, person, light skin tone, dark skin tone +🧑🏼‍❤️‍💋‍🧑🏻 kiss: person, person, medium-light skin tone, light skin tone +🧑🏼‍❤️‍💋‍🧑🏽 kiss: person, person, medium-light skin tone, medium skin tone +🧑🏼‍❤️‍💋‍🧑🏾 kiss: person, person, medium-light skin tone, medium-dark skin tone +🧑🏼‍❤️‍💋‍🧑🏿 kiss: person, person, medium-light skin tone, dark skin tone +🧑🏽‍❤️‍💋‍🧑🏻 kiss: person, person, medium skin tone, light skin tone +🧑🏽‍❤️‍💋‍🧑🏼 kiss: person, person, medium skin tone, medium-light skin tone +🧑🏽‍❤️‍💋‍🧑🏾 kiss: person, person, medium skin tone, medium-dark skin tone +🧑🏽‍❤️‍💋‍🧑🏿 kiss: person, person, medium skin tone, dark skin tone +🧑🏾‍❤️‍💋‍🧑🏻 kiss: person, person, medium-dark skin tone, light skin tone +🧑🏾‍❤️‍💋‍🧑🏼 kiss: person, person, medium-dark skin tone, medium-light skin tone +🧑🏾‍❤️‍💋‍🧑🏽 kiss: person, person, medium-dark skin tone, medium skin tone +🧑🏾‍❤️‍💋‍🧑🏿 kiss: person, person, medium-dark skin tone, dark skin tone +🧑🏿‍❤️‍💋‍🧑🏻 kiss: person, person, dark skin tone, light skin tone +🧑🏿‍❤️‍💋‍🧑🏼 kiss: person, person, dark skin tone, medium-light skin tone +🧑🏿‍❤️‍💋‍🧑🏽 kiss: person, person, dark skin tone, medium skin tone +🧑🏿‍❤️‍💋‍🧑🏾 kiss: person, person, dark skin tone, medium-dark skin tone +👩‍❤️‍💋‍👨 kiss: woman, man +👩🏻‍❤️‍💋‍👨🏻 kiss: woman, man, light skin tone +👩🏻‍❤️‍💋‍👨🏼 kiss: woman, man, light skin tone, medium-light skin tone +👩🏻‍❤️‍💋‍👨🏽 kiss: woman, man, light skin tone, medium skin tone +👩🏻‍❤️‍💋‍👨🏾 kiss: woman, man, light skin tone, medium-dark skin tone +👩🏻‍❤️‍💋‍👨🏿 kiss: woman, man, light skin tone, dark skin tone +👩🏼‍❤️‍💋‍👨🏻 kiss: woman, man, medium-light skin tone, light skin tone +👩🏼‍❤️‍💋‍👨🏼 kiss: woman, man, medium-light skin tone +👩🏼‍❤️‍💋‍👨🏽 kiss: woman, man, medium-light skin tone, medium skin tone +👩🏼‍❤️‍💋‍👨🏾 kiss: woman, man, medium-light skin tone, medium-dark skin tone +👩🏼‍❤️‍💋‍👨🏿 kiss: woman, man, medium-light skin tone, dark skin tone +👩🏽‍❤️‍💋‍👨🏻 kiss: woman, man, medium skin tone, light skin tone +👩🏽‍❤️‍💋‍👨🏼 kiss: woman, man, medium skin tone, medium-light skin tone +👩🏽‍❤️‍💋‍👨🏽 kiss: woman, man, medium skin tone +👩🏽‍❤️‍💋‍👨🏾 kiss: woman, man, medium skin tone, medium-dark skin tone +👩🏽‍❤️‍💋‍👨🏿 kiss: woman, man, medium skin tone, dark skin tone +👩🏾‍❤️‍💋‍👨🏻 kiss: woman, man, medium-dark skin tone, light skin tone +👩🏾‍❤️‍💋‍👨🏼 kiss: woman, man, medium-dark skin tone, medium-light skin tone +👩🏾‍❤️‍💋‍👨🏽 kiss: woman, man, medium-dark skin tone, medium skin tone +👩🏾‍❤️‍💋‍👨🏾 kiss: woman, man, medium-dark skin tone +👩🏾‍❤️‍💋‍👨🏿 kiss: woman, man, medium-dark skin tone, dark skin tone +👩🏿‍❤️‍💋‍👨🏻 kiss: woman, man, dark skin tone, light skin tone +👩🏿‍❤️‍💋‍👨🏼 kiss: woman, man, dark skin tone, medium-light skin tone +👩🏿‍❤️‍💋‍👨🏽 kiss: woman, man, dark skin tone, medium skin tone +👩🏿‍❤️‍💋‍👨🏾 kiss: woman, man, dark skin tone, medium-dark skin tone +👩🏿‍❤️‍💋‍👨🏿 kiss: woman, man, dark skin tone +👨‍❤️‍💋‍👨 kiss: man, man +👨🏻‍❤️‍💋‍👨🏻 kiss: man, man, light skin tone +👨🏻‍❤️‍💋‍👨🏼 kiss: man, man, light skin tone, medium-light skin tone +👨🏻‍❤️‍💋‍👨🏽 kiss: man, man, light skin tone, medium skin tone +👨🏻‍❤️‍💋‍👨🏾 kiss: man, man, light skin tone, medium-dark skin tone +👨🏻‍❤️‍💋‍👨🏿 kiss: man, man, light skin tone, dark skin tone +👨🏼‍❤️‍💋‍👨🏻 kiss: man, man, medium-light skin tone, light skin tone +👨🏼‍❤️‍💋‍👨🏼 kiss: man, man, medium-light skin tone +👨🏼‍❤️‍💋‍👨🏽 kiss: man, man, medium-light skin tone, medium skin tone +👨🏼‍❤️‍💋‍👨🏾 kiss: man, man, medium-light skin tone, medium-dark skin tone +👨🏼‍❤️‍💋‍👨🏿 kiss: man, man, medium-light skin tone, dark skin tone +👨🏽‍❤️‍💋‍👨🏻 kiss: man, man, medium skin tone, light skin tone +👨🏽‍❤️‍💋‍👨🏼 kiss: man, man, medium skin tone, medium-light skin tone +👨🏽‍❤️‍💋‍👨🏽 kiss: man, man, medium skin tone +👨🏽‍❤️‍💋‍👨🏾 kiss: man, man, medium skin tone, medium-dark skin tone +👨🏽‍❤️‍💋‍👨🏿 kiss: man, man, medium skin tone, dark skin tone +👨🏾‍❤️‍💋‍👨🏻 kiss: man, man, medium-dark skin tone, light skin tone +👨🏾‍❤️‍💋‍👨🏼 kiss: man, man, medium-dark skin tone, medium-light skin tone +👨🏾‍❤️‍💋‍👨🏽 kiss: man, man, medium-dark skin tone, medium skin tone +👨🏾‍❤️‍💋‍👨🏾 kiss: man, man, medium-dark skin tone +👨🏾‍❤️‍💋‍👨🏿 kiss: man, man, medium-dark skin tone, dark skin tone +👨🏿‍❤️‍💋‍👨🏻 kiss: man, man, dark skin tone, light skin tone +👨🏿‍❤️‍💋‍👨🏼 kiss: man, man, dark skin tone, medium-light skin tone +👨🏿‍❤️‍💋‍👨🏽 kiss: man, man, dark skin tone, medium skin tone +👨🏿‍❤️‍💋‍👨🏾 kiss: man, man, dark skin tone, medium-dark skin tone +👨🏿‍❤️‍💋‍👨🏿 kiss: man, man, dark skin tone +👩‍❤️‍💋‍👩 kiss: woman, woman +👩🏻‍❤️‍💋‍👩🏻 kiss: woman, woman, light skin tone +👩🏻‍❤️‍💋‍👩🏼 kiss: woman, woman, light skin tone, medium-light skin tone +👩🏻‍❤️‍💋‍👩🏽 kiss: woman, woman, light skin tone, medium skin tone +👩🏻‍❤️‍💋‍👩🏾 kiss: woman, woman, light skin tone, medium-dark skin tone +👩🏻‍❤️‍💋‍👩🏿 kiss: woman, woman, light skin tone, dark skin tone +👩🏼‍❤️‍💋‍👩🏻 kiss: woman, woman, medium-light skin tone, light skin tone +👩🏼‍❤️‍💋‍👩🏼 kiss: woman, woman, medium-light skin tone +👩🏼‍❤️‍💋‍👩🏽 kiss: woman, woman, medium-light skin tone, medium skin tone +👩🏼‍❤️‍💋‍👩🏾 kiss: woman, woman, medium-light skin tone, medium-dark skin tone +👩🏼‍❤️‍💋‍👩🏿 kiss: woman, woman, medium-light skin tone, dark skin tone +👩🏽‍❤️‍💋‍👩🏻 kiss: woman, woman, medium skin tone, light skin tone +👩🏽‍❤️‍💋‍👩🏼 kiss: woman, woman, medium skin tone, medium-light skin tone +👩🏽‍❤️‍💋‍👩🏽 kiss: woman, woman, medium skin tone +👩🏽‍❤️‍💋‍👩🏾 kiss: woman, woman, medium skin tone, medium-dark skin tone +👩🏽‍❤️‍💋‍👩🏿 kiss: woman, woman, medium skin tone, dark skin tone +👩🏾‍❤️‍💋‍👩🏻 kiss: woman, woman, medium-dark skin tone, light skin tone +👩🏾‍❤️‍💋‍👩🏼 kiss: woman, woman, medium-dark skin tone, medium-light skin tone +👩🏾‍❤️‍💋‍👩🏽 kiss: woman, woman, medium-dark skin tone, medium skin tone +👩🏾‍❤️‍💋‍👩🏾 kiss: woman, woman, medium-dark skin tone +👩🏾‍❤️‍💋‍👩🏿 kiss: woman, woman, medium-dark skin tone, dark skin tone +👩🏿‍❤️‍💋‍👩🏻 kiss: woman, woman, dark skin tone, light skin tone +👩🏿‍❤️‍💋‍👩🏼 kiss: woman, woman, dark skin tone, medium-light skin tone +👩🏿‍❤️‍💋‍👩🏽 kiss: woman, woman, dark skin tone, medium skin tone +👩🏿‍❤️‍💋‍👩🏾 kiss: woman, woman, dark skin tone, medium-dark skin tone +👩🏿‍❤️‍💋‍👩🏿 kiss: woman, woman, dark skin tone +💑 couple with heart +💑🏻 couple with heart: light skin tone +💑🏼 couple with heart: medium-light skin tone +💑🏽 couple with heart: medium skin tone +💑🏾 couple with heart: medium-dark skin tone +💑🏿 couple with heart: dark skin tone +🧑🏻‍❤️‍🧑🏼 couple with heart: person, person, light skin tone, medium-light skin tone +🧑🏻‍❤️‍🧑🏽 couple with heart: person, person, light skin tone, medium skin tone +🧑🏻‍❤️‍🧑🏾 couple with heart: person, person, light skin tone, medium-dark skin tone +🧑🏻‍❤️‍🧑🏿 couple with heart: person, person, light skin tone, dark skin tone +🧑🏼‍❤️‍🧑🏻 couple with heart: person, person, medium-light skin tone, light skin tone +🧑🏼‍❤️‍🧑🏽 couple with heart: person, person, medium-light skin tone, medium skin tone +🧑🏼‍❤️‍🧑🏾 couple with heart: person, person, medium-light skin tone, medium-dark skin tone +🧑🏼‍❤️‍🧑🏿 couple with heart: person, person, medium-light skin tone, dark skin tone +🧑🏽‍❤️‍🧑🏻 couple with heart: person, person, medium skin tone, light skin tone +🧑🏽‍❤️‍🧑🏼 couple with heart: person, person, medium skin tone, medium-light skin tone +🧑🏽‍❤️‍🧑🏾 couple with heart: person, person, medium skin tone, medium-dark skin tone +🧑🏽‍❤️‍🧑🏿 couple with heart: person, person, medium skin tone, dark skin tone +🧑🏾‍❤️‍🧑🏻 couple with heart: person, person, medium-dark skin tone, light skin tone +🧑🏾‍❤️‍🧑🏼 couple with heart: person, person, medium-dark skin tone, medium-light skin tone +🧑🏾‍❤️‍🧑🏽 couple with heart: person, person, medium-dark skin tone, medium skin tone +🧑🏾‍❤️‍🧑🏿 couple with heart: person, person, medium-dark skin tone, dark skin tone +🧑🏿‍❤️‍🧑🏻 couple with heart: person, person, dark skin tone, light skin tone +🧑🏿‍❤️‍🧑🏼 couple with heart: person, person, dark skin tone, medium-light skin tone +🧑🏿‍❤️‍🧑🏽 couple with heart: person, person, dark skin tone, medium skin tone +🧑🏿‍❤️‍🧑🏾 couple with heart: person, person, dark skin tone, medium-dark skin tone +👩‍❤️‍👨 couple with heart: woman, man +👩🏻‍❤️‍👨🏻 couple with heart: woman, man, light skin tone +👩🏻‍❤️‍👨🏼 couple with heart: woman, man, light skin tone, medium-light skin tone +👩🏻‍❤️‍👨🏽 couple with heart: woman, man, light skin tone, medium skin tone +👩🏻‍❤️‍👨🏾 couple with heart: woman, man, light skin tone, medium-dark skin tone +👩🏻‍❤️‍👨🏿 couple with heart: woman, man, light skin tone, dark skin tone +👩🏼‍❤️‍👨🏻 couple with heart: woman, man, medium-light skin tone, light skin tone +👩🏼‍❤️‍👨🏼 couple with heart: woman, man, medium-light skin tone +👩🏼‍❤️‍👨🏽 couple with heart: woman, man, medium-light skin tone, medium skin tone +👩🏼‍❤️‍👨🏾 couple with heart: woman, man, medium-light skin tone, medium-dark skin tone +👩🏼‍❤️‍👨🏿 couple with heart: woman, man, medium-light skin tone, dark skin tone +👩🏽‍❤️‍👨🏻 couple with heart: woman, man, medium skin tone, light skin tone +👩🏽‍❤️‍👨🏼 couple with heart: woman, man, medium skin tone, medium-light skin tone +👩🏽‍❤️‍👨🏽 couple with heart: woman, man, medium skin tone +👩🏽‍❤️‍👨🏾 couple with heart: woman, man, medium skin tone, medium-dark skin tone +👩🏽‍❤️‍👨🏿 couple with heart: woman, man, medium skin tone, dark skin tone +👩🏾‍❤️‍👨🏻 couple with heart: woman, man, medium-dark skin tone, light skin tone +👩🏾‍❤️‍👨🏼 couple with heart: woman, man, medium-dark skin tone, medium-light skin tone +👩🏾‍❤️‍👨🏽 couple with heart: woman, man, medium-dark skin tone, medium skin tone +👩🏾‍❤️‍👨🏾 couple with heart: woman, man, medium-dark skin tone +👩🏾‍❤️‍👨🏿 couple with heart: woman, man, medium-dark skin tone, dark skin tone +👩🏿‍❤️‍👨🏻 couple with heart: woman, man, dark skin tone, light skin tone +👩🏿‍❤️‍👨🏼 couple with heart: woman, man, dark skin tone, medium-light skin tone +👩🏿‍❤️‍👨🏽 couple with heart: woman, man, dark skin tone, medium skin tone +👩🏿‍❤️‍👨🏾 couple with heart: woman, man, dark skin tone, medium-dark skin tone +👩🏿‍❤️‍👨🏿 couple with heart: woman, man, dark skin tone +👨‍❤️‍👨 couple with heart: man, man +👨🏻‍❤️‍👨🏻 couple with heart: man, man, light skin tone +👨🏻‍❤️‍👨🏼 couple with heart: man, man, light skin tone, medium-light skin tone +👨🏻‍❤️‍👨🏽 couple with heart: man, man, light skin tone, medium skin tone +👨🏻‍❤️‍👨🏾 couple with heart: man, man, light skin tone, medium-dark skin tone +👨🏻‍❤️‍👨🏿 couple with heart: man, man, light skin tone, dark skin tone +👨🏼‍❤️‍👨🏻 couple with heart: man, man, medium-light skin tone, light skin tone +👨🏼‍❤️‍👨🏼 couple with heart: man, man, medium-light skin tone +👨🏼‍❤️‍👨🏽 couple with heart: man, man, medium-light skin tone, medium skin tone +👨🏼‍❤️‍👨🏾 couple with heart: man, man, medium-light skin tone, medium-dark skin tone +👨🏼‍❤️‍👨🏿 couple with heart: man, man, medium-light skin tone, dark skin tone +👨🏽‍❤️‍👨🏻 couple with heart: man, man, medium skin tone, light skin tone +👨🏽‍❤️‍👨🏼 couple with heart: man, man, medium skin tone, medium-light skin tone +👨🏽‍❤️‍👨🏽 couple with heart: man, man, medium skin tone +👨🏽‍❤️‍👨🏾 couple with heart: man, man, medium skin tone, medium-dark skin tone +👨🏽‍❤️‍👨🏿 couple with heart: man, man, medium skin tone, dark skin tone +👨🏾‍❤️‍👨🏻 couple with heart: man, man, medium-dark skin tone, light skin tone +👨🏾‍❤️‍👨🏼 couple with heart: man, man, medium-dark skin tone, medium-light skin tone +👨🏾‍❤️‍👨🏽 couple with heart: man, man, medium-dark skin tone, medium skin tone +👨🏾‍❤️‍👨🏾 couple with heart: man, man, medium-dark skin tone +👨🏾‍❤️‍👨🏿 couple with heart: man, man, medium-dark skin tone, dark skin tone +👨🏿‍❤️‍👨🏻 couple with heart: man, man, dark skin tone, light skin tone +👨🏿‍❤️‍👨🏼 couple with heart: man, man, dark skin tone, medium-light skin tone +👨🏿‍❤️‍👨🏽 couple with heart: man, man, dark skin tone, medium skin tone +👨🏿‍❤️‍👨🏾 couple with heart: man, man, dark skin tone, medium-dark skin tone +👨🏿‍❤️‍👨🏿 couple with heart: man, man, dark skin tone +👩‍❤️‍👩 couple with heart: woman, woman +👩🏻‍❤️‍👩🏻 couple with heart: woman, woman, light skin tone +👩🏻‍❤️‍👩🏼 couple with heart: woman, woman, light skin tone, medium-light skin tone +👩🏻‍❤️‍👩🏽 couple with heart: woman, woman, light skin tone, medium skin tone +👩🏻‍❤️‍👩🏾 couple with heart: woman, woman, light skin tone, medium-dark skin tone +👩🏻‍❤️‍👩🏿 couple with heart: woman, woman, light skin tone, dark skin tone +👩🏼‍❤️‍👩🏻 couple with heart: woman, woman, medium-light skin tone, light skin tone +👩🏼‍❤️‍👩🏼 couple with heart: woman, woman, medium-light skin tone +👩🏼‍❤️‍👩🏽 couple with heart: woman, woman, medium-light skin tone, medium skin tone +👩🏼‍❤️‍👩🏾 couple with heart: woman, woman, medium-light skin tone, medium-dark skin tone +👩🏼‍❤️‍👩🏿 couple with heart: woman, woman, medium-light skin tone, dark skin tone +👩🏽‍❤️‍👩🏻 couple with heart: woman, woman, medium skin tone, light skin tone +👩🏽‍❤️‍👩🏼 couple with heart: woman, woman, medium skin tone, medium-light skin tone +👩🏽‍❤️‍👩🏽 couple with heart: woman, woman, medium skin tone +👩🏽‍❤️‍👩🏾 couple with heart: woman, woman, medium skin tone, medium-dark skin tone +👩🏽‍❤️‍👩🏿 couple with heart: woman, woman, medium skin tone, dark skin tone +👩🏾‍❤️‍👩🏻 couple with heart: woman, woman, medium-dark skin tone, light skin tone +👩🏾‍❤️‍👩🏼 couple with heart: woman, woman, medium-dark skin tone, medium-light skin tone +👩🏾‍❤️‍👩🏽 couple with heart: woman, woman, medium-dark skin tone, medium skin tone +👩🏾‍❤️‍👩🏾 couple with heart: woman, woman, medium-dark skin tone +👩🏾‍❤️‍👩🏿 couple with heart: woman, woman, medium-dark skin tone, dark skin tone +👩🏿‍❤️‍👩🏻 couple with heart: woman, woman, dark skin tone, light skin tone +👩🏿‍❤️‍👩🏼 couple with heart: woman, woman, dark skin tone, medium-light skin tone +👩🏿‍❤️‍👩🏽 couple with heart: woman, woman, dark skin tone, medium skin tone +👩🏿‍❤️‍👩🏾 couple with heart: woman, woman, dark skin tone, medium-dark skin tone +👩🏿‍❤️‍👩🏿 couple with heart: woman, woman, dark skin tone +👨‍👩‍👦 family: man, woman, boy +👨‍👩‍👧 family: man, woman, girl +👨‍👩‍👧‍👦 family: man, woman, girl, boy +👨‍👩‍👦‍👦 family: man, woman, boy, boy +👨‍👩‍👧‍👧 family: man, woman, girl, girl +👨‍👨‍👦 family: man, man, boy +👨‍👨‍👧 family: man, man, girl +👨‍👨‍👧‍👦 family: man, man, girl, boy +👨‍👨‍👦‍👦 family: man, man, boy, boy +👨‍👨‍👧‍👧 family: man, man, girl, girl +👩‍👩‍👦 family: woman, woman, boy +👩‍👩‍👧 family: woman, woman, girl +👩‍👩‍👧‍👦 family: woman, woman, girl, boy +👩‍👩‍👦‍👦 family: woman, woman, boy, boy +👩‍👩‍👧‍👧 family: woman, woman, girl, girl +👨‍👦 family: man, boy +👨‍👦‍👦 family: man, boy, boy +👨‍👧 family: man, girl +👨‍👧‍👦 family: man, girl, boy +👨‍👧‍👧 family: man, girl, girl +👩‍👦 family: woman, boy +👩‍👦‍👦 family: woman, boy, boy +👩‍👧 family: woman, girl +👩‍👧‍👦 family: woman, girl, boy +👩‍👧‍👧 family: woman, girl, girl +🗣️ speaking head +👤 bust in silhouette +👥 busts in silhouette +🫂 people hugging +👪 family +🧑‍🧑‍🧒 family: adult, adult, child +🧑‍🧑‍🧒‍🧒 family: adult, adult, child, child +🧑‍🧒 family: adult, child +🧑‍🧒‍🧒 family: adult, child, child +👣 footprints +🫆 fingerprint +# group: Component +# group: Animals & Nature +🐵 monkey face +🐒 monkey +🦍 gorilla +🦧 orangutan +🐶 dog face +🐕 dog +🦮 guide dog +🐕‍🦺 service dog +🐩 poodle +🐺 wolf +🦊 fox +🦝 raccoon +🐱 cat face +🐈 cat +🐈‍⬛ black cat +🦁 lion +🐯 tiger face +🐅 tiger +🐆 leopard +🐴 horse face +🫎 moose +🫏 donkey +🐎 horse +🦄 unicorn +🦓 zebra +🦌 deer +🦬 bison +🐮 cow face +🐂 ox +🐃 water buffalo +🐄 cow +🐷 pig face +🐖 pig +🐗 boar +🐽 pig nose +🐏 ram +🐑 ewe +🐐 goat +🐪 camel +🐫 two-hump camel +🦙 llama +🦒 giraffe +🐘 elephant +🦣 mammoth +🦏 rhinoceros +🦛 hippopotamus +🐭 mouse face +🐁 mouse +🐀 rat +🐹 hamster +🐰 rabbit face +🐇 rabbit +🐿️ chipmunk +🦫 beaver +🦔 hedgehog +🦇 bat +🐻 bear +🐻‍❄️ polar bear +🐨 koala +🐼 panda +🦥 sloth +🦦 otter +🦨 skunk +🦘 kangaroo +🦡 badger +🐾 paw prints +🦃 turkey +🐔 chicken +🐓 rooster +🐣 hatching chick +🐤 baby chick +🐥 front-facing baby chick +🐦 bird +🐧 penguin +🕊️ dove +🦅 eagle +🦆 duck +🦢 swan +🦉 owl +🦤 dodo +🪶 feather +🦩 flamingo +🦚 peacock +🦜 parrot +🪽 wing +🐦‍⬛ black bird +🪿 goose +🐦‍🔥 phoenix +🐸 frog +🐊 crocodile +🐢 turtle +🦎 lizard +🐍 snake +🐲 dragon face +🐉 dragon +🦕 sauropod +🦖 T-Rex +🐳 spouting whale +🐋 whale +🐬 dolphin +🦭 seal +🐟 fish +🐠 tropical fish +🐡 blowfish +🦈 shark +🐙 octopus +🐚 spiral shell +🪸 coral +🪼 jellyfish +🦀 crab +🦞 lobster +🦐 shrimp +🦑 squid +🦪 oyster +🐌 snail +🦋 butterfly +🐛 bug +🐜 ant +🐝 honeybee +🪲 beetle +🐞 lady beetle +🦗 cricket +🪳 cockroach +🕷️ spider +🕸️ spider web +🦂 scorpion +🦟 mosquito +🪰 fly +🪱 worm +🦠 microbe +💐 bouquet +🌸 cherry blossom +💮 white flower +🪷 lotus +🏵️ rosette +🌹 rose +🥀 wilted flower +🌺 hibiscus +🌻 sunflower +🌼 blossom +🌷 tulip +🪻 hyacinth +🌱 seedling +🪴 potted plant +🌲 evergreen tree +🌳 deciduous tree +🌴 palm tree +🌵 cactus +🌾 sheaf of rice +🌿 herb +☘️ shamrock +🍀 four leaf clover +🍁 maple leaf +🍂 fallen leaf +🍃 leaf fluttering in wind +🪹 empty nest +🪺 nest with eggs +🍄 mushroom +🪾 leafless tree +# group: Food & Drink +🍇 grapes +🍈 melon +🍉 watermelon +🍊 tangerine +🍋 lemon +🍋‍🟩 lime +🍌 banana +🍍 pineapple +🥭 mango +🍎 red apple +🍏 green apple +🍐 pear +🍑 peach +🍒 cherries +🍓 strawberry +🫐 blueberries +🥝 kiwi fruit +🍅 tomato +🫒 olive +🥥 coconut +🥑 avocado +🍆 eggplant +🥔 potato +🥕 carrot +🌽 ear of corn +🌶️ hot pepper +🫑 bell pepper +🥒 cucumber +🥬 leafy green +🥦 broccoli +🧄 garlic +🧅 onion +🥜 peanuts +🫘 beans +🌰 chestnut +🫚 ginger root +🫛 pea pod +🍄‍🟫 brown mushroom +🫜 root vegetable +🍞 bread +🥐 croissant +🥖 baguette bread +🫓 flatbread +🥨 pretzel +🥯 bagel +🥞 pancakes +🧇 waffle +🧀 cheese wedge +🍖 meat on bone +🍗 poultry leg +🥩 cut of meat +🥓 bacon +🍔 hamburger +🍟 french fries +🍕 pizza +🌭 hot dog +🥪 sandwich +🌮 taco +🌯 burrito +🫔 tamale +🥙 stuffed flatbread +🧆 falafel +🥚 egg +🍳 cooking +🥘 shallow pan of food +🍲 pot of food +🫕 fondue +🥣 bowl with spoon +🥗 green salad +🍿 popcorn +🧈 butter +🧂 salt +🥫 canned food +🍱 bento box +🍘 rice cracker +🍙 rice ball +🍚 cooked rice +🍛 curry rice +🍜 steaming bowl +🍝 spaghetti +🍠 roasted sweet potato +🍢 oden +🍣 sushi +🍤 fried shrimp +🍥 fish cake with swirl +🥮 moon cake +🍡 dango +🥟 dumpling +🥠 fortune cookie +🥡 takeout box +🍦 soft ice cream +🍧 shaved ice +🍨 ice cream +🍩 doughnut +🍪 cookie +🎂 birthday cake +🍰 shortcake +🧁 cupcake +🥧 pie +🍫 chocolate bar +🍬 candy +🍭 lollipop +🍮 custard +🍯 honey pot +🍼 baby bottle +🥛 glass of milk +☕ hot beverage +🫖 teapot +🍵 teacup without handle +🍶 sake +🍾 bottle with popping cork +🍷 wine glass +🍸 cocktail glass +🍹 tropical drink +🍺 beer mug +🍻 clinking beer mugs +🥂 clinking glasses +🥃 tumbler glass +🫗 pouring liquid +🥤 cup with straw +🧋 bubble tea +🧃 beverage box +🧉 mate +🧊 ice +🥢 chopsticks +🍽️ fork and knife with plate +🍴 fork and knife +🥄 spoon +🔪 kitchen knife +🫙 jar +🏺 amphora +# group: Travel & Places +🌍 globe showing Europe-Africa +🌎 globe showing Americas +🌏 globe showing Asia-Australia +🌐 globe with meridians +🗺️ world map +🗾 map of Japan +🧭 compass +🏔️ snow-capped mountain +⛰️ mountain +🌋 volcano +🗻 mount fuji +🏕️ camping +🏖️ beach with umbrella +🏜️ desert +🏝️ desert island +🏞️ national park +🏟️ stadium +🏛️ classical building +🏗️ building construction +🧱 brick +🪨 rock +🪵 wood +🛖 hut +🏘️ houses +🏚️ derelict house +🏠 house +🏡 house with garden +🏢 office building +🏣 Japanese post office +🏤 post office +🏥 hospital +🏦 bank +🏨 hotel +🏩 love hotel +🏪 convenience store +🏫 school +🏬 department store +🏭 factory +🏯 Japanese castle +🏰 castle +💒 wedding +🗼 Tokyo tower +🗽 Statue of Liberty +⛪ church +🕌 mosque +🛕 hindu temple +🕍 synagogue +⛩️ shinto shrine +🕋 kaaba +⛲ fountain +⛺ tent +🌁 foggy +🌃 night with stars +🏙️ cityscape +🌄 sunrise over mountains +🌅 sunrise +🌆 cityscape at dusk +🌇 sunset +🌉 bridge at night +♨️ hot springs +🎠 carousel horse +🛝 playground slide +🎡 ferris wheel +🎢 roller coaster +💈 barber pole +🎪 circus tent +🚂 locomotive +🚃 railway car +🚄 high-speed train +🚅 bullet train +🚆 train +🚇 metro +🚈 light rail +🚉 station +🚊 tram +🚝 monorail +🚞 mountain railway +🚋 tram car +🚌 bus +🚍 oncoming bus +🚎 trolleybus +🚐 minibus +🚑 ambulance +🚒 fire engine +🚓 police car +🚔 oncoming police car +🚕 taxi +🚖 oncoming taxi +🚗 automobile +🚘 oncoming automobile +🚙 sport utility vehicle +🛻 pickup truck +🚚 delivery truck +🚛 articulated lorry +🚜 tractor +🏎️ racing car +🏍️ motorcycle +🛵 motor scooter +🦽 manual wheelchair +🦼 motorized wheelchair +🛺 auto rickshaw +🚲 bicycle +🛴 kick scooter +🛹 skateboard +🛼 roller skate +🚏 bus stop +🛣️ motorway +🛤️ railway track +🛢️ oil drum +⛽ fuel pump +🛞 wheel +🚨 police car light +🚥 horizontal traffic light +🚦 vertical traffic light +🛑 stop sign +🚧 construction +⚓ anchor +🛟 ring buoy +⛵ sailboat +🛶 canoe +🚤 speedboat +🛳️ passenger ship +⛴️ ferry +🛥️ motor boat +🚢 ship +✈️ airplane +🛩️ small airplane +🛫 airplane departure +🛬 airplane arrival +🪂 parachute +💺 seat +🚁 helicopter +🚟 suspension railway +🚠 mountain cableway +🚡 aerial tramway +🛰️ satellite +🚀 rocket +🛸 flying saucer +🛎️ bellhop bell +🧳 luggage +⌛ hourglass done +⏳ hourglass not done +⌚ watch +⏰ alarm clock +⏱️ stopwatch +⏲️ timer clock +🕰️ mantelpiece clock +🕛 twelve o’clock +🕧 twelve-thirty +🕐 one o’clock +🕜 one-thirty +🕑 two o’clock +🕝 two-thirty +🕒 three o’clock +🕞 three-thirty +🕓 four o’clock +🕟 four-thirty +🕔 five o’clock +🕠 five-thirty +🕕 six o’clock +🕡 six-thirty +🕖 seven o’clock +🕢 seven-thirty +🕗 eight o’clock +🕣 eight-thirty +🕘 nine o’clock +🕤 nine-thirty +🕙 ten o’clock +🕥 ten-thirty +🕚 eleven o’clock +🕦 eleven-thirty +🌑 new moon +🌒 waxing crescent moon +🌓 first quarter moon +🌔 waxing gibbous moon +🌕 full moon +🌖 waning gibbous moon +🌗 last quarter moon +🌘 waning crescent moon +🌙 crescent moon +🌚 new moon face +🌛 first quarter moon face +🌜 last quarter moon face +🌡️ thermometer +☀️ sun +🌝 full moon face +🌞 sun with face +🪐 ringed planet +⭐ star +🌟 glowing star +🌠 shooting star +🌌 milky way +☁️ cloud +⛅ sun behind cloud +⛈️ cloud with lightning and rain +🌤️ sun behind small cloud +🌥️ sun behind large cloud +🌦️ sun behind rain cloud +🌧️ cloud with rain +🌨️ cloud with snow +🌩️ cloud with lightning +🌪️ tornado +🌫️ fog +🌬️ wind face +🌀 cyclone +🌈 rainbow +🌂 closed umbrella +☂️ umbrella +☔ umbrella with rain drops +⛱️ umbrella on ground +⚡ high voltage +❄️ snowflake +☃️ snowman +⛄ snowman without snow +☄️ comet +🔥 fire +💧 droplet +🌊 water wave +# group: Activities +🎃 jack-o-lantern +🎄 Christmas tree +🎆 fireworks +🎇 sparkler +🧨 firecracker +✨ sparkles +🎈 balloon +🎉 party popper +🎊 confetti ball +🎋 tanabata tree +🎍 pine decoration +🎎 Japanese dolls +🎏 carp streamer +🎐 wind chime +🎑 moon viewing ceremony +🧧 red envelope +🎀 ribbon +🎁 wrapped gift +🎗️ reminder ribbon +🎟️ admission tickets +🎫 ticket +🎖️ military medal +🏆 trophy +🏅 sports medal +🥇 1st place medal +🥈 2nd place medal +🥉 3rd place medal +⚽ soccer ball +⚾ baseball +🥎 softball +🏀 basketball +🏐 volleyball +🏈 american football +🏉 rugby football +🎾 tennis +🥏 flying disc +🎳 bowling +🏏 cricket game +🏑 field hockey +🏒 ice hockey +🥍 lacrosse +🏓 ping pong +🏸 badminton +🥊 boxing glove +🥋 martial arts uniform +🥅 goal net +⛳ flag in hole +⛸️ ice skate +🎣 fishing pole +🤿 diving mask +🎽 running shirt +🎿 skis +🛷 sled +🥌 curling stone +🎯 bullseye +🪀 yo-yo +🪁 kite +🔫 water pistol +🎱 pool 8 ball +🔮 crystal ball +🪄 magic wand +🎮 video game +🕹️ joystick +🎰 slot machine +🎲 game die +🧩 puzzle piece +🧸 teddy bear +🪅 piñata +🪩 mirror ball +🪆 nesting dolls +♠️ spade suit +♥️ heart suit +♦️ diamond suit +♣️ club suit +♟️ chess pawn +🃏 joker +🀄 mahjong red dragon +🎴 flower playing cards +🎭 performing arts +🖼️ framed picture +🎨 artist palette +🧵 thread +🪡 sewing needle +🧶 yarn +🪢 knot +# group: Objects +👓 glasses +🕶️ sunglasses +🥽 goggles +🥼 lab coat +🦺 safety vest +👔 necktie +👕 t-shirt +👖 jeans +🧣 scarf +🧤 gloves +🧥 coat +🧦 socks +👗 dress +👘 kimono +🥻 sari +🩱 one-piece swimsuit +🩲 briefs +🩳 shorts +👙 bikini +👚 woman’s clothes +🪭 folding hand fan +👛 purse +👜 handbag +👝 clutch bag +🛍️ shopping bags +🎒 backpack +🩴 thong sandal +👞 man’s shoe +👟 running shoe +🥾 hiking boot +🥿 flat shoe +👠 high-heeled shoe +👡 woman’s sandal +🩰 ballet shoes +👢 woman’s boot +🪮 hair pick +👑 crown +👒 woman’s hat +🎩 top hat +🎓 graduation cap +🧢 billed cap +🪖 military helmet +⛑️ rescue worker’s helmet +📿 prayer beads +💄 lipstick +💍 ring +💎 gem stone +🔇 muted speaker +🔈 speaker low volume +🔉 speaker medium volume +🔊 speaker high volume +📢 loudspeaker +📣 megaphone +📯 postal horn +🔔 bell +🔕 bell with slash +🎼 musical score +🎵 musical note +🎶 musical notes +🎙️ studio microphone +🎚️ level slider +🎛️ control knobs +🎤 microphone +🎧 headphone +📻 radio +🎷 saxophone +🪗 accordion +🎸 guitar +🎹 musical keyboard +🎺 trumpet +🎻 violin +🪕 banjo +🥁 drum +🪘 long drum +🪇 maracas +🪈 flute +🪉 harp +📱 mobile phone +📲 mobile phone with arrow +☎️ telephone +📞 telephone receiver +📟 pager +📠 fax machine +🔋 battery +🪫 low battery +🔌 electric plug +💻 laptop +🖥️ desktop computer +🖨️ printer +⌨️ keyboard +🖱️ computer mouse +🖲️ trackball +💽 computer disk +💾 floppy disk +💿 optical disk +📀 dvd +🧮 abacus +🎥 movie camera +🎞️ film frames +📽️ film projector +🎬 clapper board +📺 television +📷 camera +📸 camera with flash +📹 video camera +📼 videocassette +🔍 magnifying glass tilted left +🔎 magnifying glass tilted right +🕯️ candle +💡 light bulb +🔦 flashlight +🏮 red paper lantern +🪔 diya lamp +📔 notebook with decorative cover +📕 closed book +📖 open book +📗 green book +📘 blue book +📙 orange book +📚 books +📓 notebook +📒 ledger +📃 page with curl +📜 scroll +📄 page facing up +📰 newspaper +🗞️ rolled-up newspaper +📑 bookmark tabs +🔖 bookmark +🏷️ label +💰 money bag +🪙 coin +💴 yen banknote +💵 dollar banknote +💶 euro banknote +💷 pound banknote +💸 money with wings +💳 credit card +🧾 receipt +💹 chart increasing with yen +✉️ envelope +📧 e-mail +📨 incoming envelope +📩 envelope with arrow +📤 outbox tray +📥 inbox tray +📦 package +📫 closed mailbox with raised flag +📪 closed mailbox with lowered flag +📬 open mailbox with raised flag +📭 open mailbox with lowered flag +📮 postbox +🗳️ ballot box with ballot +✏️ pencil +✒️ black nib +🖋️ fountain pen +🖊️ pen +🖌️ paintbrush +🖍️ crayon +📝 memo +💼 briefcase +📁 file folder +📂 open file folder +🗂️ card index dividers +📅 calendar +📆 tear-off calendar +🗒️ spiral notepad +🗓️ spiral calendar +📇 card index +📈 chart increasing +📉 chart decreasing +📊 bar chart +📋 clipboard +📌 pushpin +📍 round pushpin +📎 paperclip +🖇️ linked paperclips +📏 straight ruler +📐 triangular ruler +✂️ scissors +🗃️ card file box +🗄️ file cabinet +🗑️ wastebasket +🔒 locked +🔓 unlocked +🔏 locked with pen +🔐 locked with key +🔑 key +🗝️ old key +🔨 hammer +🪓 axe +⛏️ pick +⚒️ hammer and pick +🛠️ hammer and wrench +🗡️ dagger +⚔️ crossed swords +💣 bomb +🪃 boomerang +🏹 bow and arrow +🛡️ shield +🪚 carpentry saw +🔧 wrench +🪛 screwdriver +🔩 nut and bolt +⚙️ gear +🗜️ clamp +⚖️ balance scale +🦯 white cane +🔗 link +⛓️‍💥 broken chain +⛓️ chains +🪝 hook +🧰 toolbox +🧲 magnet +🪜 ladder +🪏 shovel +⚗️ alembic +🧪 test tube +🧫 petri dish +🧬 dna +🔬 microscope +🔭 telescope +📡 satellite antenna +💉 syringe +🩸 drop of blood +💊 pill +🩹 adhesive bandage +🩼 crutch +🩺 stethoscope +🩻 x-ray +🚪 door +🛗 elevator +🪞 mirror +🪟 window +🛏️ bed +🛋️ couch and lamp +🪑 chair +🚽 toilet +🪠 plunger +🚿 shower +🛁 bathtub +🪤 mouse trap +🪒 razor +🧴 lotion bottle +🧷 safety pin +🧹 broom +🧺 basket +🧻 roll of paper +🪣 bucket +🧼 soap +🫧 bubbles +🪥 toothbrush +🧽 sponge +🧯 fire extinguisher +🛒 shopping cart +🚬 cigarette +⚰️ coffin +🪦 headstone +⚱️ funeral urn +🧿 nazar amulet +🪬 hamsa +🗿 moai +🪧 placard +🪪 identification card +# group: Symbols +🏧 ATM sign +🚮 litter in bin sign +🚰 potable water +♿ wheelchair symbol +🚹 men’s room +🚺 women’s room +🚻 restroom +🚼 baby symbol +🚾 water closet +🛂 passport control +🛃 customs +🛄 baggage claim +🛅 left luggage +⚠️ warning +🚸 children crossing +⛔ no entry +🚫 prohibited +🚳 no bicycles +🚭 no smoking +🚯 no littering +🚱 non-potable water +🚷 no pedestrians +📵 no mobile phones +🔞 no one under eighteen +☢️ radioactive +☣️ biohazard +⬆️ up arrow +↗️ up-right arrow +➡️ right arrow +↘️ down-right arrow +⬇️ down arrow +↙️ down-left arrow +⬅️ left arrow +↖️ up-left arrow +↕️ up-down arrow +↔️ left-right arrow +↩️ right arrow curving left +↪️ left arrow curving right +⤴️ right arrow curving up +⤵️ right arrow curving down +🔃 clockwise vertical arrows +🔄 counterclockwise arrows button +🔙 BACK arrow +🔚 END arrow +🔛 ON! arrow +🔜 SOON arrow +🔝 TOP arrow +🛐 place of worship +⚛️ atom symbol +🕉️ om +✡️ star of David +☸️ wheel of dharma +☯️ yin yang +✝️ latin cross +☦️ orthodox cross +☪️ star and crescent +☮️ peace symbol +🕎 menorah +🔯 dotted six-pointed star +🪯 khanda +♈ Aries +♉ Taurus +♊ Gemini +♋ Cancer +♌ Leo +♍ Virgo +♎ Libra +♏ Scorpio +♐ Sagittarius +♑ Capricorn +♒ Aquarius +♓ Pisces +⛎ Ophiuchus +🔀 shuffle tracks button +🔁 repeat button +🔂 repeat single button +▶️ play button +⏩ fast-forward button +⏭️ next track button +⏯️ play or pause button +◀️ reverse button +⏪ fast reverse button +⏮️ last track button +🔼 upwards button +⏫ fast up button +🔽 downwards button +⏬ fast down button +⏸️ pause button +⏹️ stop button +⏺️ record button +⏏️ eject button +🎦 cinema +🔅 dim button +🔆 bright button +📶 antenna bars +🛜 wireless +📳 vibration mode +📴 mobile phone off +♀️ female sign +♂️ male sign +⚧️ transgender symbol +✖️ multiply +➕ plus +➖ minus +➗ divide +🟰 heavy equals sign +♾️ infinity +‼️ double exclamation mark +⁉️ exclamation question mark +❓ red question mark +❔ white question mark +❕ white exclamation mark +❗ red exclamation mark +〰️ wavy dash +💱 currency exchange +💲 heavy dollar sign +⚕️ medical symbol +♻️ recycling symbol +⚜️ fleur-de-lis +🔱 trident emblem +📛 name badge +🔰 Japanese symbol for beginner +⭕ hollow red circle +✅ check mark button +☑️ check box with check +✔️ check mark +❌ cross mark +❎ cross mark button +➰ curly loop +➿ double curly loop +〽️ part alternation mark +✳️ eight-spoked asterisk +✴️ eight-pointed star +❇️ sparkle +©️ copyright +®️ registered +™️ trade mark +🫟 splatter +#️⃣ keycap: # +*️⃣ keycap: * +0️⃣ keycap: 0 +1️⃣ keycap: 1 +2️⃣ keycap: 2 +3️⃣ keycap: 3 +4️⃣ keycap: 4 +5️⃣ keycap: 5 +6️⃣ keycap: 6 +7️⃣ keycap: 7 +8️⃣ keycap: 8 +9️⃣ keycap: 9 +🔟 keycap: 10 +🔠 input latin uppercase +🔡 input latin lowercase +🔢 input numbers +🔣 input symbols +🔤 input latin letters +🅰️ A button (blood type) +🆎 AB button (blood type) +🅱️ B button (blood type) +🆑 CL button +🆒 COOL button +🆓 FREE button +ℹ️ information +🆔 ID button +Ⓜ️ circled M +🆕 NEW button +🆖 NG button +🅾️ O button (blood type) +🆗 OK button +🅿️ P button +🆘 SOS button +🆙 UP! button +🆚 VS button +🈁 Japanese “here” button +🈂️ Japanese “service charge” button +🈷️ Japanese “monthly amount” button +🈶 Japanese “not free of charge” button +🈯 Japanese “reserved” button +🉐 Japanese “bargain” button +🈹 Japanese “discount” button +🈚 Japanese “free of charge” button +🈲 Japanese “prohibited” button +🉑 Japanese “acceptable” button +🈸 Japanese “application” button +🈴 Japanese “passing grade” button +🈳 Japanese “vacancy” button +㊗️ Japanese “congratulations” button +㊙️ Japanese “secret” button +🈺 Japanese “open for business” button +🈵 Japanese “no vacancy” button +🔴 red circle +🟠 orange circle +🟡 yellow circle +🟢 green circle +🔵 blue circle +🟣 purple circle +🟤 brown circle +⚫ black circle +⚪ white circle +🟥 red square +🟧 orange square +🟨 yellow square +🟩 green square +🟦 blue square +🟪 purple square +🟫 brown square +⬛ black large square +⬜ white large square +◼️ black medium square +◻️ white medium square +◾ black medium-small square +◽ white medium-small square +▪️ black small square +▫️ white small square +🔶 large orange diamond +🔷 large blue diamond +🔸 small orange diamond +🔹 small blue diamond +🔺 red triangle pointed up +🔻 red triangle pointed down +💠 diamond with a dot +🔘 radio button +🔳 white square button +🔲 black square button +# group: Flags +🏁 chequered flag +🚩 triangular flag +🎌 crossed flags +🏴 black flag +🏳️ white flag +🏳️‍🌈 rainbow flag +🏳️‍⚧️ transgender flag +🏴‍☠️ pirate flag +🇦🇨 flag: Ascension Island +🇦🇩 flag: Andorra +🇦🇪 flag: United Arab Emirates +🇦🇫 flag: Afghanistan +🇦🇬 flag: Antigua & Barbuda +🇦🇮 flag: Anguilla +🇦🇱 flag: Albania +🇦🇲 flag: Armenia +🇦🇴 flag: Angola +🇦🇶 flag: Antarctica +🇦🇷 flag: Argentina +🇦🇸 flag: American Samoa +🇦🇹 flag: Austria +🇦🇺 flag: Australia +🇦🇼 flag: Aruba +🇦🇽 flag: Åland Islands +🇦🇿 flag: Azerbaijan +🇧🇦 flag: Bosnia & Herzegovina +🇧🇧 flag: Barbados +🇧🇩 flag: Bangladesh +🇧🇪 flag: Belgium +🇧🇫 flag: Burkina Faso +🇧🇬 flag: Bulgaria +🇧🇭 flag: Bahrain +🇧🇮 flag: Burundi +🇧🇯 flag: Benin +🇧🇱 flag: St. Barthélemy +🇧🇲 flag: Bermuda +🇧🇳 flag: Brunei +🇧🇴 flag: Bolivia +🇧🇶 flag: Caribbean Netherlands +🇧🇷 flag: Brazil +🇧🇸 flag: Bahamas +🇧🇹 flag: Bhutan +🇧🇻 flag: Bouvet Island +🇧🇼 flag: Botswana +🇧🇾 flag: Belarus +🇧🇿 flag: Belize +🇨🇦 flag: Canada +🇨🇨 flag: Cocos (Keeling) Islands +🇨🇩 flag: Congo - Kinshasa +🇨🇫 flag: Central African Republic +🇨🇬 flag: Congo - Brazzaville +🇨🇭 flag: Switzerland +🇨🇮 flag: Côte d’Ivoire +🇨🇰 flag: Cook Islands +🇨🇱 flag: Chile +🇨🇲 flag: Cameroon +🇨🇳 flag: China +🇨🇴 flag: Colombia +🇨🇵 flag: Clipperton Island +🇨🇶 flag: Sark +🇨🇷 flag: Costa Rica +🇨🇺 flag: Cuba +🇨🇻 flag: Cape Verde +🇨🇼 flag: Curaçao +🇨🇽 flag: Christmas Island +🇨🇾 flag: Cyprus +🇨🇿 flag: Czechia +🇩🇪 flag: Germany +🇩🇬 flag: Diego Garcia +🇩🇯 flag: Djibouti +🇩🇰 flag: Denmark +🇩🇲 flag: Dominica +🇩🇴 flag: Dominican Republic +🇩🇿 flag: Algeria +🇪🇦 flag: Ceuta & Melilla +🇪🇨 flag: Ecuador +🇪🇪 flag: Estonia +🇪🇬 flag: Egypt +🇪🇭 flag: Western Sahara +🇪🇷 flag: Eritrea +🇪🇸 flag: Spain +🇪🇹 flag: Ethiopia +🇪🇺 flag: European Union +🇫🇮 flag: Finland +🇫🇯 flag: Fiji +🇫🇰 flag: Falkland Islands +🇫🇲 flag: Micronesia +🇫🇴 flag: Faroe Islands +🇫🇷 flag: France +🇬🇦 flag: Gabon +🇬🇧 flag: United Kingdom +🇬🇩 flag: Grenada +🇬🇪 flag: Georgia +🇬🇫 flag: French Guiana +🇬🇬 flag: Guernsey +🇬🇭 flag: Ghana +🇬🇮 flag: Gibraltar +🇬🇱 flag: Greenland +🇬🇲 flag: Gambia +🇬🇳 flag: Guinea +🇬🇵 flag: Guadeloupe +🇬🇶 flag: Equatorial Guinea +🇬🇷 flag: Greece +🇬🇸 flag: South Georgia & South Sandwich Islands +🇬🇹 flag: Guatemala +🇬🇺 flag: Guam +🇬🇼 flag: Guinea-Bissau +🇬🇾 flag: Guyana +🇭🇰 flag: Hong Kong SAR China +🇭🇲 flag: Heard & McDonald Islands +🇭🇳 flag: Honduras +🇭🇷 flag: Croatia +🇭🇹 flag: Haiti +🇭🇺 flag: Hungary +🇮🇨 flag: Canary Islands +🇮🇩 flag: Indonesia +🇮🇪 flag: Ireland +🇮🇱 flag: Israel +🇮🇲 flag: Isle of Man +🇮🇳 flag: India +🇮🇴 flag: British Indian Ocean Territory +🇮🇶 flag: Iraq +🇮🇷 flag: Iran +🇮🇸 flag: Iceland +🇮🇹 flag: Italy +🇯🇪 flag: Jersey +🇯🇲 flag: Jamaica +🇯🇴 flag: Jordan +🇯🇵 flag: Japan +🇰🇪 flag: Kenya +🇰🇬 flag: Kyrgyzstan +🇰🇭 flag: Cambodia +🇰🇮 flag: Kiribati +🇰🇲 flag: Comoros +🇰🇳 flag: St. Kitts & Nevis +🇰🇵 flag: North Korea +🇰🇷 flag: South Korea +🇰🇼 flag: Kuwait +🇰🇾 flag: Cayman Islands +🇰🇿 flag: Kazakhstan +🇱🇦 flag: Laos +🇱🇧 flag: Lebanon +🇱🇨 flag: St. Lucia +🇱🇮 flag: Liechtenstein +🇱🇰 flag: Sri Lanka +🇱🇷 flag: Liberia +🇱🇸 flag: Lesotho +🇱🇹 flag: Lithuania +🇱🇺 flag: Luxembourg +🇱🇻 flag: Latvia +🇱🇾 flag: Libya +🇲🇦 flag: Morocco +🇲🇨 flag: Monaco +🇲🇩 flag: Moldova +🇲🇪 flag: Montenegro +🇲🇫 flag: St. Martin +🇲🇬 flag: Madagascar +🇲🇭 flag: Marshall Islands +🇲🇰 flag: North Macedonia +🇲🇱 flag: Mali +🇲🇲 flag: Myanmar (Burma) +🇲🇳 flag: Mongolia +🇲🇴 flag: Macao SAR China +🇲🇵 flag: Northern Mariana Islands +🇲🇶 flag: Martinique +🇲🇷 flag: Mauritania +🇲🇸 flag: Montserrat +🇲🇹 flag: Malta +🇲🇺 flag: Mauritius +🇲🇻 flag: Maldives +🇲🇼 flag: Malawi +🇲🇽 flag: Mexico +🇲🇾 flag: Malaysia +🇲🇿 flag: Mozambique +🇳🇦 flag: Namibia +🇳🇨 flag: New Caledonia +🇳🇪 flag: Niger +🇳🇫 flag: Norfolk Island +🇳🇬 flag: Nigeria +🇳🇮 flag: Nicaragua +🇳🇱 flag: Netherlands +🇳🇴 flag: Norway +🇳🇵 flag: Nepal +🇳🇷 flag: Nauru +🇳🇺 flag: Niue +🇳🇿 flag: New Zealand +🇴🇲 flag: Oman +🇵🇦 flag: Panama +🇵🇪 flag: Peru +🇵🇫 flag: French Polynesia +🇵🇬 flag: Papua New Guinea +🇵🇭 flag: Philippines +🇵🇰 flag: Pakistan +🇵🇱 flag: Poland +🇵🇲 flag: St. Pierre & Miquelon +🇵🇳 flag: Pitcairn Islands +🇵🇷 flag: Puerto Rico +🇵🇸 flag: Palestinian Territories +🇵🇹 flag: Portugal +🇵🇼 flag: Palau +🇵🇾 flag: Paraguay +🇶🇦 flag: Qatar +🇷🇪 flag: Réunion +🇷🇴 flag: Romania +🇷🇸 flag: Serbia +🇷🇺 flag: Russia +🇷🇼 flag: Rwanda +🇸🇦 flag: Saudi Arabia +🇸🇧 flag: Solomon Islands +🇸🇨 flag: Seychelles +🇸🇩 flag: Sudan +🇸🇪 flag: Sweden +🇸🇬 flag: Singapore +🇸🇭 flag: St. Helena +🇸🇮 flag: Slovenia +🇸🇯 flag: Svalbard & Jan Mayen +🇸🇰 flag: Slovakia +🇸🇱 flag: Sierra Leone +🇸🇲 flag: San Marino +🇸🇳 flag: Senegal +🇸🇴 flag: Somalia +🇸🇷 flag: Suriname +🇸🇸 flag: South Sudan +🇸🇹 flag: São Tomé & Príncipe +🇸🇻 flag: El Salvador +🇸🇽 flag: Sint Maarten +🇸🇾 flag: Syria +🇸🇿 flag: Eswatini +🇹🇦 flag: Tristan da Cunha +🇹🇨 flag: Turks & Caicos Islands +🇹🇩 flag: Chad +🇹🇫 flag: French Southern Territories +🇹🇬 flag: Togo +🇹🇭 flag: Thailand +🇹🇯 flag: Tajikistan +🇹🇰 flag: Tokelau +🇹🇱 flag: Timor-Leste +🇹🇲 flag: Turkmenistan +🇹🇳 flag: Tunisia +🇹🇴 flag: Tonga +🇹🇷 flag: Türkiye +🇹🇹 flag: Trinidad & Tobago +🇹🇻 flag: Tuvalu +🇹🇼 flag: Taiwan +🇹🇿 flag: Tanzania +🇺🇦 flag: Ukraine +🇺🇬 flag: Uganda +🇺🇲 flag: U.S. Outlying Islands +🇺🇳 flag: United Nations +🇺🇸 flag: United States +🇺🇾 flag: Uruguay +🇺🇿 flag: Uzbekistan +🇻🇦 flag: Vatican City +🇻🇨 flag: St. Vincent & Grenadines +🇻🇪 flag: Venezuela +🇻🇬 flag: British Virgin Islands +🇻🇮 flag: U.S. Virgin Islands +🇻🇳 flag: Vietnam +🇻🇺 flag: Vanuatu +🇼🇫 flag: Wallis & Futuna +🇼🇸 flag: Samoa +🇽🇰 flag: Kosovo +🇾🇪 flag: Yemen +🇾🇹 flag: Mayotte +🇿🇦 flag: South Africa +🇿🇲 flag: Zambia +🇿🇼 flag: Zimbabwe +🏴󠁧󠁢󠁥󠁮󠁧󠁿 flag: England +🏴󠁧󠁢󠁳󠁣󠁴󠁿 flag: Scotland +🏴󠁧󠁢󠁷󠁬󠁳󠁿 flag: Wales diff --git a/docmostlyTests/App/AppStateNavigationSelectionTests.swift b/docmostlyTests/App/AppStateNavigationSelectionTests.swift index 217bb728..6101911d 100644 --- a/docmostlyTests/App/AppStateNavigationSelectionTests.swift +++ b/docmostlyTests/App/AppStateNavigationSelectionTests.swift @@ -60,6 +60,28 @@ struct AppStateNavigationSelectionTests { #expect(appState.selectedPageID == nil) } + @Test func leavingSpaceSettingsReturnsToTheOriginatingSpace() { + let appState = makeAppState() + appState.selectSpace(id: "space-1") + appState.selectSidebarUtilityDestination(.settings, returningTo: .space("space-1")) + + appState.selectSidebarDestination(nil) + + #expect(appState.selectedSidebarDestination == .space("space-1")) + #expect(appState.selectedSpaceID == "space-1") + } + + @Test func leavingAUtilityDestinationWithoutAReturnDestinationShowsTheSidebar() { + let appState = makeAppState() + appState.selectSpace(id: "space-1") + appState.selectSidebarUtilityDestination(.settings) + + appState.selectSidebarDestination(nil) + + #expect(appState.selectedSidebarDestination == nil) + #expect(appState.selectedSpaceID == "space-1") + } + @Test func leavingTheSelectedPageClearsItsPageAndCommentSelection() { let appState = makeAppState() appState.selectPage(id: "page-1", commentID: "comment-1") diff --git a/docmostlyTests/App/AppStatePageDiscoveryTests.swift b/docmostlyTests/App/AppStatePageDiscoveryTests.swift index cdcdbc94..78659ed3 100644 --- a/docmostlyTests/App/AppStatePageDiscoveryTests.swift +++ b/docmostlyTests/App/AppStatePageDiscoveryTests.swift @@ -16,14 +16,37 @@ struct AppStatePageDiscoveryTests { let initialKey = PageBrowserTaskKey( spaceID: "space-1", scope: .recentlyUpdated, - pageDiscoveryRevision: 0 + pageDiscoveryRevision: 0, + favoriteRevision: 0, + initializedSpaceID: nil ) let refreshedKey = PageBrowserTaskKey( spaceID: "space-1", scope: .recentlyUpdated, - pageDiscoveryRevision: 1 + pageDiscoveryRevision: 1, + favoriteRevision: 0, + initializedSpaceID: nil ) #expect(initialKey != refreshedKey) } + + @Test func initializationChangesTheBrowserTaskIdentity() { + let loadingKey = PageBrowserTaskKey( + spaceID: "space-1", + scope: .recentlyUpdated, + pageDiscoveryRevision: 0, + favoriteRevision: 0, + initializedSpaceID: nil + ) + let initializedKey = PageBrowserTaskKey( + spaceID: "space-1", + scope: .recentlyUpdated, + pageDiscoveryRevision: 0, + favoriteRevision: 0, + initializedSpaceID: "space-1" + ) + + #expect(loadingKey != initializedKey) + } } diff --git a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift index 3f843119..fa873859 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift @@ -27,22 +27,29 @@ struct NativeEditorCRDTCoordinatorReuseTests { @MainActor @Suite(.serialized) struct CRDTEngineAttachmentTests { - @Test func appStateDoesNotCreateCRDTEngineWithoutFactory() async throws { - let appState = AppState(crdtDocumentEngineFactory: nil) - let engine = try await appState.makeCRDTDocumentEngine( + @Test func appStateDoesNotAttachCRDTEngineWithoutFactory() async throws { + let appState = try configuredAppState(crdtDocumentEngineFactory: nil) + let viewModel = NativeRichEditorViewModel( pageID: "page-1", - title: "Page", - document: NativeEditorDocument() + initialTitle: "Page" + ) + + await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( + to: viewModel, + appState: appState ) - #expect(engine == nil) + #expect(viewModel.usesCRDTDocumentEngine == false) + #expect(viewModel.collaborationSession().syncDriver == nil) + #expect(viewModel.canEdit == false) + #expect(viewModel.realtimeStatus == .failed("Native CRDT runtime is unavailable.")) } @Test func crdtAttachmentConfiguresFactoryEngineBeforeCollaborationSession() async throws { let engine = CoordinatorReuseCRDTDocumentEngine() engine.encodedStateVector = Data([42]) let factory = CRDTAttachmentEngineFactory(engine: engine) - let appState = AppState(crdtDocumentEngineFactory: factory) + let appState = try configuredAppState(crdtDocumentEngineFactory: factory) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: [ NativeEditorBlock(kind: .paragraph, text: AttributedString("Seed"), alignment: .left) @@ -68,8 +75,10 @@ struct CRDTEngineAttachmentTests { #expect(frame.message == .sync(.stepOne(Data([42])))) } - @Test func crdtAttachmentReportsFactoryFailureAsCollaborationFailure() async { - let appState = AppState(crdtDocumentEngineFactory: ThrowingCRDTDocumentEngineFactory()) + @Test func crdtAttachmentReportsFactoryFailureAsCollaborationFailure() async throws { + let appState = try configuredAppState( + crdtDocumentEngineFactory: ThrowingCRDTDocumentEngineFactory() + ) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( @@ -97,6 +106,7 @@ struct CRDTEngineAttachmentTests { let appState = AppState(crdtDocumentEngineFactory: CRDTAttachmentEngineFactory(engine: engine)) appState.configure(modelContext: context, modelContainer: container) appState.configurePreviewCacheScope(scope) + appState.currentUser = try currentUser() appState.isOffline = true let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") @@ -106,10 +116,12 @@ struct CRDTEngineAttachmentTests { #expect(viewModel.canEdit) } - @Test func offlineAttachmentFailsClosedWithoutCachedYjsState() async { + @Test func offlineAttachmentFailsClosedWithoutCachedYjsState() async throws { let engine = CoordinatorReuseCRDTDocumentEngine() - let appState = AppState(crdtDocumentEngineFactory: CRDTAttachmentEngineFactory(engine: engine)) - appState.isOffline = true + let appState = try configuredAppState( + crdtDocumentEngineFactory: CRDTAttachmentEngineFactory(engine: engine), + isOffline: true + ) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable(to: viewModel, appState: appState) @@ -120,10 +132,10 @@ struct CRDTEngineAttachmentTests { )) } - @Test func crdtAttachmentDoesNotConfigureEngineAfterCancellation() async { + @Test func crdtAttachmentDoesNotConfigureEngineAfterCancellation() async throws { let engine = CoordinatorReuseCRDTDocumentEngine() let factory = SuspendingCRDTAttachmentEngineFactory(engine: engine) - let appState = AppState(crdtDocumentEngineFactory: factory) + let appState = try configuredAppState(crdtDocumentEngineFactory: factory) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") let attachTask = Task { @@ -142,6 +154,30 @@ struct CRDTEngineAttachmentTests { #expect(viewModel.collaborationSession().syncDriver == nil) #expect(viewModel.realtimeStatus == .disconnected) } + + private func configuredAppState( + crdtDocumentEngineFactory: (any NativeEditorCRDTDocumentEngineFactory)?, + isOffline: Bool = false + ) throws -> AppState { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let appState = AppState(crdtDocumentEngineFactory: crdtDocumentEngineFactory) + appState.configure(modelContext: ModelContext(container), modelContainer: container) + appState.configurePreviewCacheScope(CacheScope( + serverBaseURL: "https://docs.example.com", + userID: "user-1" + )) + appState.currentUser = try currentUser() + appState.isOffline = isOffline + return appState + } + + private func currentUser() throws -> CurrentUserResponse { + let data = try JSONSerialization.data(withJSONObject: [ + "user": ["id": "user-1", "name": "User"], + "workspace": ["id": "workspace-1", "name": "Workspace"] + ]) + return try JSONDecoder().decode(CurrentUserResponse.self, from: data) + } } @MainActor diff --git a/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift b/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift index 4b006b9f..810c65b2 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTSavesTests.swift @@ -130,10 +130,9 @@ struct NativeEditorCRDTSavesTests { #expect(viewModel.saveErrorMessage == nil) } - @Test func offlineCRDTSaveQueuesYjsStateInsteadOfRESTReplacementSnapshot() async throws { - let stateUpdate = Data([1, 4, 9]) + @Test func offlineCRDTSaveQueuesOnlyMetadataReconciliation() async throws { let engine = SavingCRDTDocumentEngine() - engine.saveResult = NativeEditorCRDTSaveResult(documentStateUpdate: stateUpdate) + engine.saveResult = NativeEditorCRDTSaveResult(documentStateUpdate: Data([1, 4, 9])) let viewModel = NativeRichEditorViewModel( pageID: "page-1", initialTitle: "Page", @@ -174,15 +173,13 @@ struct NativeEditorCRDTSavesTests { #expect(didSave) let pending = try await appState.offlineQueueRepository?.pending(scope: scope) #expect(pending?.map(\.payload) == [ - .updatePageCRDT( + .updatePageMetadata( pageId: "page-1", title: "Page", - document: viewModel.document.proseMirrorDocument, - stateUpdate: stateUpdate, baseTitle: "Page" ) ]) - #expect(try await appState.cacheReader?.loadCRDTStateUpdate(pageId: "page-1", scope: scope) == stateUpdate) + #expect(try await appState.cacheReader?.loadCRDTStateUpdate(pageId: "page-1", scope: scope) == nil) } } diff --git a/docmostlyTests/Editor/NativeEditorCollaborationSyncDriverTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationSyncDriverTests.swift new file mode 100644 index 00000000..11f5e630 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorCollaborationSyncDriverTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorCollaborationSyncDriverTests { + @Test func sessionSyncAcknowledgementCoversTheWholeInitialBatch() async throws { + let updates = [Data([1]), Data([2])] + let acknowledgements = SyncAcknowledgementRecorder() + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: SessionTestDocumentEngine(), + pendingLocalUpdatesProvider: { updates }, + localUpdateDidAcknowledge: { update in + await acknowledgements.append(update) + } + ) + let driver = NativeEditorCollaborationSyncDriver(documentName: "page.page-1", coordinator: coordinator) + + #expect(try await driver.outboundFramesAfterAuthentication().count == 3) + try await driver.didSendOutboundFramesAfterAuthentication() + try await driver.didReceiveSyncAcknowledgement() + + #expect(await acknowledgements.updates == updates) + } + + @Test func sessionSyncAcknowledgementDoesNotCoverALiveUpdate() async throws { + let initialUpdate = Data([1]) + let liveUpdate = Data([2]) + let acknowledgements = SyncAcknowledgementRecorder() + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: SessionTestDocumentEngine(), + pendingLocalUpdatesProvider: { [initialUpdate] }, + localUpdateDidAcknowledge: { update in + await acknowledgements.append(update) + } + ) + let driver = NativeEditorCollaborationSyncDriver(documentName: "page.page-1", coordinator: coordinator) + + #expect(try await driver.outboundFramesAfterAuthentication().count == 2) + try await driver.didSendOutboundFramesAfterAuthentication() + #expect(await driver.outboundFrameIfNeeded(forLocalUpdate: liveUpdate) != nil) + try await driver.didReceiveSyncAcknowledgement() + + #expect(await acknowledgements.updates == [initialUpdate]) + } + + @Test func preSubscribedLocalStreamDoesNotDuplicateAnUpdateInTheInitialBatch() async throws { + let persistedUpdates = PersistedSyncUpdateStore() + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: SessionTestDocumentEngine(), + localUpdateCommitter: { update in + await persistedUpdates.append(update) + return true + }, + pendingLocalUpdatesProvider: { + await persistedUpdates.updates + } + ) + let driver = NativeEditorCollaborationSyncDriver(documentName: "page.page-1", coordinator: coordinator) + let stream = await driver.localUpdates() + var iterator = stream.makeAsyncIterator() + + try await coordinator.integrateLocalChange(localChange()) + #expect(try await driver.outboundFramesAfterAuthentication().count == 2) + try await driver.didSendOutboundFramesAfterAuthentication() + let bufferedUpdate = try #require(await iterator.next()) + + #expect(await driver.outboundFrameIfNeeded(forLocalUpdate: bufferedUpdate) == nil) + } + + @Test func cancelledPreAuthenticationSubscriptionStopsBufferingLocalUpdates() async throws { + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: SessionTestDocumentEngine() + ) + let driver = NativeEditorCollaborationSyncDriver(documentName: "page.page-1", coordinator: coordinator) + let subscription = await driver.localUpdateSubscription() + + await subscription.cancel() + try await coordinator.integrateLocalChange(localChange()) + var iterator = subscription.updates.makeAsyncIterator() + + #expect(await iterator.next() == nil) + } + + private func localChange() -> NativeEditorCRDTLocalChange { + let snapshot = NativeEditorHistorySnapshot( + title: "Page", + document: NativeEditorDocument(), + activeBlockID: nil, + selectedBlockID: nil, + visibleBlockControlsID: nil, + isTitleFocused: false + ) + return NativeEditorCRDTLocalChange(before: snapshot, after: snapshot) + } +} + +private actor SyncAcknowledgementRecorder { + private(set) var updates: [Data] = [] + + func append(_ update: Data) { + updates.append(update) + } +} + +private actor PersistedSyncUpdateStore { + private(set) var updates: [Data] = [] + + func append(_ update: Data) { + updates.append(update) + } +} diff --git a/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift b/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift index 7889b3a0..a70622d3 100644 --- a/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift +++ b/docmostlyTests/Editor/NativeEditorCollaborationSyncStatusTests.swift @@ -158,7 +158,6 @@ struct NativeEditorCollaborationSyncStatusTests { 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) @@ -170,12 +169,10 @@ struct NativeEditorCollaborationSyncStatusTests { #expect(viewModel.hasOutgoingChangesRequiringPersistence == false) let offlineQueue = try #require(appState.offlineQueue) #expect(try offlineQueue.pending(scope: scope).map(\.payload) == [ - .updatePage( + .updatePageMetadata( pageId: "page-1", title: "Local title", - document: viewModel.document.proseMirrorDocument, - baseTitle: "Saved title", - baseDocument: savedBaseline + baseTitle: "Saved title" ) ]) } diff --git a/docmostlyTests/Editor/NativeEditorEndpointTests.swift b/docmostlyTests/Editor/NativeEditorEndpointTests.swift index bc95c552..6a570bdc 100644 --- a/docmostlyTests/Editor/NativeEditorEndpointTests.swift +++ b/docmostlyTests/Editor/NativeEditorEndpointTests.swift @@ -47,4 +47,20 @@ struct NativeEditorEndpointTests { #expect(object["operation"] == nil) #expect(object["format"] == nil) } + + @Test func pageEmojiUpdateSendsIconWithoutDocumentContent() throws { + let baseURL = try #require(URL(string: "https://docs.example.com")) + let request = try Endpoint.updatePage(pageId: "page-1", icon: "🎯") + .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["icon"] as? String == "🎯") + #expect(object["title"] == nil) + #expect(object["content"] == nil) + #expect(object["operation"] == nil) + #expect(object["format"] == nil) + } } diff --git a/docmostlyTests/Editor/NativeEditorJSCRDTDocumentEngineRuntimeFixtures.swift b/docmostlyTests/Editor/NativeEditorJSCRDTDocumentEngineRuntimeFixtures.swift index b1d6a3d1..55a70e13 100644 --- a/docmostlyTests/Editor/NativeEditorJSCRDTDocumentEngineRuntimeFixtures.swift +++ b/docmostlyTests/Editor/NativeEditorJSCRDTDocumentEngineRuntimeFixtures.swift @@ -3,7 +3,13 @@ extension NativeEditorJSCRDTEngineTests { let functions = [ "encodeStateVector": "encodeStateVector() { return \"\"; }", "encodeStateAsUpdate": "encodeStateAsUpdate() { return \"\"; }", + "validateUpdate": "validateUpdate() { return true; }", "applyRemoteUpdate": "applyRemoteUpdate() {}", + "currentSnapshot": """ + currentSnapshot() { + return { title: null, updatedAt: null, document: { type: "doc", content: [] } }; + } + """, "integrateLocalChange": "integrateLocalChange() {}", "flushPendingLocalChanges": "flushPendingLocalChanges() { return { title: null, updatedAt: null }; }", "resolveRemoteCursor": "resolveRemoteCursor() { return null; }", @@ -52,6 +58,9 @@ extension NativeEditorJSCRDTEngineTests { } return ""; }, + validateUpdate() { + return true; + }, applyRemoteUpdate(update) { if (update === "CAc=") { this.snapshots.push({ @@ -67,6 +76,13 @@ extension NativeEditorJSCRDTEngineTests { }); } }, + currentSnapshot() { + return { + title: null, + updatedAt: null, + document: seed.document + }; + }, integrateLocalChange(change) { if ( change.before.document.content[0].content[0].text === "Seed" && diff --git a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift index 1917c7f4..5768f4c4 100644 --- a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift +++ b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift @@ -43,24 +43,25 @@ struct NativeEditorJSCRDTRuntimeSourceTests { let bundle = try makeBundle(runtimeSource: Self.runtimeSource) defer { try? FileManager.default.removeItem(at: bundle.bundleURL) } let appState = AppState.production(crdtRuntimeBundle: bundle) + let factory = try #require(appState.crdtDocumentEngineFactory) - let preparedEngine = try #require(try await appState.makeCRDTDocumentEngine( + let engine = try await factory.makeDocumentEngine( pageID: "page-1", title: "Page", document: document(text: "Seed") - )) + ) - #expect(try await preparedEngine.engine.encodeStateVector() == Data([1])) - #expect(preparedEngine.restoredLocalState == false) + #expect(try await engine.encodeStateVector() == Data([1])) } @Test func productionAppStateDefersMissingRuntimeFailureUntilEngineCreation() async throws { let bundle = try makeBundle(runtimeSource: nil) defer { try? FileManager.default.removeItem(at: bundle.bundleURL) } let appState = AppState.production(crdtRuntimeBundle: bundle) + let factory = try #require(appState.crdtDocumentEngineFactory) do { - _ = try await appState.makeCRDTDocumentEngine( + _ = try await factory.makeDocumentEngine( pageID: "page-1", title: "Page", document: document(text: "Seed") @@ -122,10 +123,37 @@ struct NativeEditorJSCRDTRuntimeSourceTests { try await secondEngine.applyRemoteUpdate(update) let snapshot = try #require(await snapshotIterator.next()) - #expect(snapshot.title == "Page") + #expect(snapshot.title == nil) #expect(snapshot.document.blocks.map { String($0.text.characters) } == ["Shared edit"]) } + @Test func coordinatorCommitsEachExplicitRuntimeUpdateOnlyOnce() async throws { + let source = try NativeEditorJSCRDTRuntimeSource.bundled(in: .main) + let engine = try NativeEditorJSCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: document(text: "Seed"), + runtimeSource: source + ) + let counter = CRDTCommitCounter() + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: engine, + localUpdateCommitter: { _ in + await counter.increment() + return true + } + ) + _ = await coordinator.localUpdates() + + try await coordinator.integrateLocalChange(NativeEditorCRDTLocalChange( + before: historySnapshot(title: "Page", text: "Seed"), + after: historySnapshot(title: "Page", text: "Edited once") + )) + await Task.yield() + + #expect(await counter.value == 1) + } + @Test func mainBundleRuntimeRoundTripsAwarenessCursorAfterSync() async throws { let source = try NativeEditorJSCRDTRuntimeSource.bundled(in: .main) let sourceEngine = try NativeEditorJSCRDTDocumentEngine( @@ -220,7 +248,11 @@ struct NativeEditorJSCRDTRuntimeSourceTests { return ""; }, encodeStateAsUpdate() { return ""; }, + validateUpdate() { return true; }, applyRemoteUpdate() {}, + currentSnapshot() { + return { title: null, updatedAt: null, document: seed.document }; + }, integrateLocalChange() {}, flushPendingLocalChanges(title) { return { title, updatedAt: null }; }, resolveRemoteCursor() { return null; }, @@ -280,3 +312,11 @@ struct NativeEditorJSCRDTRuntimeSourceTests { """ } + +private actor CRDTCommitCounter { + private(set) var value = 0 + + func increment() { + value += 1 + } +} diff --git a/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift b/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift index f85ccdcd..1a6dd14d 100644 --- a/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift +++ b/docmostlyTests/Editor/NativeEditorJavaScriptCRDTDocumentEngineTests.swift @@ -181,7 +181,9 @@ struct NativeEditorJSCRDTEngineTests { @Test(arguments: [ "encodeStateVector", "encodeStateAsUpdate", + "validateUpdate", "applyRemoteUpdate", + "currentSnapshot", "integrateLocalChange", "flushPendingLocalChanges", "resolveRemoteCursor", @@ -213,7 +215,11 @@ struct NativeEditorJSCRDTEngineTests { return { encodeStateVector() { return ""; }, encodeStateAsUpdate() { return ""; }, + validateUpdate() { return true; }, applyRemoteUpdate() {}, + currentSnapshot() { + return { title: null, updatedAt: null, document: { type: "doc", content: [] } }; + }, integrateLocalChange() {}, flushPendingLocalChanges() { return { title: null, updatedAt: null }; }, resolveRemoteCursor() { return null; }, @@ -240,7 +246,11 @@ struct NativeEditorJSCRDTEngineTests { return { encodeStateVector() { return ""; }, encodeStateAsUpdate() { return ""; }, + validateUpdate() { return true; }, applyRemoteUpdate() {}, + currentSnapshot() { + return { title: null, updatedAt: null, document: { type: "doc", content: [] } }; + }, integrateLocalChange() {}, flushPendingLocalChanges() { return { title: null, updatedAt: null }; }, resolveRemoteCursor() { return null; }, @@ -267,7 +277,11 @@ struct NativeEditorJSCRDTEngineTests { return { encodeStateVector() { return ""; }, encodeStateAsUpdate() { return ""; }, + validateUpdate() { return true; }, applyRemoteUpdate() {}, + currentSnapshot() { + return { title: null, updatedAt: null, document: { type: "doc", content: [] } }; + }, integrateLocalChange() {}, flushPendingLocalChanges() { return { title: null, updatedAt: null }; }, resolveRemoteCursor() { return null; }, diff --git a/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift b/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift index 9a19b3ac..72ea51ed 100644 --- a/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift +++ b/docmostlyTests/Editor/NativeEditorSaveRaceTests.swift @@ -161,7 +161,6 @@ struct NativeEditorSaveRaceTests { @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() @@ -210,12 +209,10 @@ struct NativeEditorSaveRaceTests { #expect(viewModel.hasOutgoingChangesRequiringPersistence == false) let offlineQueue = try #require(appState.offlineQueue) #expect(try offlineQueue.pending(scope: scope).map(\.payload) == [ - .updatePage( + .updatePageMetadata( pageId: "page-1", title: "Draft", - document: viewModel.document.proseMirrorDocument, - baseTitle: "Draft", - baseDocument: originalBaseline + baseTitle: "Draft" ) ]) } diff --git a/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift index 80eaaa15..316bbf58 100644 --- a/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorBlockMechanicsTests.swift @@ -417,6 +417,29 @@ extension NativeRichEditorBlockMechanicsTests { #expect(viewModel.document.blocks.map(plainText) == ["Plan ", "SET STATUS"]) } + @Test func backspaceInEmptyBlockDeletesItAndFocusesPreviousBlockEnd() throws { + let first = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Gdbdhdhd"), + alignment: .left + ) + let empty = NativeEditorBlock( + kind: .paragraph, + text: AttributedString(""), + alignment: .left + ) + let viewModel = configuredViewModel(blocks: [first, empty]) + + #expect(viewModel.mergeBlockBackward(empty.id)) + + let remainingBlock = try #require(viewModel.document.blocks.first) + #expect(viewModel.document.blocks.count == 1) + #expect(remainingBlock.id == first.id) + #expect(plainText(remainingBlock) == "Gdbdhdhd") + #expect(try insertionOffset(in: remainingBlock) == 8) + #expect(viewModel.activeBlockID == first.id) + } + @Test func mergeRefusesToDiscardAdditionalRawListContent() throws { let original = try JSONDecoder().decode( ProseMirrorDocument.self, diff --git a/docmostlyTests/Engagement/FavoritesViewModelTests.swift b/docmostlyTests/Engagement/FavoritesViewModelTests.swift index 1429c8d3..cc73ad19 100644 --- a/docmostlyTests/Engagement/FavoritesViewModelTests.swift +++ b/docmostlyTests/Engagement/FavoritesViewModelTests.swift @@ -4,6 +4,42 @@ import Testing @MainActor struct FavoritesViewModelTests { + @Test func urlSessionCancellationDoesNotBecomeLoadError() async { + let viewModel = FavoritesViewModel() + + await viewModel.load { + throw URLError(.cancelled) + } + + #expect(viewModel.errorMessage == nil) + #expect(viewModel.isLoading == false) + } + + @Test func replacementLoadWinsWhileEarlierRequestUnwinds() async { + let first = pageFavorite(id: "favorite-first") + let replacement = pageFavorite(id: "favorite-replacement") + let loader = FavoritesLoadStub( + firstResponse: response(items: [first]), + replacementResponse: response(items: [replacement]) + ) + let viewModel = FavoritesViewModel() + + let firstLoad = Task { + await viewModel.load(operation: loader.load) + } + while loader.requestCount == 0 { + await Task.yield() + } + + await viewModel.load(operation: loader.load) + loader.finishFirstLoad() + await firstLoad.value + + #expect(viewModel.favorites.map(\.id) == [replacement.id]) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.isLoading == false) + } + @Test func optimisticRemovalStaysRemovedAfterServerSuccess() async { let favorite = pageFavorite(id: "favorite-page") let viewModel = FavoritesViewModel() @@ -15,6 +51,32 @@ struct FavoritesViewModelTests { #expect(viewModel.errorMessage == nil) } + @Test func replacementLoadDiscardsAnInFlightNextPage() async { + let first = pageFavorite(id: "favorite-first") + let staleNext = pageFavorite(id: "favorite-stale-next") + let replacement = pageFavorite(id: "favorite-replacement") + let nextPageLoader = FavoritesNextPageLoadStub(response: response(items: [staleNext])) + let viewModel = FavoritesViewModel() + viewModel.applyInitialPage(response(items: [first], nextCursor: "cursor-1")) + + let nextPageTask = Task { + await viewModel.loadNextPage(operation: nextPageLoader.load) + } + while nextPageLoader.hasStarted == false { + await Task.yield() + } + + await viewModel.load { + response(items: [replacement]) + } + nextPageLoader.finish() + await nextPageTask.value + + #expect(viewModel.favorites.map(\.id) == [replacement.id]) + #expect(viewModel.isLoadingNextPage == false) + #expect(viewModel.nextPageErrorMessage == nil) + } + @Test func optimisticRemovalRestoresOriginalOrderAfterFailure() async { let first = pageFavorite(id: "favorite-page") let second = spaceFavorite(id: "favorite-space", name: "Product") @@ -45,15 +107,16 @@ struct FavoritesViewModelTests { } private func response( - items: [DocmostFavorite] + items: [DocmostFavorite], + nextCursor: String? = nil ) -> PaginatedResponse { PaginatedResponse( items: items, meta: PaginationMeta( limit: 30, - hasNextPage: false, + hasNextPage: nextCursor != nil, hasPrevPage: false, - nextCursor: nil, + nextCursor: nextCursor, prevCursor: nil ) ) @@ -120,6 +183,60 @@ struct FavoritesViewModelTests { } } +@MainActor +private final class FavoritesNextPageLoadStub { + private let response: PaginatedResponse + private var continuation: CheckedContinuation, Never>? + private(set) var hasStarted = false + + init(response: PaginatedResponse) { + self.response = response + } + + func load(cursor: String) async -> PaginatedResponse { + _ = cursor + hasStarted = true + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func finish() { + continuation?.resume(returning: response) + continuation = nil + } +} + +@MainActor +private final class FavoritesLoadStub { + private let firstResponse: PaginatedResponse + private let replacementResponse: PaginatedResponse + private var firstContinuation: CheckedContinuation, Never>? + private(set) var requestCount = 0 + + init( + firstResponse: PaginatedResponse, + replacementResponse: PaginatedResponse + ) { + self.firstResponse = firstResponse + self.replacementResponse = replacementResponse + } + + func load() async -> PaginatedResponse { + requestCount += 1 + guard requestCount == 1 else { return replacementResponse } + + return await withCheckedContinuation { continuation in + firstContinuation = continuation + } + } + + func finishFirstLoad() { + firstContinuation?.resume(returning: firstResponse) + firstContinuation = nil + } +} + private enum FavoriteTestError: Error { case failed } diff --git a/docmostlyTests/Networking/ManagementEndpointTests.swift b/docmostlyTests/Networking/ManagementEndpointTests.swift index fffd92c1..7523ab77 100644 --- a/docmostlyTests/Networking/ManagementEndpointTests.swift +++ b/docmostlyTests/Networking/ManagementEndpointTests.swift @@ -83,6 +83,9 @@ struct ManagementEndpointTests { @Test func buildsWorkspaceAndGroupManagementRequests() throws { let baseURL = try #require(URL(string: "https://docs.example.com")) + let entitlements = try Endpoint.workspaceEntitlements.urlRequest(baseURL: baseURL) + #expect(entitlements.url?.absoluteString == "https://docs.example.com/api/workspace/entitlements") + let workspace = try Endpoint.updateWorkspace(WorkspaceUpdate( name: "Jumpseat", logo: "✈️" diff --git a/docmostlyTests/Networking/WorkspaceEntitlementsTests.swift b/docmostlyTests/Networking/WorkspaceEntitlementsTests.swift new file mode 100644 index 00000000..4319303a --- /dev/null +++ b/docmostlyTests/Networking/WorkspaceEntitlementsTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import docmostly + +struct WorkspaceEntitlementsTests { + @Test func decodesKnownAndFutureFeatureIdentifiers() throws { + let data = Data(#"{"cloud":false,"tier":"business","features":["ai","templates","future:feature"]}"#.utf8) + + let entitlements = try JSONDecoder().decode(DocmostWorkspaceEntitlements.self, from: data) + + #expect(entitlements.cloud == false) + #expect(entitlements.tier == "business") + #expect(entitlements.contains(.artificialIntelligence)) + #expect(entitlements.contains(.templates)) + #expect(entitlements.contains(.mcp) == false) + #expect(entitlements.features.contains("future:feature")) + } +} diff --git a/docmostlyTests/PageReader/EmojiCatalogTests.swift b/docmostlyTests/PageReader/EmojiCatalogTests.swift new file mode 100644 index 00000000..e49cf099 --- /dev/null +++ b/docmostlyTests/PageReader/EmojiCatalogTests.swift @@ -0,0 +1,49 @@ +import Testing +@testable import docmostly + +struct EmojiCatalogTests { + @Test func parsesGroupedEmojiCatalog() throws { + let source = """ + # group: Smileys & Emotion + 😀\tgrinning face + 🥰\tsmiling face with hearts + # group: Travel & Places + 🚀\trocket + """ + + let sections = EmojiCatalog.parse(source) + + #expect(sections.map(\.name) == ["Smileys & Emotion", "Travel & Places"]) + #expect(sections[0].items.map(\.emoji) == ["😀", "🥰"]) + #expect(sections[1].items.first?.name == "rocket") + } + + @Test @MainActor func filtersEmojiByNameUsingLocalizedSearch() throws { + let sections = [ + EmojiCatalogSection( + name: "Smileys & Emotion", + items: [EmojiCatalogItem(emoji: "😀", name: "grinning face")] + ), + EmojiCatalogSection( + name: "Travel & Places", + items: [EmojiCatalogItem(emoji: "🚀", name: "rocket")] + ) + ] + let viewModel = PageEmojiPickerViewModel(sections: sections) + + viewModel.searchText = "rocket" + + let section = try #require(viewModel.visibleSections.first) + #expect(viewModel.visibleSections.count == 1) + #expect(section.items.map(\.emoji) == ["🚀"]) + } + + @Test func bundledCatalogContainsUnicodeEmojiSet() { + let itemCount = EmojiCatalog.sections.reduce(into: 0) { count, section in + count += section.items.count + } + + #expect(EmojiCatalog.sections.count == 9) + #expect(itemCount == 3_781) + } +} diff --git a/docmostlyTests/PageTree/PageBrowserViewModelTests.swift b/docmostlyTests/PageTree/PageBrowserViewModelTests.swift index 9dca4ecc..5af1e43e 100644 --- a/docmostlyTests/PageTree/PageBrowserViewModelTests.swift +++ b/docmostlyTests/PageTree/PageBrowserViewModelTests.swift @@ -44,6 +44,42 @@ struct PageBrowserViewModelTests { #expect(provider.createdRequests == [PageBrowserProviderSpy.Request(spaceId: "space-1", userId: "user-1")]) } + @Test func loadsRecentlyUpdatedPagesAcrossAllSpaces() async throws { + let provider = PageBrowserProviderSpy() + provider.recentPages = [Self.page(id: "recent-1", title: "Roadmap")] + let viewModel = PageBrowserViewModel() + + await viewModel.load(spaces: [Self.space], provider: provider) + + #expect(viewModel.items.map(\.title) == ["Roadmap"]) + #expect(viewModel.items.map(\.subtitle) == ["Jumpseat"]) + #expect(provider.recentRequests == [PageBrowserProviderSpy.Request(spaceId: nil, userId: nil)]) + } + + @Test func loadsFavoritePagesAcrossAllSpaces() async throws { + let provider = PageBrowserProviderSpy() + provider.favorites = [Self.favorite(id: "favorite-1", title: "Launch Plan")] + let viewModel = PageBrowserViewModel() + viewModel.selectedScope = .favorites + + await viewModel.load(spaces: [Self.space], provider: provider) + + #expect(viewModel.items.map(\.subtitle) == ["Jumpseat"]) + #expect(provider.favoriteRequests == [PageBrowserProviderSpy.Request(spaceId: nil, userId: nil)]) + } + + @Test func loadsCreatedByMePagesAcrossAllSpaces() async throws { + let provider = PageBrowserProviderSpy() + provider.currentPageBrowserUserID = "user-1" + provider.createdPages = [Self.page(id: "created-1", title: "My Notes")] + let viewModel = PageBrowserViewModel() + viewModel.selectedScope = .createdByMe + + await viewModel.load(spaces: [Self.space], provider: provider) + + #expect(provider.createdRequests == [PageBrowserProviderSpy.Request(spaceId: nil, userId: "user-1")]) + } + private static let space = DocmostSpace( id: "space-1", name: "Jumpseat", diff --git a/docmostlyTests/PageTree/PageTreeMovePayloadTests.swift b/docmostlyTests/PageTree/PageTreeMovePayloadTests.swift index 27cd1c7b..6fce2e3f 100644 --- a/docmostlyTests/PageTree/PageTreeMovePayloadTests.swift +++ b/docmostlyTests/PageTree/PageTreeMovePayloadTests.swift @@ -29,6 +29,21 @@ struct PageTreeMovePayloadTests { #expect(payload == PageTreeMovePayload(pageId: "b", parentPageId: "a", position: "a2")) } + @Test func makeChildOfUnloadedParentKeepsTheParentUnloaded() throws { + let unloadedTree = [ + node(id: "a", position: "a0", hasChildren: true), + node(id: "b", position: "a1") + ] + + let payload = try unloadedTree.movePayload(sourceID: "b", operation: .makeChild(targetID: "a")) + let movedTree = try unloadedTree.moving(sourceID: "b", operation: .makeChild(targetID: "a")).tree + + #expect(payload == PageTreeMovePayload(pageId: "b", parentPageId: "a", position: "a0")) + #expect(movedTree.node(id: "b") == nil) + #expect(movedTree.node(id: "a")?.hasChildren == true) + #expect(movedTree.node(id: "a")?.isChildrenLoaded == false) + } + @Test func adjacentMovesUsePostMoveNeighbors() throws { let adjacent = [ node(id: "a", position: "a0"), @@ -64,7 +79,8 @@ struct PageTreeMovePayloadTests { id: String, parentPageId: String? = nil, position: String, - children: [PageTreeNode] = [] + children: [PageTreeNode] = [], + hasChildren: Bool? = nil ) -> PageTreeNode { PageTreeNode( id: id, @@ -74,7 +90,7 @@ struct PageTreeMovePayloadTests { spaceId: "space-1", parentPageId: parentPageId, position: position, - hasChildren: children.isEmpty == false, + hasChildren: hasChildren ?? (children.isEmpty == false), children: children, isChildrenLoaded: children.isEmpty == false ) diff --git a/docmostlyTests/PageTree/PageTreeViewModelRefreshTests.swift b/docmostlyTests/PageTree/PageTreeViewModelRefreshTests.swift new file mode 100644 index 00000000..65213dfe --- /dev/null +++ b/docmostlyTests/PageTree/PageTreeViewModelRefreshTests.swift @@ -0,0 +1,65 @@ +import SwiftData +import Testing +@testable import docmostly + +@MainActor +struct PageTreeViewModelRefreshTests { + @Test func expandedParentDropsStaleDisclosureWhenRefreshedChildrenAreEmpty() async throws { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let context = ModelContext(container) + let appState = AppState() + let scope = CacheScope(serverBaseURL: "https://docs.example.com", userID: "user-1") + appState.configure(modelContext: context, modelContainer: container) + appState.configurePreviewCacheScope(scope) + let repository = try #require(appState.cacheRepository) + try repository.savePageTree( + spaceId: "space-1", + parentPageId: nil, + pages: [page(id: "parent", parentPageId: nil, hasChildren: true)], + scope: scope + ) + try repository.savePageTree( + spaceId: "space-1", + parentPageId: "parent", + pages: [], + scope: scope + ) + let viewModel = PageTreeViewModel() + + await viewModel.loadRoot(spaceId: "space-1", appState: appState) + await viewModel.toggle(node: try #require(viewModel.nodes.first), appState: appState) + #expect(viewModel.nodes.first?.hasChildren == false) + + await viewModel.loadRoot(spaceId: "space-1", appState: appState) + + #expect(viewModel.nodes.first?.hasChildren == false) + #expect(viewModel.nodes.first?.isChildrenLoaded == true) + } + + private func page(id: String, parentPageId: String?, hasChildren: Bool) -> DocmostPage { + DocmostPage( + id: id, + slugId: id, + title: "Parent", + content: nil, + icon: nil, + coverPhoto: nil, + parentPageId: parentPageId, + creatorId: nil, + spaceId: "space-1", + workspaceId: "workspace-1", + isLocked: false, + lastUpdatedById: nil, + createdAt: nil, + updatedAt: nil, + deletedAt: nil, + position: "a0", + hasChildren: hasChildren, + permissions: nil, + creator: nil, + lastUpdatedBy: nil, + contributors: nil, + space: nil + ) + } +} diff --git a/docmostlyTests/PageTree/PageTreeVisibleNodeTests.swift b/docmostlyTests/PageTree/PageTreeVisibleNodeTests.swift index b42f7362..b5692213 100644 --- a/docmostlyTests/PageTree/PageTreeVisibleNodeTests.swift +++ b/docmostlyTests/PageTree/PageTreeVisibleNodeTests.swift @@ -48,8 +48,19 @@ struct PageTreeVisibleNodeTests { #expect(nested.map(\.depth) == [0, 1, 2]) } + @Test func insertingIntoAnUnloadedParentDoesNotHideUnknownServerChildren() { + let nodes = [node(id: "root", hasChildren: true)] + + let updated = nodes.inserting(node(id: "new-child"), parentPageId: "root", index: 0) + + #expect(updated.first?.hasChildren == true) + #expect(updated.first?.isChildrenLoaded == false) + #expect(updated.first?.children.isEmpty == true) + } + private func node( id: String, + hasChildren: Bool? = nil, children: [PageTreeNode] = [] ) -> PageTreeNode { PageTreeNode( @@ -60,7 +71,7 @@ struct PageTreeVisibleNodeTests { spaceId: "space-1", parentPageId: nil, position: nil, - hasChildren: children.isEmpty == false, + hasChildren: hasChildren ?? children.isEmpty == false, children: children, isChildrenLoaded: children.isEmpty == false ) diff --git a/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift b/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift index e1687aa5..3287936a 100644 --- a/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift +++ b/docmostlyTests/Persistence/AppStateOfflineQueueSafetyTests.swift @@ -147,7 +147,6 @@ struct AppStateOfflineQueueSafetyTests { title: "Local", document: localDocument, remoteBaseTitle: "Remote title", - remoteBaseDocument: remoteDocument, replacingThrough: oldRecord.createdAt.addingTimeInterval(1) ) @@ -155,18 +154,48 @@ struct AppStateOfflineQueueSafetyTests { let cached = try appState.cacheRepository?.loadEditablePage(idOrSlugId: "page-1", scope: scope) #expect(result == .superseded) #expect(pending?.map(\.payload) == [ - .updatePage( + .updatePageMetadata( pageId: "page-1", title: "Local", - document: localDocument, - baseTitle: "Remote title", - baseDocument: remoteDocument + baseTitle: "Remote title" ) ]) #expect(cached?.title == "Local") #expect(cached?.content == localDocument) } + @MainActor + @Test func collaborativeBodyDraftQueuesAReplayMarkerWhenTheTitleIsUnchanged() async throws { + let (appState, scope) = makeConfiguredAppState() + let localDocument = document(text: "Unacknowledged body") + try appState.cacheRepository?.saveEditablePage( + DocmostEditablePage( + id: "page-1", + slugId: "page-1", + title: "Same title", + content: document(text: "Remote body"), + icon: nil, + spaceId: "space-1", + updatedAt: .now, + permissions: nil, + lastUpdatedBy: nil + ), + scope: scope + ) + + _ = try await appState.persistDeferredCollaborativeDraft( + pageId: "page-1", + title: "Same title", + documentSnapshot: localDocument, + baseTitle: "Same title" + ) + + let pending = try await appState.offlineQueueRepository?.pending(scope: scope) + #expect(pending?.map(\.payload) == [ + .updatePageMetadata(pageId: "page-1", title: "Same title", baseTitle: "Same title") + ]) + } + @MainActor private func makeConfiguredAppState() -> (AppState, CacheScope) { let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) diff --git a/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift b/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift index 191dd324..dbd895be 100644 --- a/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift +++ b/docmostlyTests/Persistence/AppStateOfflineReplayRecoveryTests.swift @@ -34,6 +34,7 @@ struct AppStateOfflineReplayRecoveryTests { @MainActor @Test func pageConflictDoesNotBlockAnUnrelatedQueuedMutation() async throws { let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 200, data: try collaborationTokenEnvelope()), .init(statusCode: 200, data: try editablePageEnvelope(title: "Remote", body: "Remote change")), .init(statusCode: 200) ]) @@ -60,12 +61,13 @@ struct AppStateOfflineReplayRecoveryTests { #expect(pending.count == 1) #expect(pending.first?.kind == .updatePage) #expect(pending.first?.attemptCount == 1) - #expect(requestedPaths == ["/api/pages/info", "/api/pages/move"]) + #expect(requestedPaths == ["/api/auth/collab-token", "/api/pages/info", "/api/pages/move"]) } @MainActor @Test func rejectedPageContentIsRetainedWithoutBlockingLaterQueuedWork() async throws { let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 200, data: try collaborationTokenEnvelope()), .init(statusCode: 422), .init(statusCode: 200) ]) @@ -92,31 +94,30 @@ struct AppStateOfflineReplayRecoveryTests { #expect(pending.count == 1) #expect(pending.first?.kind == .updatePage) #expect(pending.first?.attemptCount == 1) - #expect(requestedPaths == ["/api/pages/info", "/api/pages/move"]) + #expect(requestedPaths == ["/api/auth/collab-token", "/api/pages/info", "/api/pages/move"]) } @MainActor - @Test func CRDTPageReplayMergesThroughCollaborationWithoutRESTBodyReplacement() async throws { + @Test func legacyCRDTPageReplayMigratesIntoTheDocumentSessionWithoutRESTBodyReplacement() async throws { let queuedState = Data([1, 2, 3]) - let preparedState = Data([4, 5, 6]) - let engine = OfflineReplayCRDTDocumentEngine(preparedState: preparedState) + let engine = OfflineReplayCRDTDocumentEngine(preparedState: Data([4, 5, 6])) let factory = OfflineReplayCRDTDocumentEngineFactory(engine: engine) - let synchronizer = OfflineReplayCRDTSynchronizer() let loader = OfflineReplayHTTPDataLoader(stubs: [ - .init(statusCode: 200, data: try collaborationTokenEnvelope()), - .init(statusCode: 200, data: try editablePageEnvelope(title: "Page", body: "Remote body")) + .init(statusCode: 200, data: try collaborationTokenEnvelope()) ]) let baseURL = try #require(URL(string: "https://docs.example.com")) let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) let context = ModelContext(container) let appState = AppState( crdtDocumentEngineFactory: factory, - offlineCRDTSynchronizer: synchronizer, + offlineCRDTSynchronizer: OfflineReplayCRDTSynchronizer(), apiClient: DocmostAPIClient(baseURL: baseURL, loader: loader) ) + appState.serverURLString = baseURL.absoluteString let scope = CacheScope(serverBaseURL: baseURL, userID: "user-1") appState.configure(modelContext: context, modelContainer: container) appState.configurePreviewCacheScope(scope) + appState.currentUser = try currentUser() try appState.cacheRepository?.saveEditablePage( DocmostEditablePage( id: "page-1", @@ -144,21 +145,81 @@ struct AppStateOfflineReplayRecoveryTests { await replayTask.value let pending = try await appState.offlineQueueRepository?.pending(scope: scope) - let synchronizedStates = await synchronizer.synchronizedStates let requestedPaths = await loader.requestedPaths + let storedState = try await DocumentLocalPersistencePeer(modelContainer: container).load( + DocumentStoreKey( + serverBaseURL: scope.serverBaseURL, + userID: scope.userID, + workspaceID: "workspace-1", + pageID: "page-1" + ) + ) #expect(pending?.isEmpty == true) #expect(engine.appliedUpdates == [queuedState]) - #expect(synchronizedStates == [preparedState]) - #expect(requestedPaths == ["/api/auth/collab-token", "/api/pages/info"]) - #expect(try await appState.cacheReader?.loadCRDTStateUpdate( + #expect(storedState.updates.map(\.payload) == [queuedState]) + #expect(requestedPaths == ["/api/auth/collab-token"]) + } + + @MainActor + @Test func metadataReplayDoesNotRequireCreatingADocumentSession() async throws { + let loader = OfflineReplayHTTPDataLoader(stubs: [ + .init(statusCode: 200, data: try editablePageEnvelope(title: "Remote", body: "Remote body")), + .init(statusCode: 200, data: try editablePageEnvelope(title: "Local", body: "Remote body")) + ]) + let (appState, scope) = try makeConfiguredAppStateWithoutCRDT(loader: loader) + _ = try await appState.queueOfflineMutation(.updatePageMetadata( pageId: "page-1", - scope: scope - ) == preparedState) + title: "Local", + baseTitle: "Remote" + )) + + appState.scheduleOfflineQueueReconciliation() + await appState.offlineReplayTask?.value + + #expect(try await appState.offlineQueueRepository?.pending(scope: scope).isEmpty == true) + #expect(await loader.requestedPaths == ["/api/pages/info", "/api/pages/update"]) + } + + @MainActor + @Test func permanentTitleFailureRemovesThePreflightReconciliationMarker() async throws { + let loader = OfflineReplayHTTPDataLoader(stubs: [.init(statusCode: 403)]) + let (appState, scope) = try makeConfiguredAppStateWithoutCRDT(loader: loader) + + await #expect(throws: APIError.self) { + try await appState.updateCollaborativePageTitle( + pageId: "page-1", + title: "Rejected", + documentSnapshot: self.document(text: "Local body"), + baseTitle: "Remote" + ) + } + + #expect(try await appState.offlineQueueRepository?.pending(scope: scope).isEmpty == true) } @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 engine = OfflineReplayCRDTDocumentEngine(preparedState: Data([4, 5, 6])) + let appState = AppState( + crdtDocumentEngineFactory: OfflineReplayCRDTDocumentEngineFactory(engine: engine), + offlineCRDTSynchronizer: OfflineReplayCRDTSynchronizer(), + apiClient: DocmostAPIClient(baseURL: baseURL, loader: loader) + ) + let scope = CacheScope(serverBaseURL: baseURL, userID: "user-1") + appState.configure(modelContext: ModelContext(container), modelContainer: container) + appState.configurePreviewCacheScope(scope) + appState.currentUser = try currentUser() + appState.serverURLString = baseURL.absoluteString + return (appState, scope) + } + + @MainActor + private func makeConfiguredAppStateWithoutCRDT( + loader: OfflineReplayHTTPDataLoader ) throws -> (AppState, CacheScope) { let baseURL = try #require(URL(string: "https://docs.example.com")) let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) @@ -166,6 +227,8 @@ struct AppStateOfflineReplayRecoveryTests { let scope = CacheScope(serverBaseURL: baseURL, userID: "user-1") appState.configure(modelContext: ModelContext(container), modelContainer: container) appState.configurePreviewCacheScope(scope) + appState.currentUser = try currentUser() + appState.serverURLString = baseURL.absoluteString return (appState, scope) } @@ -190,9 +253,17 @@ struct AppStateOfflineReplayRecoveryTests { ]) } + private func currentUser() throws -> CurrentUserResponse { + let data = try JSONSerialization.data(withJSONObject: [ + "user": ["id": "user-1", "name": "User"], + "workspace": ["id": "workspace-1", "name": "Workspace"] + ]) + return try JSONDecoder().decode(CurrentUserResponse.self, from: data) + } + private func collaborationTokenEnvelope() throws -> Data { try JSONSerialization.data(withJSONObject: [ - "data": ["token": "collaboration-token"], + "data": ["token": "test-token"], "success": true, "status": 200 ]) @@ -208,6 +279,30 @@ struct AppStateOfflineReplayRecoveryTests { } } +private actor OfflineReplayCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing { + func synchronize( + pageID: String, + session: DocumentSession, + url: URL, + token: String, + user: DocmostUser? + ) async throws { + _ = pageID + _ = url + _ = token + _ = user + guard let coordinator = await session.syncCoordinator else { + throw APIError.connectionFailed("Missing test collaboration coordinator.") + } + if try await coordinator.pendingLocalUpdates().isEmpty { + _ = try await coordinator.receive(.stepTwo(Data([9]))) + } + for update in try await coordinator.pendingLocalUpdates() { + try await coordinator.recordLocalUpdateAcknowledged(update) + } + } +} + @MainActor private final class OfflineReplayCRDTDocumentEngineFactory: NativeEditorCRDTDocumentEngineFactory { let engine: OfflineReplayCRDTDocumentEngine @@ -261,20 +356,6 @@ private final class OfflineReplayCRDTDocumentEngine: NativeEditorCRDTDocumentEng } } -private actor OfflineReplayCRDTSynchronizer: NativeEditorOfflineCRDTSynchronizing { - private(set) var synchronizedStates: [Data] = [] - - func synchronize( - pageID: String, - engine: any NativeEditorCRDTDocumentEngine, - url: URL, - token: String, - user: DocmostUser? - ) async throws { - synchronizedStates.append(try await engine.encodeDocumentState()) - } -} - private actor OfflineReplayHTTPDataLoader: HTTPDataLoading { struct Stub: Sendable { let statusCode: Int diff --git a/docmostlyTests/Persistence/CacheRepositoryEditablePageTests.swift b/docmostlyTests/Persistence/CacheRepositoryEditablePageTests.swift index d42712c3..0b3afe5f 100644 --- a/docmostlyTests/Persistence/CacheRepositoryEditablePageTests.swift +++ b/docmostlyTests/Persistence/CacheRepositoryEditablePageTests.swift @@ -97,6 +97,61 @@ struct CacheRepositoryEditablePageTests { #expect(cached.permissions == nil) } + @Test func updatingOnlyThePageIconPreservesTheDocumentAndRefreshesTreeRows() throws { + let (repository, context) = makeRepositoryAndContext() + let document = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Keep this body") + ]) + ]) + try repository.saveEditablePage(editablePage(content: document), scope: scope) + try repository.savePageTree( + spaceId: "space-1", + parentPageId: nil, + pages: [htmlPage(title: "Roadmap")], + scope: scope + ) + + try repository.updatePageIcon(pageID: "page-1", icon: "🚀", updatedAt: nil, scope: scope) + + let loadedPage = try repository.loadEditablePage(idOrSlugId: "page-1", scope: scope) + let editable = try #require(loadedPage) + let treeItem = try #require(context.fetch(FetchDescriptor()).first) + #expect(editable.content == document) + #expect(editable.icon == "🚀") + #expect(treeItem.icon == "🚀") + } + + @Test func upsertingPageMetadataCreatesMissingRowsAndPreservesExistingDocuments() throws { + let repository = makeRepository() + let originalDocument = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Keep this body") + ]) + ]) + let replacementDocument = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Do not cache this body") + ]) + ]) + let metadata = editablePage(content: replacementDocument, icon: "🚀") + + try repository.upsertEditablePageMetadata(metadata, scope: scope) + let metadataOnlyPage = try #require( + try repository.loadEditablePage(idOrSlugId: "page-1", scope: scope) + ) + #expect(metadataOnlyPage.icon == "🚀") + #expect(metadataOnlyPage.content == ProseMirrorDocument()) + #expect(metadataOnlyPage.content != replacementDocument) + + try repository.saveEditablePage(editablePage(content: originalDocument), scope: scope) + try repository.upsertEditablePageMetadata(metadata, scope: scope) + + let loadedPage = try #require(try repository.loadEditablePage(idOrSlugId: "page-1", scope: scope)) + #expect(loadedPage.icon == "🚀") + #expect(loadedPage.content == originalDocument) + } + @Test func savingLocalEditableDraftPreservesRemoteUpdatedAtBaseline() throws { let repository = makeRepository() let remoteUpdatedAt = try Date("2026-06-28T08:00:00Z", strategy: .iso8601) @@ -259,14 +314,15 @@ struct CacheRepositoryEditablePageTests { private func editablePage( content: ProseMirrorDocument?, updatedAt: Date? = nil, - permissions: DocmostPagePermissions? = DocmostPagePermissions(canEdit: true, hasRestriction: false) + permissions: DocmostPagePermissions? = DocmostPagePermissions(canEdit: true, hasRestriction: false), + icon: String? = nil ) -> DocmostEditablePage { DocmostEditablePage( id: "page-1", slugId: "roadmap", title: "Roadmap", content: content, - icon: nil, + icon: icon, spaceId: "space-1", updatedAt: updatedAt, permissions: permissions, diff --git a/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift b/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift new file mode 100644 index 00000000..8adb155a --- /dev/null +++ b/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift @@ -0,0 +1,123 @@ +import Foundation +@testable import docmostly + +@MainActor +final class SessionTestDocumentEngine: NativeEditorCRDTDocumentEngine { + let requiresInitialRemoteSnapshot = true + private(set) var appliedUpdates: [Data] = [] + private var localSequence = 0 + + func encodeStateVector() async throws -> Data { + Data([0]) + } + + func encodeStateAsUpdate(for stateVector: Data) async throws -> Data { + _ = stateVector + return try await encodeDocumentState() + } + + func encodeDocumentState() async throws -> Data { + Data("state-\(localSequence)-\(appliedUpdates.count)".utf8) + } + + func validateUpdate(_ update: Data) async throws { + let value = String(bytes: update, encoding: .utf8) ?? "" + guard update.isEmpty == false, value.hasPrefix("corrupt") == false else { + throw DocumentSessionTestError.corrupt + } + } + + func applyRemoteUpdate(_ update: Data) async throws { + _ = try await applyRemoteUpdateCapturingSnapshot(update) + } + + func applyRemoteUpdateCapturingSnapshot( + _ update: Data + ) async throws -> NativeEditorCRDTDocumentSnapshot? { + try await validateUpdate(update) + if appliedUpdates.contains(update) == false { + appliedUpdates.append(update) + } + return NativeEditorCRDTDocumentSnapshot(title: "Page", document: NativeEditorDocument()) + } + + func currentDocumentSnapshot() async throws -> NativeEditorCRDTDocumentSnapshot? { + NativeEditorCRDTDocumentSnapshot(title: "Page", document: NativeEditorDocument()) + } + + func integrateLocalChangeForCommit(_ change: NativeEditorCRDTLocalChange) async throws -> [Data] { + _ = change + localSequence += 1 + return [Data("local-\(localSequence)".utf8)] + } + + func flushPendingLocalChanges( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTSaveResult { + let committed = try await flushPendingLocalChangesForCommit(title: title, document: document) + return committed.result + } + + func flushPendingLocalChangesForCommit( + title: String, + document: NativeEditorDocument + ) async throws -> NativeEditorCRDTCommittedSave { + _ = document + localSequence += 1 + let update = Data("local-\(localSequence)".utf8) + return NativeEditorCRDTCommittedSave( + result: NativeEditorCRDTSaveResult( + title: title, + documentStateUpdate: try await encodeDocumentState() + ), + updates: [update] + ) + } +} + +@MainActor +final class SessionTestDocumentEngineFactory: NativeEditorCRDTDocumentEngineFactory { + private(set) var engines: [SessionTestDocumentEngine] = [] + + func makeDocumentEngine( + pageID: String, + title: String, + document: NativeEditorDocument + ) async throws -> any NativeEditorCRDTDocumentEngine { + _ = pageID + _ = title + _ = document + let engine = SessionTestDocumentEngine() + engines.append(engine) + return engine + } +} + +actor RecordingDocumentUpdateIndexer: DocumentUpdateIndexer { + private(set) var count = 0 + + func documentUpdateCommitted(_ update: CommittedDocumentUpdate) { + _ = update + count += 1 + } +} + +actor DocumentSessionTestEventLog { + private(set) var entries: [String] = [] + + func append(_ entry: String) { + entries.append(entry) + } +} + +struct FailingDocumentCompactionFaultInjector: DocumentCompactionFaultInjector { + func beforeCompactionCommit() async throws { + throw DocumentSessionTestError.compactionCrash + } +} + +enum DocumentSessionTestError: Error { + case corrupt + case compactionCrash +} diff --git a/docmostlyTests/Persistence/DocumentSessionArchitectureTests.swift b/docmostlyTests/Persistence/DocumentSessionArchitectureTests.swift new file mode 100644 index 00000000..df19f9a0 --- /dev/null +++ b/docmostlyTests/Persistence/DocumentSessionArchitectureTests.swift @@ -0,0 +1,599 @@ +import Foundation +import SwiftData +import Testing +@testable import docmostly + +@MainActor +// swiftlint:disable:next type_body_length +struct DocumentSessionArchitectureTests { + @Test func cleanReopenReplaysDurableUpdatesBeforeRemoteSync() async throws { + let dependencies = makeDependencies() + let update = Data("offline-edit".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + + #expect(session.restoredLocalState) + #expect(dependencies.factory.engines.last?.appliedUpdates == [update]) + #expect(session.initialSnapshot != nil) + } + + @Test func documentStoreIdentityIncludesVersionWorkspaceUserAndPage() async throws { + let dependencies = makeDependencies() + let update = Data("scoped-update".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + + let otherKeys = [ + DocumentStoreKey( + schemaVersion: dependencies.key.schemaVersion + 1, + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID, + workspaceID: dependencies.key.workspaceID, + pageID: dependencies.key.pageID + ), + DocumentStoreKey( + serverBaseURL: dependencies.key.serverBaseURL, + userID: "user-2", + workspaceID: dependencies.key.workspaceID, + pageID: dependencies.key.pageID + ), + DocumentStoreKey( + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID, + workspaceID: "workspace-2", + pageID: dependencies.key.pageID + ), + DocumentStoreKey( + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID, + workspaceID: dependencies.key.workspaceID, + pageID: "page-2" + ) + ] + + for key in otherKeys { + #expect(try await dependencies.peer.load(key).updates.isEmpty) + } + #expect(try await dependencies.peer.load(dependencies.key).updates.map(\.payload) == [update]) + } + + @Test func manyOfflineEditsRemainOrderedAndPending() async throws { + let dependencies = makeDependencies() + let indexer = RecordingDocumentUpdateIndexer() + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory, + indexer: indexer, + compactionPolicy: DocumentCompactionPolicy(updateCount: 20, byteCount: .max) + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let coordinator = try #require(session.syncCoordinator) + + for index in 0..<250 { + try await coordinator.integrateLocalChange(localChange(title: "Edit \(index)")) + } + + let pending = try await dependencies.peer.pendingLocalUpdates(dependencies.key) + #expect(pending.count == 250) + #expect(pending.map(\.sequence) == Array(1...250).map(Int64.init)) + #expect(await indexer.count == 250) + } + + @Test func localUpdatesArePersistedBeforeTheSocketCanObserveThem() async throws { + let engine = SessionTestDocumentEngine() + let events = DocumentSessionTestEventLog() + let coordinator = NativeEditorCRDTSyncCoordinator( + documentEngine: engine, + localUpdateCommitter: { update in + let value = String(bytes: update, encoding: .utf8) ?? "" + await events.append("persist:\(value)") + return true + } + ) + let stream = await coordinator.localUpdates() + let sender = Task { + for await update in stream { + let value = String(bytes: update, encoding: .utf8) ?? "" + await events.append("send:\(value)") + return + } + } + + try await coordinator.integrateLocalChange(localChange(title: "ordered")) + await sender.value + + #expect(await events.entries == ["persist:local-1", "send:local-1"]) + } + + @Test func duplicateAndReorderedRemoteUpdatesCommitAndApplyExactlyOnce() async throws { + let dependencies = makeDependencies() + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let coordinator = try #require(session.syncCoordinator) + let first = Data("remote-first".utf8) + let second = Data("remote-second".utf8) + + _ = try await coordinator.receive(.update(second)) + _ = try await coordinator.receive(.update(first)) + _ = try await coordinator.receive(.update(second)) + + let state = try await dependencies.peer.load(dependencies.key) + #expect(state.updates.map(\.payload) == [second, first]) + #expect(dependencies.factory.engines.last?.appliedUpdates == [second, first]) + } + + @Test func reconnectResendsOnlyUnacknowledgedLocalUpdates() async throws { + let dependencies = makeDependencies() + let update = Data("pending-send".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let driver = NativeEditorCollaborationSyncDriver( + documentName: "page.\(dependencies.key.pageID)", + coordinator: try #require(session.syncCoordinator) + ) + + #expect(try await driver.outboundFramesAfterAuthentication().count == 2) + #expect(try await dependencies.peer.pendingLocalUpdates(dependencies.key).count == 1) + try await driver.didSendOutboundFramesAfterAuthentication() + #expect(try await dependencies.peer.pendingLocalUpdates(dependencies.key).count == 1) + try await driver.didReceiveSyncAcknowledgement() + #expect(try await dependencies.peer.pendingLocalUpdates(dependencies.key).isEmpty) + #expect(try await driver.outboundFramesAfterAuthentication().count == 1) + } + + @Test func twoWindowsResolveToOneAuthoritativeSession() async throws { + let dependencies = makeDependencies() + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + + let firstSession = try await registry.session( + for: dependencies.key, + title: "First window", + document: NativeEditorDocument() + ) + let secondSession = try await registry.session( + for: dependencies.key, + title: "Second window", + document: NativeEditorDocument() + ) + + #expect(firstSession === secondSession) + #expect(dependencies.factory.engines.count == 1) + let snapshots = secondSession.snapshots() + let nextProjection = Task { + for await snapshot in snapshots { + return snapshot + } + return nil + } + let coordinator = try #require(firstSession.syncCoordinator) + try await coordinator.integrateLocalChange(localChange(title: "Edited in first window")) + #expect(await nextProjection.value != nil) + + let lateSnapshots = secondSession.snapshots() + var lateIterator = lateSnapshots.makeAsyncIterator() + #expect(await lateIterator.next() == secondSession.initialSnapshot) + } + + @Test func restartDuringSyncRetainsAnUnacknowledgedUpdate() async throws { + let dependencies = makeDependencies() + let update = Data("crash-window".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + + let restartedPeer = DocumentLocalPersistencePeer(modelContainer: dependencies.container) + #expect(try await restartedPeer.pendingLocalUpdates(dependencies.key).map(\.payload) == [update]) + try await restartedPeer.markPushed(update, key: dependencies.key) + #expect(try await restartedPeer.pendingLocalUpdates(dependencies.key).isEmpty) + } + + @Test func corruptPrimarySnapshotRecoversFromTheLastCommittedRecoverySnapshot() async throws { + let dependencies = makeDependencies() + let update = Data("remote".utf8) + _ = try await dependencies.peer.append(update, origin: .remote, key: dependencies.key) + try await dependencies.peer.compact(dependencies.key, snapshot: Data("valid-state".utf8), through: 1) + try corruptPrimarySnapshot(in: dependencies.container, key: dependencies.key) + + let registry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: dependencies.container), + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + + #expect(session.restoredLocalState) + #expect(dependencies.factory.engines.last?.appliedUpdates == [Data("valid-state".utf8)]) + } + + @Test func legacyDocumentMigrationSeedsExactlyOnceAndLeavesOnlyMetadataQueued() async throws { + let dependencies = makeDependencies() + let scope = CacheScope( + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID + ) + let legacyDocument = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Offline draft") + ]) + ]) + let context = ModelContext(dependencies.container) + context.insert(CachedCRDTDocument( + pageId: dependencies.key.pageID, + stateUpdate: Data("older-cache".utf8), + scope: scope + )) + let queue = OfflineMutationQueue(context: context) + _ = try queue.enqueue( + .updatePage( + pageId: dependencies.key.pageID, + title: "Offline title", + document: legacyDocument, + baseTitle: "Server title" + ), + scope: scope + ) + + let firstRegistry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + _ = try await firstRegistry.session( + for: dependencies.key, + title: "Server title", + document: NativeEditorDocument() + ) + let firstState = try await dependencies.peer.load(dependencies.key) + let pendingRecords = try OfflineMutationQueue( + context: ModelContext(dependencies.container) + ).pending(scope: scope) + #expect(firstState.migrationVersion == DocumentLocalPersistencePeer.migrationVersion) + #expect(firstState.lastCommittedSequence == 1) + #expect(pendingRecords.count == 1) + guard case .updatePageMetadata(let pageID, let title, _) = pendingRecords.first?.payload else { + Issue.record("Expected a metadata-only page mutation after migration") + return + } + #expect(pageID == dependencies.key.pageID) + #expect(title == "Offline title") + + let secondRegistry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: dependencies.container), + engineFactory: dependencies.factory + ) + _ = try await secondRegistry.session( + for: dependencies.key, + title: "Server title", + document: NativeEditorDocument() + ) + let secondState = try await dependencies.peer.load(dependencies.key) + #expect(secondState.lastCommittedSequence == 1) + } + + @Test func legacyFullStateCacheBecomesTheSnapshotWithoutDoubleSeeding() async throws { + let dependencies = makeDependencies() + let scope = CacheScope( + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID + ) + let cachedState = Data("cached-full-state".utf8) + let context = ModelContext(dependencies.container) + context.insert(CachedCRDTDocument( + pageId: dependencies.key.pageID, + stateUpdate: cachedState, + scope: scope + )) + try context.save() + + let firstRegistry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + _ = try await firstRegistry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let migrated = try await dependencies.peer.load(dependencies.key) + #expect(migrated.snapshot == cachedState) + #expect(migrated.updates.isEmpty) + #expect(migrated.migrationVersion == DocumentLocalPersistencePeer.migrationVersion) + + let secondRegistry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: dependencies.container), + engineFactory: dependencies.factory + ) + _ = try await secondRegistry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let reopened = try await dependencies.peer.load(dependencies.key) + #expect(reopened.snapshot == cachedState) + #expect(reopened.lastCommittedSequence == 0) + } + + @Test func retainedConflictDraftPromotesToADurableYjsUpdateOnReopen() async throws { + let dependencies = makeDependencies() + let retainedDocument = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Keep this local draft") + ]) + ]) + _ = try await dependencies.peer.append( + Data("remote-base-update".utf8), + origin: .remote, + key: dependencies.key + ) + try await dependencies.peer.compact( + dependencies.key, + snapshot: Data("remote-base-snapshot".utf8), + through: 1 + ) + try await dependencies.peer.retainDraft( + retainedDocument, + title: "Retained title", + key: dependencies.key + ) + + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + _ = try await registry.session( + for: dependencies.key, + title: "Remote title", + document: NativeEditorDocument() + ) + + let reopened = try await dependencies.peer.load(dependencies.key) + #expect(reopened.retainedDraft == nil) + #expect(reopened.retainedDraftTitle == nil) + #expect(reopened.lastCommittedSequence == 2) + #expect(reopened.updates.map(\.origin) == [.local]) + #expect(reopened.updates.map(\.payload) == [Data("local-1".utf8)]) + } + + @Test func legacyProseMirrorDraftWaitsForARemoteBaseBeforeYjsPromotion() async throws { + let dependencies = makeDependencies() + let scope = CacheScope( + serverBaseURL: dependencies.key.serverBaseURL, + userID: dependencies.key.userID + ) + let legacyDocument = ProseMirrorDocument(content: [ + ProseMirrorNode(type: "paragraph", content: [ + ProseMirrorNode(type: "text", text: "Unsynced legacy draft") + ]) + ]) + let queue = OfflineMutationQueue(context: ModelContext(dependencies.container)) + _ = try queue.enqueue( + .updatePage( + pageId: dependencies.key.pageID, + title: "Legacy title", + document: legacyDocument, + baseTitle: "Remote title" + ), + scope: scope + ) + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Remote title", + document: NativeEditorDocument() + ) + + let beforeRemoteSync = try await dependencies.peer.load(dependencies.key) + #expect(beforeRemoteSync.lastCommittedSequence == 0) + #expect(beforeRemoteSync.retainedDraft == legacyDocument) + #expect(session.initialSnapshot?.document.blocks.first?.text == AttributedString("Unsynced legacy draft")) + let viewModel = NativeRichEditorViewModel(pageID: dependencies.key.pageID, initialTitle: "Remote title") + viewModel.configureDocumentSession(session, restoredLocalState: session.restoredLocalState) + #expect(viewModel.canEdit == false) + + _ = try await #require(session.syncCoordinator).receive(.update(Data("remote-base".utf8))) + + let afterRemoteSync = try await dependencies.peer.load(dependencies.key) + #expect(afterRemoteSync.retainedDraft == nil) + #expect(afterRemoteSync.lastCommittedSequence == 2) + #expect(afterRemoteSync.updates.map(\.origin) == [.remote, .local]) + #expect(try await dependencies.peer.pendingLocalUpdates(dependencies.key).count == 1) + } + + @Test func crashBeforeCompactionCommitPreservesTheReplayableUpdateLog() async throws { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let key = Self.makeKey() + let failingPeer = DocumentLocalPersistencePeer( + modelContainer: container, + compactionFaultInjector: FailingDocumentCompactionFaultInjector() + ) + let update = Data("last-valid-update".utf8) + _ = try await failingPeer.append(update, origin: .remote, key: key) + + await #expect(throws: DocumentSessionTestError.self) { + try await failingPeer.compact(key, snapshot: Data("new-snapshot".utf8), through: 1) + } + + let restartedPeer = DocumentLocalPersistencePeer(modelContainer: container) + let state = try await restartedPeer.load(key) + #expect(state.snapshot == nil) + #expect(state.updates.map(\.payload) == [update]) + } +} + +extension DocumentSessionArchitectureTests { + @Test func receiveOnlyAuthenticationDoesNotUploadOrAcknowledgePendingUpdates() async throws { + let dependencies = makeDependencies() + let update = Data("private-draft".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + let registry = DocumentSessionRegistry( + localPeer: dependencies.peer, + engineFactory: dependencies.factory + ) + let session = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + let driver = NativeEditorCollaborationSyncDriver( + documentName: "page.\(dependencies.key.pageID)", + coordinator: try #require(session.syncCoordinator) + ) + + let frames = try await driver.outboundFramesAfterAuthentication(includePendingLocalUpdates: false) + try await driver.didSendOutboundFramesAfterAuthentication() + + #expect(frames.count == 1) + #expect(try await dependencies.peer.pendingLocalUpdates(dependencies.key).map(\.payload) == [update]) + } + + @Test func laterCompactionKeepsThePreviousSnapshotAndReplayTailForRecovery() async throws { + let dependencies = makeDependencies() + let firstUpdate = Data("first-remote".utf8) + let secondUpdate = Data("second-local".utf8) + _ = try await dependencies.peer.append(firstUpdate, origin: .remote, key: dependencies.key) + try await dependencies.peer.compact( + dependencies.key, + snapshot: Data("snapshot-one".utf8), + through: 1 + ) + _ = try await dependencies.peer.append(secondUpdate, origin: .local, key: dependencies.key) + try await dependencies.peer.compact( + dependencies.key, + snapshot: Data("snapshot-two".utf8), + through: 2 + ) + + let compacted = try await dependencies.peer.load(dependencies.key) + #expect(compacted.recoverySnapshot == Data("snapshot-one".utf8)) + #expect(compacted.recoverySnapshotSequence == 1) + #expect(compacted.updates.map(\.payload) == [secondUpdate]) + + try corruptPrimarySnapshot(in: dependencies.container, key: dependencies.key) + let registry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: dependencies.container), + engineFactory: dependencies.factory + ) + _ = try await registry.session( + for: dependencies.key, + title: "Page", + document: NativeEditorDocument() + ) + + #expect(dependencies.factory.engines.last?.appliedUpdates == [ + Data("snapshot-one".utf8), + secondUpdate + ]) + } + + @Test func acknowledgedCompactedPayloadIsReleasedOnceBothSnapshotsCoverIt() async throws { + let dependencies = makeDependencies() + let update = Data("local".utf8) + _ = try await dependencies.peer.append(update, origin: .local, key: dependencies.key) + try await dependencies.peer.compact(dependencies.key, snapshot: Data("one".utf8), through: 1) + try await dependencies.peer.compact(dependencies.key, snapshot: Data("two".utf8), through: 1) + + try await dependencies.peer.markPushed(update, key: dependencies.key) + + #expect(try await dependencies.peer.load(dependencies.key).updates.isEmpty) + } +} + +@MainActor +private extension DocumentSessionArchitectureTests { + struct Dependencies { + let container: ModelContainer + let peer: DocumentLocalPersistencePeer + let factory: SessionTestDocumentEngineFactory + let key: DocumentStoreKey + } + + func makeDependencies() -> Dependencies { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + return Dependencies( + container: container, + peer: DocumentLocalPersistencePeer(modelContainer: container), + factory: SessionTestDocumentEngineFactory(), + key: Self.makeKey() + ) + } + + static func makeKey() -> DocumentStoreKey { + DocumentStoreKey( + serverBaseURL: "https://docs.example.com", + userID: "user-1", + workspaceID: "workspace-1", + pageID: "page-1" + ) + } + + func localChange(title: String) -> NativeEditorCRDTLocalChange { + let before = NativeEditorHistorySnapshot( + title: "Before", + document: NativeEditorDocument(), + activeBlockID: nil, + selectedBlockID: nil, + visibleBlockControlsID: nil, + isTitleFocused: false + ) + let after = NativeEditorHistorySnapshot( + title: title, + document: NativeEditorDocument(), + activeBlockID: nil, + selectedBlockID: nil, + visibleBlockControlsID: nil, + isTitleFocused: false + ) + return NativeEditorCRDTLocalChange(before: before, after: after) + } + + func corruptPrimarySnapshot(in container: ModelContainer, key: DocumentStoreKey) throws { + let context = ModelContext(container) + let serverBaseURL = key.serverBaseURL + let userID = key.userID + let workspaceID = key.workspaceID + let pageID = key.pageID + var descriptor = FetchDescriptor(predicate: #Predicate { document in + document.serverBaseURL == serverBaseURL && + document.userID == userID && + document.workspaceID == workspaceID && + document.pageID == pageID + }) + descriptor.fetchLimit = 1 + let document = try #require(context.fetch(descriptor).first) + document.snapshot = Data("corrupt-state".utf8) + try context.save() + } +} diff --git a/docmostlyTests/Persistence/DocumentSessionRegistryTests.swift b/docmostlyTests/Persistence/DocumentSessionRegistryTests.swift new file mode 100644 index 00000000..d3348e3b --- /dev/null +++ b/docmostlyTests/Persistence/DocumentSessionRegistryTests.swift @@ -0,0 +1,75 @@ +import SwiftData +import Testing +@testable import docmostly + +@MainActor +struct DocumentSessionRegistryTests { + @Test func cancelledOwnerStillRegistersASuccessfullyCreatedSession() async throws { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let factory = SuspendingDocumentEngineFactory() + let registry = DocumentSessionRegistry( + localPeer: DocumentLocalPersistencePeer(modelContainer: container), + engineFactory: factory + ) + let key = DocumentStoreKey( + serverBaseURL: "https://docs.example.com", + userID: "user-1", + workspaceID: "workspace-1", + pageID: "page-1" + ) + let owner = Task { @MainActor in + try await registry.session(for: key, title: "Page", document: NativeEditorDocument()) + } + await factory.waitUntilStarted() + + owner.cancel() + factory.resume() + await #expect(throws: CancellationError.self) { + try await owner.value + } + let reused = try await registry.session(for: key, title: "Page", document: NativeEditorDocument()) + + #expect(registry.existingSession(for: key) === reused) + #expect(factory.creationCount == 1) + } +} + +@MainActor +private final class SuspendingDocumentEngineFactory: NativeEditorCRDTDocumentEngineFactory { + private(set) var creationCount = 0 + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var creationContinuation: CheckedContinuation? + + func makeDocumentEngine( + pageID: String, + title: String, + document: NativeEditorDocument + ) async throws -> any NativeEditorCRDTDocumentEngine { + _ = pageID + _ = title + _ = document + creationCount += 1 + hasStarted = true + for waiter in startWaiters { + waiter.resume() + } + startWaiters = [] + await withCheckedContinuation { continuation in + creationContinuation = continuation + } + return SessionTestDocumentEngine() + } + + func waitUntilStarted() async { + guard hasStarted == false else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func resume() { + creationContinuation?.resume() + creationContinuation = nil + } +} diff --git a/docmostlyTests/Settings/SettingsDraftPayloadTests.swift b/docmostlyTests/Settings/SettingsDraftPayloadTests.swift index c28471ee..ad9364b7 100644 --- a/docmostlyTests/Settings/SettingsDraftPayloadTests.swift +++ b/docmostlyTests/Settings/SettingsDraftPayloadTests.swift @@ -30,6 +30,63 @@ struct SettingsDraftPayloadTests { #expect(body["restrictApiToAdmins"] == nil) } + @Test func workspaceDraftOmitsChangesForUnavailableLicensedFeatures() throws { + var draft = WorkspaceSettingsDraft(workspace: workspace()) + draft.name = "Docs" + draft.disablePublicSharing = true + draft.restrictApiToAdmins = true + draft.trashRetentionDays = 60 + draft.allowMemberTemplates = false + draft.aiSearch = true + draft.generativeAi = true + draft.mcpEnabled = true + + let update = draft.update(comparedTo: workspace(), availableFeatures: []) + let body = try encodedDictionary(update) + + #expect(body["name"] as? String == "Docs") + #expect(body["disablePublicSharing"] == nil) + #expect(body["restrictApiToAdmins"] == nil) + #expect(body["trashRetentionDays"] == nil) + #expect(body["allowMemberTemplates"] == nil) + #expect(body["aiSearch"] == nil) + #expect(body["generativeAi"] == nil) + #expect(body["mcpEnabled"] == nil) + #expect(update.hasChanges) + } + + @Test func workspaceDraftIncludesChangesForAvailableLicensedFeatures() throws { + var draft = WorkspaceSettingsDraft(workspace: workspace()) + draft.disablePublicSharing = true + draft.restrictApiToAdmins = true + draft.trashRetentionDays = 60 + draft.allowMemberTemplates = false + draft.aiSearch = true + draft.generativeAi = true + draft.mcpEnabled = true + + let update = draft.update( + comparedTo: workspace(), + availableFeatures: [ + .apiKeys, + .artificialIntelligence, + .mcp, + .retention, + .sharingControls, + .templates + ] + ) + let body = try encodedDictionary(update) + + #expect(body["disablePublicSharing"] as? Bool == true) + #expect(body["restrictApiToAdmins"] as? Bool == true) + #expect(body["trashRetentionDays"] as? Int == 60) + #expect(body["allowMemberTemplates"] as? Bool == false) + #expect(body["aiSearch"] as? Bool == true) + #expect(body["generativeAi"] as? Bool == true) + #expect(body["mcpEnabled"] as? Bool == true) + } + @Test func roleLabelsMirrorDocmostWeb() { #expect(SettingsRoleOption.workspaceRoles.map(\.value) == ["owner", "admin", "member"]) #expect(SettingsRoleOption.assignableWorkspaceRoles(isOwner: false).map(\.value) == ["admin", "member"])