From 1d36e2e4b2bf0cdcd3e6eaa28ad7ec0a43090854 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:23:21 +0800 Subject: [PATCH] feat(core): improve mobile perf (#15317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Virtualized mobile navigation with shell navigation and interactive swipe menus; coordinated mobile back handling with interactive phases/state restoration. * Added shared auth request proxy and message-port based token handling across mobile and worker flows. * **Bug Fixes** * Hydrated remote worker error stacks for calls and observable errors. * Improved SQLite FTS/indexer and nbstore optional text handling; refined docs-search ref parsing and notification loading/retry. * **Refactor / UX** * Modal focus-preservation and pointer behavior updates; improved mobile menu controls and back gesture plugins. --- package.json | 2 +- .../infra/src/op/__tests__/client.spec.ts | 36 + .../infra/src/op/__tests__/consumer.spec.ts | 38 + packages/common/infra/src/op/client.ts | 16 +- .../nbstore/src/impls/sqlite/indexer/index.ts | 16 +- .../impls/sqlite/indexer/node-builder.spec.ts | 70 ++ .../src/impls/sqlite/indexer/node-builder.ts | 20 +- .../nbstore/src/impls/sqlite/indexer/utils.ts | 3 + .../common/nbstore/src/sync/indexer/index.ts | 4 +- .../apps/android/App/app/build.gradle | 38 +- .../affine/pro/MobileBackDispatcherTest.kt | 77 ++ .../App/app/src/main/AndroidManifest.xml | 2 +- .../src/main/java/app/affine/pro/AFFiNEApp.kt | 34 +- .../java/app/affine/pro/AuthInitializer.kt | 2 +- .../main/java/app/affine/pro/MainActivity.kt | 17 + .../app/affine/pro/plugin/MobileBackPlugin.kt | 52 + .../app/affine/pro/plugin/NbStorePlugin.kt | 15 +- .../App/app/src/main/res/values/styles.xml | 3 +- .../build.gradle | 20 +- .../cordova.variables.gradle | 2 +- .../src/main/AndroidManifest.xml | 2 +- .../apps/android/App/gradle.properties | 1 + .../android/App/gradle/libs.versions.toml | 26 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../frontend/apps/android/capacitor.config.ts | 4 +- packages/frontend/apps/android/package.json | 14 +- packages/frontend/apps/android/src/app.tsx | 99 +- .../apps/android/src/nbstore.worker.ts | 72 +- .../apps/android/src/plugins/mobile-back.ts | 17 + .../src/plugins/nbstore/definitions.ts | 2 +- .../apps/android/src/plugins/nbstore/index.ts | 3 +- packages/frontend/apps/android/src/proxy.ts | 206 +--- .../frontend/apps/android/src/setup-worker.ts | 1 - .../ios/App/App.xcodeproj/project.pbxproj | 186 ++-- .../ios/App/App/AffineViewController.swift | 5 + .../App/Plugins/NBStore/NBStorePlugin.swift | 2 +- .../NavigationGesturePlugin.swift | 44 +- packages/frontend/apps/ios/App/Podfile | 2 +- packages/frontend/apps/ios/App/Podfile.lock | 28 +- packages/frontend/apps/ios/package.json | 14 +- packages/frontend/apps/ios/src/app.tsx | 88 +- .../frontend/apps/ios/src/bs-menu-config.tsx | 36 +- .../frontend/apps/ios/src/modal-config.tsx | 30 - .../frontend/apps/ios/src/nbstore.worker.ts | 71 +- .../plugins/navigation-gesture/definitions.ts | 9 + .../ios/src/plugins/nbstore/definitions.ts | 2 +- .../apps/ios/src/plugins/nbstore/index.ts | 3 +- packages/frontend/apps/ios/src/proxy.ts | 206 +--- .../frontend/apps/ios/src/setup-worker.ts | 1 - .../apps/ios/src/web-navigation-control.tsx | 13 - .../frontend/apps/mobile-shared/package.json | 5 +- .../apps/mobile-shared/src/auth/auth.spec.ts | 319 ++++++ .../apps/mobile-shared/src/auth/channel.ts | 137 +++ .../mobile-shared/src/auth/endpoint.spec.ts | 16 - .../apps/mobile-shared/src/auth/endpoint.ts | 4 + .../apps/mobile-shared/src/auth/request.ts | 213 ++++ .../frontend/apps/mobile-shared/src/index.ts | 3 + .../src/nbstore/optional.spec.ts | 14 + .../mobile-shared/src/nbstore/optional.ts | 3 + .../component/src/ui/menu/menu.types.ts | 10 +- .../component/src/ui/menu/mobile/item.tsx | 45 +- .../component/src/ui/menu/mobile/root.tsx | 8 +- .../src/ui/menu/mobile/styles.css.ts | 7 +- .../component/src/ui/menu/mobile/sub.tsx | 15 +- .../component/src/ui/modal/context.ts | 2 +- .../frontend/component/src/ui/modal/modal.tsx | 75 +- .../component/src/ui/modal/prompt-modal.tsx | 1 + .../component/src/ui/modal/styles.css.ts | 17 +- .../mobile/notification-center.tsx | 2 +- .../component/src/ui/safe-area/style.css.ts | 4 +- .../core/src/commands/affine-creation.tsx | 26 +- .../components/mobile/config-modal/index.tsx | 1 + .../components/notification/list.style.css.ts | 13 +- .../core/src/components/notification/list.tsx | 106 +- .../core/src/components/tags/tags-editor.tsx | 1 + .../core/src/desktop/pages/index/index.tsx | 26 +- .../mobile/components/app-fallback/index.tsx | 42 +- .../src/mobile/components/app-tabs/create.tsx | 4 +- .../mobile/components/app-tabs/journal.tsx | 2 +- .../core/src/mobile/components/index.ts | 2 + .../mobile-modal-config-provider.tsx | 34 + .../mobile/components/mobile-shell-host.tsx | 62 ++ .../components/navigation-back/index.tsx | 24 +- .../src/mobile/components/navigation/index.ts | 1 + .../layouts/collapsible-section.tsx | 22 +- .../components/navigation/menu-host.tsx | 74 ++ .../navigation/nodes/collection/index.tsx | 38 +- .../nodes/collection/operations.tsx | 110 ++- .../components/navigation/nodes/doc/index.tsx | 158 +-- .../navigation/nodes/doc/operations.tsx | 90 +- .../navigation/nodes/folder/index.tsx | 392 ++++---- .../components/navigation/nodes/tag/index.tsx | 35 +- .../navigation/nodes/tag/operations.tsx | 62 +- .../components/navigation/tree/node.tsx | 88 +- .../navigation/virtual-scroller/index.tsx | 927 ++++++++++++++++++ .../navigation/virtual-scroller/styles.css.ts | 93 ++ .../core/src/mobile/components/page/index.tsx | 2 - .../src/mobile/components/page/styles.css.ts | 8 + .../src/mobile/components/rename/dialog.tsx | 1 + .../mobile/components/search-input/index.tsx | 9 +- .../mobile/components/swipe-menu/index.tsx | 27 +- .../components/swipe-menu/styles.css.ts | 1 + .../workspace-selector/current-card.tsx | 1 + .../components/workspace-selector/index.tsx | 7 +- .../components/workspace-selector/menu.css.ts | 2 +- .../components/workspace-selector/menu.tsx | 4 +- .../core/src/mobile/dialogs/index.tsx | 4 - .../mobile/dialogs/setting/devices/index.tsx | 28 +- .../core/src/mobile/dialogs/setting/index.tsx | 5 +- .../dialogs/setting/others/index.css.ts | 20 +- .../mobile/dialogs/setting/swipe-dialog.tsx | 32 +- .../back-coordinator/back-coordinator.spec.ts | 160 +++ .../back-coordinator/back-coordinator.ts | 269 +++++ .../mobile/modules/back-coordinator/index.ts | 20 + .../modules/back-coordinator/visual-layer.ts | 29 + .../frontend/core/src/mobile/modules/index.ts | 6 +- .../modules/navigation-gesture/index.ts | 13 - .../providers/navigation-gesture.ts | 10 - .../services/navigation-gesture.ts | 57 -- .../modules/navigation-projection/index.ts | 34 + .../navigation-projection.spec.ts | 214 ++++ .../navigation-projection.ts | 143 +++ .../shell-data-projection.spec.ts | 293 ++++++ .../shell-data-projection.ts | 513 ++++++++++ .../mobile/modules/search/service/search.ts | 16 +- .../frontend/core/src/mobile/pages/index.tsx | 6 +- .../pages/workspace/collection/index.tsx | 2 - .../workspace/detail/mobile-detail-page.tsx | 36 +- .../detail/page-header-more-button.tsx | 10 +- .../core/src/mobile/pages/workspace/home.tsx | 34 +- .../src/mobile/pages/workspace/journals.tsx | 47 +- .../src/mobile/pages/workspace/layout.tsx | 29 +- .../src/mobile/pages/workspace/search.tsx | 39 +- .../src/mobile/pages/workspace/tag/index.tsx | 2 - .../pages/workspace/workbench-root.spec.tsx | 77 ++ .../mobile/pages/workspace/workbench-root.tsx | 146 ++- .../core/src/mobile/styles/mobile.css.ts | 2 +- .../core/src/mobile/utils/swipe-helper.ts | 55 +- .../src/mobile/views/home-header/index.tsx | 95 +- .../mobile/views/home-header/styles.css.ts | 34 +- .../src/mobile/views/recent-docs/index.tsx | 2 +- .../core/src/modules/docs-search/index.ts | 5 +- .../docs-search/services/docs-search.spec.ts | 131 +++ .../docs-search/services/docs-search.ts | 184 +++- .../src/modules/notification/services/list.ts | 6 + .../core/src/modules/workspace/impls/meta.ts | 8 + .../utils/__tests__/first-app-data.spec.ts | 231 +++++ .../frontend/core/src/utils/first-app-data.ts | 31 +- .../e2e/local-first-delete-workspace.spec.ts | 9 +- .../e2e/back-button-visibility.spec.ts | 19 +- tests/affine-mobile/e2e/detail.spec.ts | 15 +- .../e2e/explorer-favorite.spec.ts | 17 +- .../affine-mobile/e2e/explorer-folder.spec.ts | 2 +- tests/affine-mobile/e2e/explorer-tag.spec.ts | 25 +- tests/affine-mobile/e2e/home.spec.ts | 112 ++- tests/affine-mobile/e2e/settings.spec.ts | 1 + tests/affine-mobile/e2e/utils.ts | 51 +- tests/kit/src/mobile.ts | 5 +- tests/kit/src/utils/workspace.ts | 7 +- yarn.lock | 158 +-- 160 files changed, 6660 insertions(+), 1890 deletions(-) create mode 100644 packages/common/nbstore/src/impls/sqlite/indexer/node-builder.spec.ts create mode 100644 packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/MobileBackDispatcherTest.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/MobileBackPlugin.kt create mode 100644 packages/frontend/apps/android/src/plugins/mobile-back.ts delete mode 100644 packages/frontend/apps/ios/src/modal-config.tsx delete mode 100644 packages/frontend/apps/ios/src/web-navigation-control.tsx create mode 100644 packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts create mode 100644 packages/frontend/apps/mobile-shared/src/auth/channel.ts delete mode 100644 packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts create mode 100644 packages/frontend/apps/mobile-shared/src/auth/request.ts create mode 100644 packages/frontend/apps/mobile-shared/src/nbstore/optional.spec.ts create mode 100644 packages/frontend/apps/mobile-shared/src/nbstore/optional.ts create mode 100644 packages/frontend/core/src/mobile/components/mobile-modal-config-provider.tsx create mode 100644 packages/frontend/core/src/mobile/components/mobile-shell-host.tsx create mode 100644 packages/frontend/core/src/mobile/components/navigation/menu-host.tsx create mode 100644 packages/frontend/core/src/mobile/components/navigation/virtual-scroller/index.tsx create mode 100644 packages/frontend/core/src/mobile/components/navigation/virtual-scroller/styles.css.ts create mode 100644 packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.spec.ts create mode 100644 packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.ts create mode 100644 packages/frontend/core/src/mobile/modules/back-coordinator/index.ts create mode 100644 packages/frontend/core/src/mobile/modules/back-coordinator/visual-layer.ts delete mode 100644 packages/frontend/core/src/mobile/modules/navigation-gesture/index.ts delete mode 100644 packages/frontend/core/src/mobile/modules/navigation-gesture/providers/navigation-gesture.ts delete mode 100644 packages/frontend/core/src/mobile/modules/navigation-gesture/services/navigation-gesture.ts create mode 100644 packages/frontend/core/src/mobile/modules/navigation-projection/index.ts create mode 100644 packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.spec.ts create mode 100644 packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.ts create mode 100644 packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.spec.ts create mode 100644 packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.ts create mode 100644 packages/frontend/core/src/mobile/pages/workspace/workbench-root.spec.tsx create mode 100644 packages/frontend/core/src/modules/docs-search/services/docs-search.spec.ts create mode 100644 packages/frontend/core/src/utils/__tests__/first-app-data.spec.ts diff --git a/package.json b/package.json index 35ad088813..4cd9ae3436 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, "devDependencies": { "@affine-tools/cli": "workspace:*", - "@capacitor/cli": "^7.6.5", + "@capacitor/cli": "^8.4.2", "@eslint/js": "^9.39.2", "@faker-js/faker": "^10.1.0", "@istanbuljs/schema": "^0.1.3", diff --git a/packages/common/infra/src/op/__tests__/client.spec.ts b/packages/common/infra/src/op/__tests__/client.spec.ts index 69990bc8e7..f48dfaf6e5 100644 --- a/packages/common/infra/src/op/__tests__/client.spec.ts +++ b/packages/common/infra/src/op/__tests__/client.spec.ts @@ -11,6 +11,7 @@ interface TestOps extends OpSchema { bin: [Uint8Array, Uint8Array]; sub: [Uint8Array, number]; init: [{ fastText?: boolean } | undefined, { ok: true }]; + any: [undefined, unknown]; } declare module 'vitest' { @@ -242,6 +243,41 @@ describe('op client', () => { sub.unsubscribe(); }); + it('hydrates worker stacks for calls and observables', async ctx => { + const marker = 'WORKER_STACK_MARKER'; + const remoteError = { + name: 'TypeError', + message: marker, + stacktrace: `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`, + } as unknown as Error; + + const call = ctx.producer.call('any', undefined); + ctx.handlers.return({ + type: 'return', + id: 'any:1', + error: remoteError, + }); + await expect(call).rejects.toMatchObject({ + name: 'TypeError', + message: marker, + stack: expect.stringContaining('worker.ts:42:7'), + }); + + const observableError = new Promise(resolve => { + ctx.producer.ob$('any').subscribe({ error: resolve }); + }); + ctx.handlers.error({ + type: 'error', + id: 'any:2', + error: remoteError, + }); + await expect(observableError).resolves.toMatchObject({ + name: 'TypeError', + message: marker, + stack: expect.stringContaining('worker.ts:42:7'), + }); + }); + it('should unsubscribe subscription op', ctx => { const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({ next: vi.fn(), diff --git a/packages/common/infra/src/op/__tests__/consumer.spec.ts b/packages/common/infra/src/op/__tests__/consumer.spec.ts index 2a424b97a7..3e2818e185 100644 --- a/packages/common/infra/src/op/__tests__/consumer.spec.ts +++ b/packages/common/infra/src/op/__tests__/consumer.spec.ts @@ -101,6 +101,44 @@ describe('op consumer', () => { expect(message).toContain('"code":"E_PANIC"'); }); + it('serializes worker stacks for calls and observables', async ctx => { + for (const kind of ['call', 'observable'] as const) { + ctx.postMessage.mockClear(); + const marker = 'WORKER_STACK_MARKER'; + const error = new TypeError(marker); + error.stack = `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`; + ctx.consumer.register('any', () => { + if (kind === 'observable') { + return new Observable(observer => observer.error(error)); + } + throw error; + }); + + if (kind === 'observable') { + ctx.handlers.subscribe({ + type: 'subscribe', + id: 'any:1', + name: 'any', + payload: undefined, + }); + } else { + ctx.handlers.call({ + type: 'call', + id: 'any:1', + name: 'any', + payload: undefined, + }); + } + await vi.advanceTimersToNextTimerAsync(); + + expect(ctx.postMessage.mock.calls[0][0].error).toMatchObject({ + name: 'TypeError', + message: marker, + stacktrace: expect.stringContaining('worker.ts:42:7'), + }); + } + }); + it('should handle cancel message', async ctx => { ctx.consumer.register('add', ({ a, b }, { signal }) => { const { reject, resolve, promise } = Promise.withResolvers(); diff --git a/packages/common/infra/src/op/client.ts b/packages/common/infra/src/op/client.ts index efc3bb54b8..e017fb8e58 100644 --- a/packages/common/infra/src/op/client.ts +++ b/packages/common/infra/src/op/client.ts @@ -32,6 +32,18 @@ export interface OpClientOptions { timeout?: number; } +function hydrateRemoteError(error: Error) { + const hydrated = Object.assign(new Error(), error); + if ( + 'stacktrace' in error && + typeof error.stacktrace === 'string' && + error.stacktrace.length > 0 + ) { + hydrated.stack = error.stacktrace; + } + return hydrated; +} + export class OpClient extends AutoMessageHandler { private readonly callIds = new Map, number>(); private readonly pendingCalls = new Map(); @@ -61,7 +73,7 @@ export class OpClient extends AutoMessageHandler { } if ('error' in msg) { - pending.reject(Object.assign(new Error(), msg.error)); + pending.reject(hydrateRemoteError(msg.error)); } else { pending.resolve(msg.data); } @@ -86,7 +98,7 @@ export class OpClient extends AutoMessageHandler { return; } - ob.error(Object.assign(new Error(), msg.error)); + ob.error(hydrateRemoteError(msg.error)); }; private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] = diff --git a/packages/common/nbstore/src/impls/sqlite/indexer/index.ts b/packages/common/nbstore/src/impls/sqlite/indexer/index.ts index 3c883aa682..089dcfc331 100644 --- a/packages/common/nbstore/src/impls/sqlite/indexer/index.ts +++ b/packages/common/nbstore/src/impls/sqlite/indexer/index.ts @@ -20,6 +20,8 @@ import { createNode } from './node-builder'; import { queryRaw } from './query'; import { getText, tryParseArrayField } from './utils'; +const SQLITE_INDEXER_VERSION_OFFSET = 1; + export class SqliteIndexerStorage extends IndexerStorageBase { static readonly identifier = 'SqliteIndexerStorage'; override readonly recommendRefreshInterval = 30 * 1000; // 5 seconds @@ -92,7 +94,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase { `${table}:${field as string}`, id ); - if (text) { + if (typeof text === 'string' && text.length > 0) { let values: string[] = [text]; const parsed = tryParseArrayField(text); if (parsed) { @@ -129,6 +131,13 @@ export class SqliteIndexerStorage extends IndexerStorageBase { } } } + } else if (text != null && typeof text !== 'string') { + console.warn('[nbstore] invalid indexed aggregate type', { + table, + field: field as string, + id, + type: typeof text, + }); } } @@ -244,6 +253,9 @@ export class SqliteIndexerStorage extends IndexerStorageBase { } async indexVersion(): Promise { - return this.connection.apis.ftsIndexVersion(); + return ( + (await this.connection.apis.ftsIndexVersion()) + + SQLITE_INDEXER_VERSION_OFFSET + ); } } diff --git a/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.spec.ts b/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.spec.ts new file mode 100644 index 0000000000..1f9dacee1f --- /dev/null +++ b/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { NativeDBConnection } from '../db'; +import { SqliteIndexerStorage } from '.'; +import { createNode } from './node-builder'; +import { getText } from './utils'; + +const query = { type: 'match', field: 'title', match: 'query' } as const; + +const connectionWith = (value: unknown) => + ({ + apis: { + ftsGetDocument: vi.fn().mockResolvedValue(value), + ftsGetMatches: vi.fn().mockResolvedValue([]), + }, + }) as unknown as NativeDBConnection; + +describe('sqlite indexer node fields', () => { + it.each([ + ['string', 'summary', 'summary'], + ['singleton array', ['one'], 'one'], + ['array', ['one', 'two'], ['one', 'two']], + ['serialized array', '["one","two"]', ['one', 'two']], + ['malformed array', '[not-json]', '[not-json]'], + ['null', null, ''], + ['missing', undefined, ''], + ['wrong type', 42, ''], + ])('isolates %s values', async (_, value, expected) => { + const node = await createNode( + connectionWith(Array.isArray(value) ? getText(value) : value), + 'doc', + 'doc-id', + 1, + { fields: ['title'] }, + query + ); + + expect(node.fields.title).toEqual(expected); + }); + + it('does not highlight non-string values', async () => { + const connection = connectionWith({ invalid: true }); + const node = await createNode( + connection, + 'doc', + 'doc-id', + 1, + { highlights: [{ field: 'title', before: '', end: '' }] }, + query + ); + + expect(node.highlights.title).toEqual([]); + expect(connection.apis.ftsGetMatches).not.toHaveBeenCalled(); + }); + + it('isolates wrong aggregate field types', async () => { + const connection = connectionWith(42); + connection.apis.ftsSearch = vi + .fn() + .mockResolvedValue([{ id: 'doc-id', score: 1, terms: [] }]); + const storage = Object.create( + SqliteIndexerStorage.prototype + ) as SqliteIndexerStorage; + Object.defineProperty(storage, 'connection', { value: connection }); + + await expect( + storage.aggregate('doc', query, 'title') + ).resolves.toMatchObject({ buckets: [] }); + }); +}); diff --git a/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.ts b/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.ts index 716b892ab8..f01adb0e4a 100644 --- a/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.ts +++ b/packages/common/nbstore/src/impls/sqlite/indexer/node-builder.ts @@ -20,14 +20,22 @@ export async function createNode( `${table}:${field as string}`, id ); - if (text !== null) { + if (typeof text === 'string') { const parsed = tryParseArrayField(text); if (parsed) { fields[field as string] = parsed; } else { fields[field as string] = text; } + } else if (text == null) { + fields[field as string] = ''; } else { + console.warn('[nbstore] invalid indexed field type', { + table, + field: field as string, + id, + type: typeof text, + }); fields[field as string] = ''; } } @@ -43,7 +51,7 @@ export async function createNode( `${table}:${h.field as string}`, id ); - if (text) { + if (typeof text === 'string' && text.length > 0) { const queryString = Array.from(queryStrings).join(' '); const matches = await connection.apis.ftsGetMatches( `${table}:${h.field as string}`, @@ -67,6 +75,14 @@ export async function createNode( highlights[h.field as string] = []; } } else { + if (text != null && typeof text !== 'string') { + console.warn('[nbstore] invalid indexed highlight type', { + table, + field: h.field as string, + id, + type: typeof text, + }); + } highlights[h.field as string] = []; } } diff --git a/packages/common/nbstore/src/impls/sqlite/indexer/utils.ts b/packages/common/nbstore/src/impls/sqlite/indexer/utils.ts index eb05fc0881..2945f59ba1 100644 --- a/packages/common/nbstore/src/impls/sqlite/indexer/utils.ts +++ b/packages/common/nbstore/src/impls/sqlite/indexer/utils.ts @@ -2,6 +2,9 @@ export function getText( val: string | string[] | undefined ): string | undefined { if (Array.isArray(val)) { + if (val.length === 1) { + return val[0]; + } return JSON.stringify(val); } return val; diff --git a/packages/common/nbstore/src/sync/indexer/index.ts b/packages/common/nbstore/src/sync/indexer/index.ts index a02208b912..3c35baed1d 100644 --- a/packages/common/nbstore/src/sync/indexer/index.ts +++ b/packages/common/nbstore/src/sync/indexer/index.ts @@ -568,9 +568,7 @@ export class IndexerSyncImpl implements IndexerSync { const title = node.fields.title; return [ node.id, - { - title: typeof title === 'string' ? title : title.at(0), - }, + { title: typeof title === 'string' ? title : undefined }, ]; }) ); diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle index 75c0529da8..922573f3c7 100644 --- a/packages/frontend/apps/android/App/app/build.gradle +++ b/packages/frontend/apps/android/App/app/build.gradle @@ -20,35 +20,42 @@ def nativeAbi = nativeTarget == 'x86_64' ? 'x86_64' : 'arm64-v8a' apply from: 'capacitor.build.gradle' android { - namespace "app.affine.pro" - compileSdk rootProject.ext.compileSdkVersion + namespace = "app.affine.pro" + compileSdk = rootProject.ext.compileSdkVersion ndkVersion = new File(sdkDirectory, "ndk").listFiles().sort().last().name defaultConfig { - applicationId "app.affine.pro" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local' - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + applicationId = "app.affine.pro" + minSdkVersion = rootProject.ext.minSdkVersion + targetSdkVersion = rootProject.ext.targetSdkVersion + versionCode = 1 + versionName = System.getenv('VERSION_NAME') ?: 'v1.0.0-local' + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders = [usesCleartextTraffic: "false"] aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 - ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } ndk { abiFilters nativeAbi } } buildFeatures { - compose true - buildConfig true - viewBinding true + compose = true + buildConfig = true + viewBinding = true } buildTypes { release { minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + profile { + initWith release + profileable true + signingConfig signingConfigs.debug + matchingFallbacks = ['release'] } debug { minifyEnabled false @@ -112,6 +119,7 @@ dependencies { implementation libs.androidx.datastore.preferences implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.ui.ktx + implementation libs.androidx.webkit implementation libs.apollo.runtime implementation (libs.jna) { @@ -159,8 +167,8 @@ cargo { kotlin { compilerOptions { - apiVersion = KotlinVersion.KOTLIN_2_1 - languageVersion = KotlinVersion.KOTLIN_2_1 + apiVersion = KotlinVersion.KOTLIN_2_2 + languageVersion = KotlinVersion.KOTLIN_2_2 jvmTarget = JvmTarget.JVM_21 } } diff --git a/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/MobileBackDispatcherTest.kt b/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/MobileBackDispatcherTest.kt new file mode 100644 index 0000000000..6569616fde --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/MobileBackDispatcherTest.kt @@ -0,0 +1,77 @@ +package app.affine.pro + +import androidx.activity.BackEventCompat +import androidx.activity.OnBackPressedCallback +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MobileBackDispatcherTest { + @Test + fun disabledAppCallbackPassesTheSameBackToTheNextCallback() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + var fallbackCalled = false + activity.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + fallbackCalled = true + } + }, + ) + val appCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = Unit + } + activity.onBackPressedDispatcher.addCallback(appCallback) + + appCallback.isEnabled = false + activity.onBackPressedDispatcher.onBackPressed() + + assertTrue(fallbackCalled) + } + } + } + + @Test + fun predictiveLifecycleTargetsOneEnabledCallback() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val phases = mutableListOf() + activity.onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackStarted(backEvent: BackEventCompat) { + phases.add("begin") + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + phases.add("progress") + } + + override fun handleOnBackCancelled() { + phases.add("cancel") + } + + override fun handleOnBackPressed() { + phases.add("commit") + } + }, + ) + val event = BackEventCompat(0f, 0f, 0.5f, BackEventCompat.EDGE_LEFT) + activity.onBackPressedDispatcher.dispatchOnBackStarted(event) + activity.onBackPressedDispatcher.dispatchOnBackProgressed(event) + activity.onBackPressedDispatcher.dispatchOnBackCancelled() + activity.onBackPressedDispatcher.dispatchOnBackStarted(event) + activity.onBackPressedDispatcher.onBackPressed() + + assertEquals( + listOf("begin", "progress", "cancel", "begin", "commit"), + phases, + ) + } + } + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml index 2c6ab32bae..8efecf5766 100644 --- a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml +++ b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ { + override fun onResult(result: WebViewStartUpResult) { + executor.shutdown() + Timber.i( + "WebView startup completed asynchronously in %d ms.", + SystemClock.elapsedRealtime() - startedAt, + ) + } + + override fun onError(error: WebViewStartupException) { + executor.shutdown() + Timber.w(error, "WebView asynchronous startup failed.") + } + }, + ) + } + override fun onTerminate() { _context = null super.onTerminate() @@ -44,4 +76,4 @@ class AFFiNEApp : Application() { fun context() = requireNotNull(_context) } -} \ No newline at end of file +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt index 0e92676df6..70cc546052 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AuthInitializer.kt @@ -16,7 +16,7 @@ object AuthInitializer { fun initialize(bridge: Bridge) { bridge.addWebViewListener(object : WebViewListener() { override fun onPageLoaded(webView: WebView?) { - bridge.removeWebViewListener(this) + webView?.post { bridge.removeWebViewListener(this) } MainScope().launch(Dispatchers.IO) { try { FileTree.get()?.checkAndUploadOldLogs( diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt index 8fa79b2ac2..35fd864a9d 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt @@ -1,13 +1,16 @@ package app.affine.pro import android.content.res.ColorStateList +import android.content.ComponentCallbacks2 import android.os.Bundle import android.view.Gravity import android.view.View import android.webkit.WebSettings +import androidx.activity.enableEdgeToEdge import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateMargins @@ -19,6 +22,7 @@ import app.affine.pro.plugin.AFFiNEThemePlugin import app.affine.pro.plugin.AuthPlugin import app.affine.pro.plugin.HashCashPlugin import app.affine.pro.plugin.NbStorePlugin +import app.affine.pro.plugin.MobileBackPlugin import app.affine.pro.plugin.PreviewPlugin import app.affine.pro.service.GraphQLService import app.affine.pro.service.SSEService @@ -53,6 +57,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi AuthPlugin::class.java, HashCashPlugin::class.java, NbStorePlugin::class.java, + MobileBackPlugin::class.java, PreviewPlugin::class.java, ) ) @@ -83,6 +88,8 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi private var navHeight = 0 override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() super.onCreate(savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets -> navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom) @@ -96,6 +103,16 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi configureEditorWebView() } + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + bridge.webView.evaluateJavascript( + "window.dispatchEvent(new Event('affine:memory-pressure'))", + null, + ) + } + } + private fun configureEditorWebView() { bridge.webView.apply { overScrollMode = View.OVER_SCROLL_NEVER diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/MobileBackPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/MobileBackPlugin.kt new file mode 100644 index 0000000000..08ebc9c706 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/MobileBackPlugin.kt @@ -0,0 +1,52 @@ +package app.affine.pro.plugin + +import androidx.activity.BackEventCompat +import androidx.activity.OnBackPressedCallback +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "MobileBack") +class MobileBackPlugin : Plugin() { + private val callback = object : OnBackPressedCallback(false) { + override fun handleOnBackStarted(backEvent: BackEventCompat) { + emit("begin", backEvent.progress) + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + emit("progress", backEvent.progress) + } + + override fun handleOnBackCancelled() { + emit("cancel") + } + + override fun handleOnBackPressed() { + emit("commit") + } + } + + override fun load() { + activity.onBackPressedDispatcher.addCallback(activity, callback) + } + + @PluginMethod + fun setEnabled(call: PluginCall) { + callback.isEnabled = call.getBoolean("enabled", false) ?: false + call.resolve() + } + + private fun emit(phase: String, progress: Float? = null) { + notifyListeners("back", JSObject().apply { + put("phase", phase) + progress?.let { put("progress", it) } + }) + } + + override fun handleOnDestroy() { + callback.remove() + super.handleOnDestroy() + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt index 8e206d17f4..d5e19c3f2a 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt @@ -7,11 +7,14 @@ import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import kotlinx.coroutines.Dispatchers +import org.json.JSONObject import timber.log.Timber import uniffi.affine_mobile_native.DocRecord import uniffi.affine_mobile_native.SetBlob import uniffi.affine_mobile_native.newDocStoragePool +private const val ANDROID_INDEXER_VERSION_OFFSET = 1u + @CapacitorPlugin(name = "NbStoreDocStorage") class NbStorePlugin : Plugin() { @@ -598,10 +601,10 @@ class NbStorePlugin : Plugin() { JSObject() .put("blockId", block.blockId) .put("flavour", block.flavour) - .put("content", block.content) - .put("blob", block.blob) - .put("refDocId", block.refDocId) - .put("refInfo", block.refInfo) + .put("content", block.content?.let(::JSArray)) + .put("blob", block.blob?.let(::JSArray)) + .put("refDocId", block.refDocId?.let(::JSArray)) + .put("refInfo", block.refInfo?.let(::JSArray)) .put("parentFlavour", block.parentFlavour) .put("parentBlockId", block.parentBlockId) .put("additional", block.additional) @@ -683,7 +686,7 @@ class NbStorePlugin : Plugin() { val indexName = call.getStringEnsure("indexName") val docId = call.getStringEnsure("docId") val text = docStoragePool.ftsGetDocument(id, indexName, docId) - call.resolve(JSObject().put("text", text)) + call.resolve(JSObject().put("text", text ?: JSONObject.NULL)) } catch (e: Exception) { call.reject("Failed to get fts document: ${e.message}", null, e) } @@ -728,7 +731,7 @@ class NbStorePlugin : Plugin() { fun ftsIndexVersion(call: PluginCall) { launch(Dispatchers.IO) { try { - val version = docStoragePool.ftsIndexVersion() + val version = docStoragePool.ftsIndexVersion() + ANDROID_INDEXER_VERSION_OFFSET call.resolve(JSObject().put("indexVersion", version)) } catch (e: Exception) { call.reject("Failed to get fts index version: ${e.message}", null, e) diff --git a/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml b/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml index 5ee77ef07b..3af3d84a38 100644 --- a/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml +++ b/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml @@ -17,6 +17,7 @@ - \ No newline at end of file + diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle index d949bbd35b..b2e25ddd6e 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle @@ -1,6 +1,6 @@ ext { - androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0' - cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1' + cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1' } buildscript { @@ -9,21 +9,23 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'com.android.tools.build:gradle:8.13.0' } } apply plugin: 'com.android.library' android { - namespace "capacitor.cordova.android.plugins" - compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36 + namespace = "capacitor.cordova.android.plugins" + compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36 defaultConfig { - minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23 - targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35 + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36 + versionCode 1 + versionName "1.0" } lintOptions { - abortOnError false + abortOnError = false } compileOptions { sourceCompatibility JavaVersion.VERSION_21 @@ -54,4 +56,4 @@ apply from: "cordova.variables.gradle" for (def func : cdvPluginPostBuildExtras) { func() -} +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/cordova.variables.gradle b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/cordova.variables.gradle index c91fae7f2d..b806d8ad65 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/cordova.variables.gradle +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/cordova.variables.gradle @@ -1,6 +1,6 @@ // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN ext { - cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23 + cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 // Plugin gradle extensions can append to this to have code run at the end. cdvPluginPostBuildExtras = [] cordovaConfig = [:] diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml index 91d30c6275..cb9c8aa354 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/packages/frontend/apps/android/App/gradle.properties b/packages/frontend/apps/android/App/gradle.properties index 5d2cc73c0d..518bd1f8b2 100644 --- a/packages/frontend/apps/android/App/gradle.properties +++ b/packages/frontend/apps/android/App/gradle.properties @@ -20,5 +20,6 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.r8.optimizedResourceShrinking=true ksp.incremental.apt=true ksp.useKSP2=true diff --git a/packages/frontend/apps/android/App/gradle/libs.versions.toml b/packages/frontend/apps/android/App/gradle/libs.versions.toml index 2b2c4279ef..e3eb21c1e8 100644 --- a/packages/frontend/apps/android/App/gradle/libs.versions.toml +++ b/packages/frontend/apps/android/App/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] android-gradle-plugin = "8.13.2" -androidx-activity-compose = "1.10.1" -androidx-appcompat = "1.7.0" -androidx-browser = "1.8.0" +androidx-activity-compose = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-browser = "1.9.0" androidx-compose-bom = "2025.05.00" androidx-coordinatorlayout = "1.3.0" -androidx-core-ktx = "1.16.0" -androidx-core-splashscreen = "1.0.1" +androidx-core-ktx = "1.17.0" +androidx-core-splashscreen = "1.2.0" androidx-datastore-preferences = "1.2.0-alpha02" -androidx-espresso-core = "3.6.1" -androidx-junit = "1.2.1" +androidx-espresso-core = "3.7.0" +androidx-junit = "1.3.0" androidx-lifecycle-compose = "2.9.0" androidx-material3 = "1.3.1" androidx-navigation = "2.9.0" @@ -19,25 +19,26 @@ apollo-kotlin-adapters = "0.0.6" compileSdk = "36" firebase-bom = "33.13.0" firebase-crashlytics = "3.0.3" -google-services = "4.4.2" +google-services = "4.4.4" gradle-versions = "0.52.0" hilt = "2.56.2" hilt-ext = "1.2.0" jna = "5.17.0" junit = "4.13.2" -kotlin = "2.1.20" +kotlin = "2.2.20" kotlinx-coroutines = "1.10.2" kotlinx-datetime = "0.6.2" kotlinx-serialization-json = "1.8.1" -ksp = "2.1.20-2.0.1" +ksp = "2.2.20-2.0.4" # @keep -minSdk = "23" +minSdk = "24" mozilla-rust-android = "0.9.6" okhttp-bom = "5.0.0-alpha.14" richtext = "1.0.0-alpha02" # @keep -targetSdk = "35" +targetSdk = "36" timber = "5.0.1" +webkit = "1.16.0" version-catalog-update = "1.0.0" [libraries] @@ -67,6 +68,7 @@ androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecy androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters" } apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters" } apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" } diff --git a/packages/frontend/apps/android/App/gradle/wrapper/gradle-wrapper.properties b/packages/frontend/apps/android/App/gradle/wrapper/gradle-wrapper.properties index 6514f919fd..7705927e94 100644 --- a/packages/frontend/apps/android/App/gradle/wrapper/gradle-wrapper.properties +++ b/packages/frontend/apps/android/App/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/packages/frontend/apps/android/capacitor.config.ts b/packages/frontend/apps/android/capacitor.config.ts index 50a645d19d..e1275920fa 100644 --- a/packages/frontend/apps/android/capacitor.config.ts +++ b/packages/frontend/apps/android/capacitor.config.ts @@ -28,9 +28,11 @@ const config: CapacitorConfig & AppConfig = { keystoreAliasPassword: process.env.AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD, releaseType: 'AAB', }, - adjustMarginsForEdgeToEdge: 'force', }, plugins: { + SystemBars: { + insetsHandling: 'css', + }, CapacitorHttp: { enabled: false, }, diff --git a/packages/frontend/apps/android/package.json b/packages/frontend/apps/android/package.json index e6da17919c..bea3663b49 100644 --- a/packages/frontend/apps/android/package.json +++ b/packages/frontend/apps/android/package.json @@ -19,12 +19,12 @@ "@affine/nbstore": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", - "@capacitor/android": "^7.0.0", - "@capacitor/app": "^7.0.0", - "@capacitor/core": "^7.0.0", - "@capacitor/keyboard": "^7.0.0", - "@capacitor/status-bar": "^7.0.0", - "@capgo/inappbrowser": "^8.0.0", + "@capacitor/android": "^8.4.2", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.2", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/status-bar": "^8.0.0", + "@capgo/inappbrowser": "^8.6.14", "@toeverything/infra": "workspace:*", "async-call-rpc": "^6.4.2", "idb": "^8.0.0", @@ -34,7 +34,7 @@ "react-router-dom": "^6.30.4" }, "devDependencies": { - "@capacitor/cli": "^7.6.5", + "@capacitor/cli": "^8.4.2", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "typescript": "^5.9.3" diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index 35af06d90a..5ebd8c2fae 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -2,7 +2,9 @@ import { notify } from '@affine/component'; import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { AffineContext } from '@affine/core/components/context'; import { AppFallback } from '@affine/core/mobile/components/app-fallback'; +import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider'; import { configureMobileModules } from '@affine/core/mobile/modules'; +import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator'; import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; @@ -32,6 +34,7 @@ import { WorkspacesService } from '@affine/core/modules/workspace'; import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine'; import { getWorkerUrl } from '@affine/env/worker'; import { I18n } from '@affine/i18n'; +import { serveAuthRequests } from '@affine/mobile-shared/auth/channel'; import { StoreManagerClient } from '@affine/nbstore/worker/client'; import { setTelemetryTransport } from '@affine/track'; import { Container } from '@blocksuite/affine/global/di'; @@ -44,7 +47,13 @@ import { App as CapacitorApp } from '@capacitor/app'; import { Keyboard } from '@capacitor/keyboard'; import { StatusBar, Style } from '@capacitor/status-bar'; import { InAppBrowser } from '@capgo/inappbrowser'; -import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra'; +import { + Framework, + FrameworkRoot, + getCurrentStore, + useLiveData, + useService, +} from '@toeverything/infra'; import { OpClient } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; import { useTheme } from 'next-themes'; @@ -55,9 +64,14 @@ import { AffineTheme } from './plugins/affine-theme'; import { AIButton } from './plugins/ai-button'; import { Auth } from './plugins/auth'; import { HashCash } from './plugins/hashcash'; +import { MobileBack } from './plugins/mobile-back'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { Preview } from './plugins/preview'; -import { clearEndpointSession, getValidAccessToken } from './proxy'; +import { + authRequestProvider, + clearEndpointSession, + getValidAccessToken, +} from './proxy'; const storeManagerClient = createStoreManagerClient(); setTelemetryTransport(storeManagerClient.telemetry); @@ -409,19 +423,67 @@ const ThemeProvider = () => { return null; }; +const AndroidCapacitorApp = CapacitorApp as typeof CapacitorApp & { + toggleBackButtonHandler(options: { enabled: boolean }): Promise; +}; + +const AndroidBackAdapter = () => { + const coordinator = useService(MobileBackCoordinator); + const canHandle = useLiveData(coordinator.canHandle$); + + useEffect(() => { + Promise.all([ + AndroidCapacitorApp.toggleBackButtonHandler({ enabled: !canHandle }), + MobileBack.setEnabled({ enabled: canHandle }), + ]).catch(console.error); + }, [canHandle]); + + useEffect(() => { + let disposed = false; + let remove = () => {}; + MobileBack.addListener('back', event => { + const handled = coordinator.handleInteractivePhase(event.phase); + if (event.phase === 'commit' && !handled) { + coordinator.request('system-back'); + } + }) + .then(handle => { + if (disposed) handle.remove().catch(console.error); + else + remove = () => { + handle.remove().catch(console.error); + }; + }) + .catch(console.error); + return () => { + disposed = true; + remove(); + Promise.all([ + AndroidCapacitorApp.toggleBackButtonHandler({ enabled: true }), + MobileBack.setEnabled({ enabled: false }), + ]).catch(console.error); + }; + }, [coordinator]); + + return null; +}; + export function App() { return ( - - - } - router={router} - future={future} - /> - + + + + + } + router={router} + future={future} + /> + + @@ -460,22 +522,7 @@ function createStoreManagerClient() { const { port1: authTokenChannelServer, port2: authTokenChannelClient } = new MessageChannel(); - authTokenChannelServer.addEventListener('message', event => { - const { id, endpoint } = event.data as { id?: string; endpoint?: string }; - if (!id || !endpoint) return; - getValidAccessToken(endpoint) - .then(token => authTokenChannelServer.postMessage({ id, token })) - .catch(error => - authTokenChannelServer.postMessage({ - id, - error: - typeof error === 'object' && error && 'code' in error - ? error.code - : 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', - }) - ); - }); - authTokenChannelServer.start(); + serveAuthRequests(authTokenChannelServer, authRequestProvider); worker.postMessage( { type: 'auth-access-token-channel', port: authTokenChannelClient }, [authTokenChannelClient] diff --git a/packages/frontend/apps/android/src/nbstore.worker.ts b/packages/frontend/apps/android/src/nbstore.worker.ts index 9031a75aa0..aa693a00d6 100644 --- a/packages/frontend/apps/android/src/nbstore.worker.ts +++ b/packages/frontend/apps/android/src/nbstore.worker.ts @@ -1,5 +1,7 @@ import './setup-worker'; +import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel'; +import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request'; import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel'; import { cloudStorages, @@ -18,54 +20,19 @@ import { import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; -let authTokenPort: MessagePort | undefined; -const pendingTokenRequests = new Map< - string, - { - resolve: (token: string | null) => void; - reject: (error: Error) => void; - } ->(); +const authProvider = new MessagePortAuthProvider(); +installAuthRequestProxy(authProvider); configureSocketAuthMethod((endpoint, cb) => { - getValidAccessToken(endpoint) + authProvider + .getValidAccessToken(endpoint) .then(token => cb(token ? { token, tokenType: 'jwt' } : {})) .catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' })); }); globalThis.addEventListener('message', e => { if (e.data.type === 'auth-access-token-channel') { - authTokenPort = e.ports[0] as MessagePort; - authTokenPort.addEventListener('message', e => { - const { id, token, error } = e.data as { - id?: string; - token?: string | null; - error?: string; - }; - if (!id) return; - const pending = pendingTokenRequests.get(id); - if (error) { - if ( - [ - 'ACCESS_TOKEN_INVALID', - 'AUTH_SESSION_EXPIRED', - 'AUTH_SESSION_REVOKED', - 'REFRESH_TOKEN_INVALID', - 'REFRESH_TOKEN_REUSED', - 'UNSUPPORTED_CLIENT_VERSION', - 'AUTH_SESSION_EMPTY', - ].includes(error) - ) { - pending?.resolve(null); - } else { - pending?.reject(new Error(error)); - } - } else { - pending?.resolve(token ?? null); - } - pendingTokenRequests.delete(id); - }); - authTokenPort.start(); + authProvider.setPort(e.ports[0] as MessagePort); return; } @@ -95,31 +62,6 @@ globalThis.addEventListener('message', e => { } }); -function getValidAccessToken(endpoint: string) { - if (!authTokenPort) { - return Promise.resolve(null); - } - - const id = `${Date.now()}:${Math.random()}`; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingTokenRequests.delete(id); - reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE')); - }, 5000); - pendingTokenRequests.set(id, { - resolve: token => { - clearTimeout(timeout); - resolve(token); - }, - reject: error => { - clearTimeout(timeout); - reject(error); - }, - }); - authTokenPort?.postMessage({ id, endpoint }); - }); -} - const consumer = new OpConsumer( globalThis as MessageCommunicapable ); diff --git a/packages/frontend/apps/android/src/plugins/mobile-back.ts b/packages/frontend/apps/android/src/plugins/mobile-back.ts new file mode 100644 index 0000000000..6799e9b2d8 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/mobile-back.ts @@ -0,0 +1,17 @@ +import type { PluginListenerHandle } from '@capacitor/core'; +import { registerPlugin } from '@capacitor/core'; + +type MobileBackEvent = { + phase: 'begin' | 'progress' | 'cancel' | 'commit'; + progress?: number; +}; + +type MobileBackPlugin = { + setEnabled(options: { enabled: boolean }): Promise; + addListener( + event: 'back', + listener: (event: MobileBackEvent) => void + ): Promise; +}; + +export const MobileBack = registerPlugin('MobileBack'); diff --git a/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts b/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts index 0bd4d68303..b6b755d462 100644 --- a/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts +++ b/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts @@ -180,7 +180,7 @@ export interface NbStorePlugin { id: string; indexName: string; docId: string; - }) => Promise<{ text: string | null }>; + }) => Promise<{ text?: string | null }>; ftsGetMatches: (options: { id: string; indexName: string; diff --git a/packages/frontend/apps/android/src/plugins/nbstore/index.ts b/packages/frontend/apps/android/src/plugins/nbstore/index.ts index e20a10f522..ae985db409 100644 --- a/packages/frontend/apps/android/src/plugins/nbstore/index.ts +++ b/packages/frontend/apps/android/src/plugins/nbstore/index.ts @@ -2,6 +2,7 @@ import { base64ToUint8Array, uint8ArrayToBase64, } from '@affine/core/modules/workspace-engine'; +import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional'; import { decodePayload, MOBILE_BLOB_FILE_PREFIX, @@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = { indexName, docId, }); - return result.text; + return normalizeNativeOptional(result.text); }, ftsGetMatches: async function ( id: string, diff --git a/packages/frontend/apps/android/src/proxy.ts b/packages/frontend/apps/android/src/proxy.ts index b1cb0370b5..0bfa527fac 100644 --- a/packages/frontend/apps/android/src/proxy.ts +++ b/packages/frontend/apps/android/src/proxy.ts @@ -1,196 +1,30 @@ import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint'; +import { + type AuthRequestProvider, + installAuthRequestProxy, +} from '@affine/mobile-shared/auth/request'; import { Auth } from './plugins/auth'; -function authEndpointForUrl(url: string | URL) { - try { - const parsed = new URL(url, globalThis.location.origin); - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - ? parsed.origin - : null; - } catch { - return null; - } -} - -/** - * the below code includes the custom fetch and xmlhttprequest implementation for ios webview. - * should be included in the entry file of the app or webworker. - */ -const rawFetch = globalThis.fetch; -globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - const retry = request.clone(); - - const origin = authEndpointForUrl(request.url); - - const token = origin ? await getValidAccessToken(origin) : null; - if (token) { - request.headers.set('Authorization', `Bearer ${token}`); - } - - const response = await rawFetch(request); - if (response.status !== 401 || !origin) return response; - const body = await response - .clone() - .json() - .catch(() => null); - if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response; - const { token: refreshed } = await Auth.refreshAccessToken({ - endpoint: origin, - }); - retry.headers.set('Authorization', `Bearer ${refreshed}`); - return rawFetch(retry); +export const authRequestProvider: AuthRequestProvider = { + async getValidAccessToken(endpoint) { + const { token } = await Auth.getValidAccessToken({ + endpoint: canonicalAuthEndpoint(endpoint), + }); + return token ?? null; + }, + async refreshAccessToken(endpoint) { + const { token } = await Auth.refreshAccessToken({ + endpoint: canonicalAuthEndpoint(endpoint), + }); + return token; + }, }; -const rawXMLHttpRequest = globalThis.XMLHttpRequest; -const xhrRequestUrls = new WeakMap(); -globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { - private request: - | { - method: string; - url: string | URL; - async: boolean; - username?: string | null; - password?: string | null; - } - | undefined; - private readonly headers = new Map(); - private requestBody?: Document | XMLHttpRequestBodyInit | null; - private replaying = false; - private hasReplayed = false; +installAuthRequestProxy(authRequestProvider); - constructor() { - super(); - const suppressExpiredResponse = (event: Event) => { - if (this.replaying) event.stopImmediatePropagation(); - }; - this.addEventListener('load', suppressExpiredResponse, true); - this.addEventListener('loadend', suppressExpiredResponse, true); - this.addEventListener( - 'readystatechange', - event => { - if ( - this.readyState !== rawXMLHttpRequest.DONE || - this.status !== 401 || - this.replaying || - this.hasReplayed || - !this.request?.async - ) { - return; - } - let code: unknown; - try { - code = - this.responseType === 'json' - ? this.response?.code - : JSON.parse(this.responseText)?.code; - } catch { - return; - } - if (code !== 'ACCESS_TOKEN_EXPIRED') return; - event.stopImmediatePropagation(); - this.replaying = true; - this.hasReplayed = true; - this.replayWithFreshToken().catch(() => {}); - }, - true - ); - } - - override open( - method: string, - url: string | URL, - async: boolean = true, - username?: string | null, - password?: string | null - ): void { - this.request = { method, url, async, username, password }; - this.headers.clear(); - this.requestBody = undefined; - this.replaying = false; - this.hasReplayed = false; - xhrRequestUrls.set(this, url.toString()); - return super.open( - method, - url, - async, - username ?? undefined, - password ?? undefined - ); - } - - override setRequestHeader(name: string, value: string): void { - this.headers.set(name, value); - super.setRequestHeader(name, value); - } - - override send(body?: Document | XMLHttpRequestBodyInit | null): void { - this.requestBody = body; - const requestUrl = xhrRequestUrls.get(this); - const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href); - - (origin ? getValidAccessToken(origin) : Promise.resolve(null)) - .then(token => { - if (token) { - super.setRequestHeader('Authorization', `Bearer ${token}`); - } - return super.send(body); - }) - .catch(() => { - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - }); - } - - private async replayWithFreshToken() { - const request = this.request; - if (!request) return this.failReplay(); - const origin = authEndpointForUrl(request.url); - if (!origin) return this.failReplay(); - try { - const { token } = await Auth.refreshAccessToken({ endpoint: origin }); - const responseType = this.responseType; - const timeout = this.timeout; - const withCredentials = this.withCredentials; - super.open( - request.method, - request.url, - true, - request.username ?? undefined, - request.password ?? undefined - ); - this.replaying = false; - this.headers.forEach((value, name) => { - if (name.toLowerCase() !== 'authorization') { - super.setRequestHeader(name, value); - } - }); - super.setRequestHeader('Authorization', `Bearer ${token}`); - this.responseType = responseType; - this.timeout = timeout; - this.withCredentials = withCredentials; - super.send(this.requestBody); - } catch { - this.failReplay(); - } - } - - private failReplay() { - this.replaying = false; - this.dispatchEvent(new Event('readystatechange')); - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - } -}; - -export async function getValidAccessToken( - endpoint: string -): Promise { - const { token } = await Auth.getValidAccessToken({ - endpoint: canonicalAuthEndpoint(endpoint), - }); - return token ?? null; +export function getValidAccessToken(endpoint: string) { + return authRequestProvider.getValidAccessToken(endpoint); } export async function clearEndpointSession(endpoint: string) { diff --git a/packages/frontend/apps/android/src/setup-worker.ts b/packages/frontend/apps/android/src/setup-worker.ts index baefae9fa5..c89c680277 100644 --- a/packages/frontend/apps/android/src/setup-worker.ts +++ b/packages/frontend/apps/android/src/setup-worker.ts @@ -1,2 +1 @@ import '@affine/core/bootstrap/browser'; -import './proxy'; diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index d6c2603804..fbeef41e00 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -30,11 +30,11 @@ 9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; }; 9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; }; 9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; }; + AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; }; + AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; }; C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; }; C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; }; C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; }; - AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; }; - AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -77,6 +77,9 @@ 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = ""; }; 9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = ""; }; 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = ""; }; + AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = ""; }; + AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = ""; }; + AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libaffine_mobile_native.a; path = "../../../../../target/aarch64-apple-ios-sim/debug/libaffine_mobile_native.a"; sourceTree = ""; }; @@ -85,27 +88,19 @@ C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = ""; }; E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.release.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.release.xcconfig"; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; - AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = ""; }; - AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = ""; }; - AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Plugins; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - AA0000070000000000000000 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 504EC3011FED79650016851F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -120,6 +115,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000070000000000000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -229,23 +231,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - AA0000090000000000000000 /* AFFiNETests */ = { - isa = PBXNativeTarget; - buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */; - buildPhases = ( - AA0000060000000000000000 /* Sources */, - AA0000070000000000000000 /* Frameworks */, - AA0000080000000000000000 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = AFFiNETests; - productName = AFFiNETests; - productReference = AA0000030000000000000000 /* AFFiNETests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; 504EC3031FED79650016851F /* AFFiNE */ = { isa = PBXNativeTarget; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */; @@ -270,6 +255,23 @@ productReference = 504EC3041FED79650016851F /* AFFiNE.app */; productType = "com.apple.product-type.application"; }; + AA0000090000000000000000 /* AFFiNETests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */; + buildPhases = ( + AA0000060000000000000000 /* Sources */, + AA0000070000000000000000 /* Frameworks */, + AA0000080000000000000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AFFiNETests; + productName = AFFiNETests; + productReference = AA0000030000000000000000 /* AFFiNETests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -280,13 +282,13 @@ LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 2600; TargetAttributes = { - AA0000090000000000000000 = { - CreatedOnToolsVersion = 26.0; - }; 504EC3031FED79650016851F = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1600; }; + AA0000090000000000000000 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; @@ -309,13 +311,6 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - AA0000080000000000000000 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 504EC3021FED79650016851F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -330,6 +325,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000080000000000000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -358,13 +360,9 @@ ); inputFileListPaths = ( ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n"; @@ -391,15 +389,6 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - AA0000060000000000000000 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - AA0000040000000000000000 /* AuthDateParser.swift in Sources */, - AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -419,6 +408,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000060000000000000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000040000000000000000 /* AuthDateParser.swift in Sources */, + AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -433,38 +431,6 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - AA00000A0000000000000000 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGNING_ALLOWED = NO; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.5; - PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - AA00000B0000000000000000 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGNING_ALLOWED = NO; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.5; - PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; 504EC3141FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -585,10 +551,10 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; + STRIP_INSTALLED_PRODUCT = YES; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - STRIP_INSTALLED_PRODUCT = YES; VALIDATE_PRODUCT = YES; }; name = Release; @@ -665,18 +631,41 @@ }; name = Release; }; + AA00000A0000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.5; + PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA00000B0000000000000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.5; + PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - AA00000A0000000000000000 /* Debug */, - AA00000B0000000000000000 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -695,6 +684,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA00000A0000000000000000 /* Debug */, + AA00000B0000000000000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index afeb882e90..9a2d30492c 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -55,6 +55,11 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin dismissIntelligentsButton() } + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + webView?.evaluateJavaScript("window.dispatchEvent(new Event('affine:memory-pressure'))") + } + override func capacitorDidLoad() { let plugins: [CAPPlugin] = [ AffineThemePlugin(associatedController: self), diff --git a/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift index 141d80a542..46fe919962 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift @@ -664,7 +664,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { indexName: indexName, docId: docId ) - call.resolve(["text": text as Any]) + call.resolve(["text": text ?? NSNull()]) } catch { call.reject("Failed to get fts document, \(error)", nil, error) } diff --git a/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift index 85690746d8..8e66d561b4 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift @@ -1,5 +1,6 @@ import Capacitor import Foundation +import UIKit @objc(NavigationGesturePlugin) public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin { @@ -10,23 +11,54 @@ public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise), ] + private var edgePan: UIScreenEdgePanGestureRecognizer? + + public override func load() { + guard let webView = bridge?.webView else { return } + let recognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleEdgePan(_:))) + recognizer.edges = .left + recognizer.isEnabled = false + webView.addGestureRecognizer(recognizer) + edgePan = recognizer + } @objc func isEnabled(_ call: CAPPluginCall) { - let enabled = bridge?.webView?.allowsBackForwardNavigationGestures ?? true - call.resolve(["value": enabled]) + DispatchQueue.main.async { + let enabled = self.edgePan?.isEnabled ?? false + call.resolve(["value": enabled]) + } } @objc func enable(_ call: CAPPluginCall) { - DispatchQueue.main.sync { - self.bridge?.webView?.allowsBackForwardNavigationGestures = true + DispatchQueue.main.async { + self.edgePan?.isEnabled = true call.resolve([:]) } } @objc func disable(_ call: CAPPluginCall) { - DispatchQueue.main.sync { - self.bridge?.webView?.allowsBackForwardNavigationGestures = false + DispatchQueue.main.async { + self.edgePan?.isEnabled = false call.resolve([:]) } } + + @objc private func handleEdgePan(_ recognizer: UIScreenEdgePanGestureRecognizer) { + guard let view = recognizer.view else { return } + let progress = min(1, max(0, recognizer.translation(in: view).x / max(1, view.bounds.width))) + switch recognizer.state { + case .began: + notifyListeners("gesture", data: ["phase": "begin", "progress": progress]) + case .changed: + notifyListeners("gesture", data: ["phase": "progress", "progress": progress]) + case .ended: + let velocity = recognizer.velocity(in: view).x + let phase = progress >= 0.35 || velocity >= 500 ? "commit" : "cancel" + notifyListeners("gesture", data: ["phase": phase, "progress": progress]) + case .cancelled, .failed: + notifyListeners("gesture", data: ["phase": "cancel", "progress": progress]) + default: + break + } + } } diff --git a/packages/frontend/apps/ios/App/Podfile b/packages/frontend/apps/ios/App/Podfile index a03128bc19..51d2101722 100644 --- a/packages/frontend/apps/ios/App/Podfile +++ b/packages/frontend/apps/ios/App/Podfile @@ -1,6 +1,6 @@ require_relative '../../../../../node_modules/@capacitor/ios/scripts/pods_helpers' -platform :ios, '13.0' +platform :ios, '15.0' use_frameworks! # workaround to avoid Xcode caching of Pods that requires diff --git a/packages/frontend/apps/ios/App/Podfile.lock b/packages/frontend/apps/ios/App/Podfile.lock index 1fd4deba17..49d8a70341 100644 --- a/packages/frontend/apps/ios/App/Podfile.lock +++ b/packages/frontend/apps/ios/App/Podfile.lock @@ -1,14 +1,14 @@ PODS: - - Capacitor (7.4.5): + - Capacitor (8.4.2): - CapacitorCordova - - CapacitorApp (7.1.1): + - CapacitorApp (8.1.1): - Capacitor - - CapacitorBrowser (7.0.3): + - CapacitorBrowser (8.0.4): - Capacitor - - CapacitorCordova (7.4.5) - - CapacitorHaptics (7.0.3): + - CapacitorCordova (8.4.2) + - CapacitorHaptics (8.0.2): - Capacitor - - CapacitorKeyboard (7.0.4): + - CapacitorKeyboard (8.0.5): - Capacitor - CapacitorPluginAppTrackingTransparency (3.0.0): - Capacitor @@ -45,15 +45,15 @@ EXTERNAL SOURCES: :path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency" SPEC CHECKSUMS: - Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd - CapacitorApp: 63b237168fc869e758481dba283315a85743ee78 - CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3 - CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa - CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc - CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3 + Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3 + CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06 + CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656 + CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4 + CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5 + CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b CapacitorPluginAppTrackingTransparency: f0df8fe65d07d3854de3dad832caf192262bbaa5 CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483 -PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082 +PODFILE CHECKSUM: ea5ce301a5d89089eaf7d25b0c1fd49293dcfc20 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/packages/frontend/apps/ios/package.json b/packages/frontend/apps/ios/package.json index 3333165110..0d96b82941 100644 --- a/packages/frontend/apps/ios/package.json +++ b/packages/frontend/apps/ios/package.json @@ -23,12 +23,12 @@ "@affine/nbstore": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", - "@capacitor/app": "^7.0.0", - "@capacitor/browser": "^7.0.0", - "@capacitor/core": "^7.0.0", - "@capacitor/haptics": "^7.0.0", - "@capacitor/ios": "^7.0.0", - "@capacitor/keyboard": "^7.0.0", + "@capacitor/app": "^8.0.0", + "@capacitor/browser": "^8.0.0", + "@capacitor/core": "^8.4.2", + "@capacitor/haptics": "^8.0.0", + "@capacitor/ios": "^8.4.2", + "@capacitor/keyboard": "^8.0.0", "@toeverything/infra": "workspace:^", "async-call-rpc": "^6.4.2", "capacitor-plugin-app-tracking-transparency": "^3.0.0", @@ -41,7 +41,7 @@ "devDependencies": { "@affine-tools/cli": "workspace:*", "@affine-tools/utils": "workspace:*", - "@capacitor/cli": "^7.6.5", + "@capacitor/cli": "^8.4.2", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "typescript": "^5.9.3" diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index a613ef2709..5a18d65088 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -2,9 +2,10 @@ import { notify } from '@affine/component'; import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { AffineContext } from '@affine/core/components/context'; import { AppFallback } from '@affine/core/mobile/components/app-fallback'; +import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider'; import { configureMobileModules } from '@affine/core/mobile/modules'; +import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator'; import { HapticProvider } from '@affine/core/mobile/modules/haptics'; -import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigation-gesture'; import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; @@ -46,6 +47,7 @@ import { requestApplySubscriptionMutation, } from '@affine/graphql'; import { I18n } from '@affine/i18n'; +import { serveAuthRequests } from '@affine/mobile-shared/auth/channel'; import { StoreManagerClient } from '@affine/nbstore/worker/client'; import { setTelemetryTransport } from '@affine/track'; import { Container } from '@blocksuite/affine/global/di'; @@ -61,7 +63,13 @@ import { Browser } from '@capacitor/browser'; import { Capacitor } from '@capacitor/core'; import { Haptics } from '@capacitor/haptics'; import { Keyboard, KeyboardStyle } from '@capacitor/keyboard'; -import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra'; +import { + Framework, + FrameworkRoot, + getCurrentStore, + useLiveData, + useService, +} from '@toeverything/infra'; import { OpClient } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency'; @@ -70,16 +78,19 @@ import { Suspense, useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { BlocksuiteMenuConfigProvider } from './bs-menu-config'; -import { ModalConfigProvider } from './modal-config'; import { AffineTheme } from './plugins/affine-theme'; import { Auth } from './plugins/auth'; import { Hashcash } from './plugins/hashcash'; import { ImagePicker } from './plugins/image-picker'; +import { NavigationGesture } from './plugins/navigation-gesture'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { PayWall } from './plugins/paywall'; import { Preview } from './plugins/preview'; -import { clearEndpointSession, getValidAccessToken } from './proxy'; -import { enableNavigationGesture$ } from './web-navigation-control'; +import { + authRequestProvider, + clearEndpointSession, + getValidAccessToken, +} from './proxy'; const storeManagerClient = createStoreManagerClient(); setTelemetryTransport(storeManagerClient.telemetry); @@ -165,11 +176,6 @@ framework.impl(VirtualKeyboardProvider, { }; }, }); -framework.impl(NavigationGestureProvider, { - isEnabled: () => enableNavigationGesture$.value, - enable: () => enableNavigationGesture$.next(true), - disable: () => enableNavigationGesture$.next(false), -}); framework.impl(HapticProvider, { impact: options => Haptics.impact(options as any), vibrate: options => Haptics.vibrate(options as any), @@ -578,14 +584,49 @@ const KeyboardThemeProvider = () => { return null; }; +const IOSBackAdapter = () => { + const coordinator = useService(MobileBackCoordinator); + const enabled = useLiveData(coordinator.canInteractivePop$); + + useEffect(() => { + (enabled ? NavigationGesture.enable() : NavigationGesture.disable()).catch( + console.error + ); + }, [enabled]); + + useEffect(() => { + let disposed = false; + let remove = () => {}; + NavigationGesture.addListener('gesture', event => { + coordinator.handleInteractivePhase(event.phase); + }) + .then(handle => { + if (disposed) handle.remove().catch(console.error); + else + remove = () => { + handle.remove().catch(console.error); + }; + }) + .catch(console.error); + return () => { + disposed = true; + remove(); + NavigationGesture.disable().catch(console.error); + }; + }, [coordinator]); + + return null; +}; + export function App() { return ( - - - + + + + } @@ -593,8 +634,8 @@ export function App() { future={future} /> - - + + @@ -626,22 +667,7 @@ function createStoreManagerClient() { const { port1: authTokenChannelServer, port2: authTokenChannelClient } = new MessageChannel(); - authTokenChannelServer.addEventListener('message', event => { - const { id, endpoint } = event.data as { id?: string; endpoint?: string }; - if (!id || !endpoint) return; - getValidAccessToken(endpoint) - .then(token => authTokenChannelServer.postMessage({ id, token })) - .catch(error => - authTokenChannelServer.postMessage({ - id, - error: - typeof error === 'object' && error && 'code' in error - ? error.code - : 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE', - }) - ); - }); - authTokenChannelServer.start(); + serveAuthRequests(authTokenChannelServer, authRequestProvider); worker.postMessage( { type: 'auth-access-token-channel', port: authTokenChannelClient }, [authTokenChannelClient] diff --git a/packages/frontend/apps/ios/src/bs-menu-config.tsx b/packages/frontend/apps/ios/src/bs-menu-config.tsx index 67d57a85ea..63e0ffbc99 100644 --- a/packages/frontend/apps/ios/src/bs-menu-config.tsx +++ b/packages/frontend/apps/ios/src/bs-menu-config.tsx @@ -1,41 +1,21 @@ -import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture'; +import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator'; import { onMenuOpen } from '@blocksuite/affine/components/context-menu'; import { useService } from '@toeverything/infra'; -import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react'; +import { type PropsWithChildren, useEffect } from 'react'; export const BlocksuiteMenuConfigProvider = ({ children, }: PropsWithChildren) => { - const navigationGesture = useService(NavigationGestureService); - const menuCountRef = useRef(0); - const prevEnabledRef = useRef(false); - - const handleMenuState = useCallback(() => { - const currentCount = menuCountRef.current + 1; - menuCountRef.current = currentCount; - - if (currentCount === 1) { - prevEnabledRef.current = navigationGesture.enabled$.value; - if (prevEnabledRef.current) { - navigationGesture.setEnabled(false); - } - } - - return () => { - const currentCount = menuCountRef.current - 1; - menuCountRef.current = currentCount; - - if (currentCount === 0 && prevEnabledRef.current) { - navigationGesture.setEnabled(true); - } - }; - }, [navigationGesture]); + const coordinator = useService(MobileBackCoordinator); useEffect(() => { return onMenuOpen(() => { - return handleMenuState(); + return coordinator.registerVisual({ + interactive: false, + handle: () => false, + }).dispose; }); - }, [handleMenuState]); + }, [coordinator]); return children; }; diff --git a/packages/frontend/apps/ios/src/modal-config.tsx b/packages/frontend/apps/ios/src/modal-config.tsx deleted file mode 100644 index 8c389f7596..0000000000 --- a/packages/frontend/apps/ios/src/modal-config.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ModalConfigContext } from '@affine/component'; -import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture'; -import { globalVars } from '@affine/core/mobile/styles/variables.css'; -import { useService } from '@toeverything/infra'; -import { useCallback, useMemo } from 'react'; - -export const ModalConfigProvider = ({ children }: React.PropsWithChildren) => { - const navigationGesture = useService(NavigationGestureService); - - const onOpen = useCallback(() => { - const prev = navigationGesture.enabled$.value; - if (prev) { - navigationGesture.setEnabled(false); - return () => { - navigationGesture.setEnabled(prev); - }; - } - return; - }, [navigationGesture]); - const modalConfigValue = useMemo( - () => ({ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }), - [onOpen] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/frontend/apps/ios/src/nbstore.worker.ts b/packages/frontend/apps/ios/src/nbstore.worker.ts index 5fb1d5c925..aa693a00d6 100644 --- a/packages/frontend/apps/ios/src/nbstore.worker.ts +++ b/packages/frontend/apps/ios/src/nbstore.worker.ts @@ -1,5 +1,7 @@ import './setup-worker'; +import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel'; +import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request'; import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel'; import { cloudStorages, @@ -18,53 +20,19 @@ import { import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; -let authTokenPort: MessagePort | undefined; -const terminalAuthErrors = new Set([ - 'ACCESS_TOKEN_INVALID', - 'AUTH_SESSION_EXPIRED', - 'AUTH_SESSION_REVOKED', - 'REFRESH_TOKEN_INVALID', - 'REFRESH_TOKEN_REUSED', - 'UNSUPPORTED_CLIENT_VERSION', - 'AUTH_SESSION_EMPTY', -]); -const pendingTokenRequests = new Map< - string, - { - resolve: (token: string | null) => void; - reject: (error: Error) => void; - } ->(); +const authProvider = new MessagePortAuthProvider(); +installAuthRequestProxy(authProvider); configureSocketAuthMethod((endpoint, cb) => { - getValidAccessToken(endpoint) + authProvider + .getValidAccessToken(endpoint) .then(token => cb(token ? { token, tokenType: 'jwt' } : {})) .catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' })); }); globalThis.addEventListener('message', e => { if (e.data.type === 'auth-access-token-channel') { - authTokenPort = e.ports[0] as MessagePort; - authTokenPort.addEventListener('message', e => { - const { id, token, error } = e.data as { - id?: string; - token?: string | null; - error?: string; - }; - if (!id) return; - const pending = pendingTokenRequests.get(id); - if (error) { - if (terminalAuthErrors.has(error)) { - pending?.resolve(null); - } else { - pending?.reject(new Error(error)); - } - } else { - pending?.resolve(token ?? null); - } - pendingTokenRequests.delete(id); - }); - authTokenPort.start(); + authProvider.setPort(e.ports[0] as MessagePort); return; } @@ -94,31 +62,6 @@ globalThis.addEventListener('message', e => { } }); -function getValidAccessToken(endpoint: string) { - if (!authTokenPort) { - return Promise.resolve(null); - } - - const id = `${Date.now()}:${Math.random()}`; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingTokenRequests.delete(id); - reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE')); - }, 5000); - pendingTokenRequests.set(id, { - resolve: token => { - clearTimeout(timeout); - resolve(token); - }, - reject: error => { - clearTimeout(timeout); - reject(error); - }, - }); - authTokenPort?.postMessage({ id, endpoint }); - }); -} - const consumer = new OpConsumer( globalThis as MessageCommunicapable ); diff --git a/packages/frontend/apps/ios/src/plugins/navigation-gesture/definitions.ts b/packages/frontend/apps/ios/src/plugins/navigation-gesture/definitions.ts index c65018514e..58064c35de 100644 --- a/packages/frontend/apps/ios/src/plugins/navigation-gesture/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/navigation-gesture/definitions.ts @@ -1,5 +1,14 @@ +import type { PluginListenerHandle } from '@capacitor/core'; + export interface NavigationGesturePlugin { isEnabled: () => Promise; enable: () => Promise; disable: () => Promise; + addListener( + event: 'gesture', + listener: (event: { + phase: 'begin' | 'progress' | 'commit' | 'cancel'; + progress: number; + }) => void + ): Promise; } diff --git a/packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts b/packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts index 0bd4d68303..b6b755d462 100644 --- a/packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts @@ -180,7 +180,7 @@ export interface NbStorePlugin { id: string; indexName: string; docId: string; - }) => Promise<{ text: string | null }>; + }) => Promise<{ text?: string | null }>; ftsGetMatches: (options: { id: string; indexName: string; diff --git a/packages/frontend/apps/ios/src/plugins/nbstore/index.ts b/packages/frontend/apps/ios/src/plugins/nbstore/index.ts index e20a10f522..ae985db409 100644 --- a/packages/frontend/apps/ios/src/plugins/nbstore/index.ts +++ b/packages/frontend/apps/ios/src/plugins/nbstore/index.ts @@ -2,6 +2,7 @@ import { base64ToUint8Array, uint8ArrayToBase64, } from '@affine/core/modules/workspace-engine'; +import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional'; import { decodePayload, MOBILE_BLOB_FILE_PREFIX, @@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = { indexName, docId, }); - return result.text; + return normalizeNativeOptional(result.text); }, ftsGetMatches: async function ( id: string, diff --git a/packages/frontend/apps/ios/src/proxy.ts b/packages/frontend/apps/ios/src/proxy.ts index b1cb0370b5..0bfa527fac 100644 --- a/packages/frontend/apps/ios/src/proxy.ts +++ b/packages/frontend/apps/ios/src/proxy.ts @@ -1,196 +1,30 @@ import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint'; +import { + type AuthRequestProvider, + installAuthRequestProxy, +} from '@affine/mobile-shared/auth/request'; import { Auth } from './plugins/auth'; -function authEndpointForUrl(url: string | URL) { - try { - const parsed = new URL(url, globalThis.location.origin); - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - ? parsed.origin - : null; - } catch { - return null; - } -} - -/** - * the below code includes the custom fetch and xmlhttprequest implementation for ios webview. - * should be included in the entry file of the app or webworker. - */ -const rawFetch = globalThis.fetch; -globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - const retry = request.clone(); - - const origin = authEndpointForUrl(request.url); - - const token = origin ? await getValidAccessToken(origin) : null; - if (token) { - request.headers.set('Authorization', `Bearer ${token}`); - } - - const response = await rawFetch(request); - if (response.status !== 401 || !origin) return response; - const body = await response - .clone() - .json() - .catch(() => null); - if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response; - const { token: refreshed } = await Auth.refreshAccessToken({ - endpoint: origin, - }); - retry.headers.set('Authorization', `Bearer ${refreshed}`); - return rawFetch(retry); +export const authRequestProvider: AuthRequestProvider = { + async getValidAccessToken(endpoint) { + const { token } = await Auth.getValidAccessToken({ + endpoint: canonicalAuthEndpoint(endpoint), + }); + return token ?? null; + }, + async refreshAccessToken(endpoint) { + const { token } = await Auth.refreshAccessToken({ + endpoint: canonicalAuthEndpoint(endpoint), + }); + return token; + }, }; -const rawXMLHttpRequest = globalThis.XMLHttpRequest; -const xhrRequestUrls = new WeakMap(); -globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { - private request: - | { - method: string; - url: string | URL; - async: boolean; - username?: string | null; - password?: string | null; - } - | undefined; - private readonly headers = new Map(); - private requestBody?: Document | XMLHttpRequestBodyInit | null; - private replaying = false; - private hasReplayed = false; +installAuthRequestProxy(authRequestProvider); - constructor() { - super(); - const suppressExpiredResponse = (event: Event) => { - if (this.replaying) event.stopImmediatePropagation(); - }; - this.addEventListener('load', suppressExpiredResponse, true); - this.addEventListener('loadend', suppressExpiredResponse, true); - this.addEventListener( - 'readystatechange', - event => { - if ( - this.readyState !== rawXMLHttpRequest.DONE || - this.status !== 401 || - this.replaying || - this.hasReplayed || - !this.request?.async - ) { - return; - } - let code: unknown; - try { - code = - this.responseType === 'json' - ? this.response?.code - : JSON.parse(this.responseText)?.code; - } catch { - return; - } - if (code !== 'ACCESS_TOKEN_EXPIRED') return; - event.stopImmediatePropagation(); - this.replaying = true; - this.hasReplayed = true; - this.replayWithFreshToken().catch(() => {}); - }, - true - ); - } - - override open( - method: string, - url: string | URL, - async: boolean = true, - username?: string | null, - password?: string | null - ): void { - this.request = { method, url, async, username, password }; - this.headers.clear(); - this.requestBody = undefined; - this.replaying = false; - this.hasReplayed = false; - xhrRequestUrls.set(this, url.toString()); - return super.open( - method, - url, - async, - username ?? undefined, - password ?? undefined - ); - } - - override setRequestHeader(name: string, value: string): void { - this.headers.set(name, value); - super.setRequestHeader(name, value); - } - - override send(body?: Document | XMLHttpRequestBodyInit | null): void { - this.requestBody = body; - const requestUrl = xhrRequestUrls.get(this); - const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href); - - (origin ? getValidAccessToken(origin) : Promise.resolve(null)) - .then(token => { - if (token) { - super.setRequestHeader('Authorization', `Bearer ${token}`); - } - return super.send(body); - }) - .catch(() => { - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - }); - } - - private async replayWithFreshToken() { - const request = this.request; - if (!request) return this.failReplay(); - const origin = authEndpointForUrl(request.url); - if (!origin) return this.failReplay(); - try { - const { token } = await Auth.refreshAccessToken({ endpoint: origin }); - const responseType = this.responseType; - const timeout = this.timeout; - const withCredentials = this.withCredentials; - super.open( - request.method, - request.url, - true, - request.username ?? undefined, - request.password ?? undefined - ); - this.replaying = false; - this.headers.forEach((value, name) => { - if (name.toLowerCase() !== 'authorization') { - super.setRequestHeader(name, value); - } - }); - super.setRequestHeader('Authorization', `Bearer ${token}`); - this.responseType = responseType; - this.timeout = timeout; - this.withCredentials = withCredentials; - super.send(this.requestBody); - } catch { - this.failReplay(); - } - } - - private failReplay() { - this.replaying = false; - this.dispatchEvent(new Event('readystatechange')); - this.dispatchEvent(new Event('error')); - this.dispatchEvent(new Event('loadend')); - } -}; - -export async function getValidAccessToken( - endpoint: string -): Promise { - const { token } = await Auth.getValidAccessToken({ - endpoint: canonicalAuthEndpoint(endpoint), - }); - return token ?? null; +export function getValidAccessToken(endpoint: string) { + return authRequestProvider.getValidAccessToken(endpoint); } export async function clearEndpointSession(endpoint: string) { diff --git a/packages/frontend/apps/ios/src/setup-worker.ts b/packages/frontend/apps/ios/src/setup-worker.ts index baefae9fa5..c89c680277 100644 --- a/packages/frontend/apps/ios/src/setup-worker.ts +++ b/packages/frontend/apps/ios/src/setup-worker.ts @@ -1,2 +1 @@ import '@affine/core/bootstrap/browser'; -import './proxy'; diff --git a/packages/frontend/apps/ios/src/web-navigation-control.tsx b/packages/frontend/apps/ios/src/web-navigation-control.tsx deleted file mode 100644 index 0a91182394..0000000000 --- a/packages/frontend/apps/ios/src/web-navigation-control.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LiveData } from '@toeverything/infra'; - -export const enableNavigationGesture$ = new LiveData(false); - -const onTouchStart = (e: TouchEvent) => { - if (enableNavigationGesture$.value) return; - - const clientX = e.changedTouches[0].clientX; - if (clientX <= 25) { - e.preventDefault(); - } -}; -document.body.addEventListener('touchstart', onTouchStart, { passive: false }); diff --git a/packages/frontend/apps/mobile-shared/package.json b/packages/frontend/apps/mobile-shared/package.json index 328001066e..a70981b2c0 100644 --- a/packages/frontend/apps/mobile-shared/package.json +++ b/packages/frontend/apps/mobile-shared/package.json @@ -6,12 +6,15 @@ "sideEffects": false, "exports": { ".": "./src/index.ts", + "./auth/channel": "./src/auth/channel.ts", "./auth/endpoint": "./src/auth/endpoint.ts", + "./auth/request": "./src/auth/request.ts", + "./nbstore/optional": "./src/nbstore/optional.ts", "./nbstore/payload": "./src/nbstore/payload.ts" }, "dependencies": { "@affine/core": "workspace:*", - "@capacitor/core": "^7.0.0" + "@capacitor/core": "^8.4.2" }, "devDependencies": { "typescript": "^5.9.3", diff --git a/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts b/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts new file mode 100644 index 0000000000..7456bca365 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { MessagePortAuthProvider, serveAuthRequests } from './channel'; +import { canonicalAuthEndpoint, shouldRefreshAccessToken } from './endpoint'; +import { createAuthFetch, installAuthRequestProxy } from './request'; + +function stubXMLHttpRequest(completeOnSend = false) { + const send = vi.fn(); + const abort = vi.fn(); + const instances = new Set(); + + class FakeXMLHttpRequest extends EventTarget { + static readonly DONE = 4; + readyState = 0; + status = 0; + responseType: XMLHttpRequestResponseType = ''; + response: unknown; + responseText = ''; + timeout = 0; + withCredentials = false; + + constructor() { + super(); + instances.add(this); + } + + open() { + this.readyState = 1; + } + + setRequestHeader() {} + + send(body?: Document | XMLHttpRequestBodyInit | null) { + send(body); + if (completeOnSend) { + this.readyState = FakeXMLHttpRequest.DONE; + this.dispatchEvent(new Event('loadend')); + } + } + + abort() { + abort(); + this.dispatchEvent(new Event('abort')); + this.dispatchEvent(new Event('loadend')); + } + } + + vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest); + return { + send, + abort, + respond(status: number, responseText: string) { + const current = [...instances].at(-1); + if (!current) throw new Error('No XMLHttpRequest instance'); + current.status = status; + current.responseText = responseText; + current.readyState = FakeXMLHttpRequest.DONE; + current.dispatchEvent(new Event('readystatechange')); + current.dispatchEvent(new Event('load')); + current.dispatchEvent(new Event('loadend')); + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('canonicalAuthEndpoint', () => { + test.each([ + ['https://AFFINE.PRO/path?query=1', 'https://affine.pro'], + ['https://affine.pro:443', 'https://affine.pro'], + ['http://localhost:80/path', 'http://localhost'], + ['http://localhost:8080/path', 'http://localhost:8080'], + ['capacitor://localhost/path', 'capacitor://localhost/path'], + ['invalid endpoint', 'invalid endpoint'], + ])('normalizes %s', (endpoint, expected) => { + expect(canonicalAuthEndpoint(endpoint)).toBe(expected); + }); +}); + +describe('shouldRefreshAccessToken', () => { + test.each(['ACCESS_TOKEN_EXPIRED', 'ACCESS_TOKEN_INVALID'])( + 'refreshes for %s', + code => { + expect(shouldRefreshAccessToken(code)).toBe(true); + } + ); + + test.each(['INVALID_REFRESH_TOKEN', undefined, null])( + 'does not refresh for %s', + code => { + expect(shouldRefreshAccessToken(code)).toBe(false); + } + ); +}); + +describe('auth request fetch', () => { + test('injects the endpoint token', async () => { + const provider = { + getValidAccessToken: vi.fn(async () => 'access-token'), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + const rawFetch = vi.fn( + async () => new Response(null, { status: 200 }) + ); + const fetch = createAuthFetch(provider, rawFetch); + + await fetch('https://example.com/api/workspaces/1/blobs/1'); + + expect(provider.getValidAccessToken).toHaveBeenCalledWith( + 'https://example.com' + ); + expect( + (rawFetch.mock.calls[0][0] as Request).headers.get('Authorization') + ).toBe('Bearer access-token'); + }); + + test('refreshes and replays an expired request once', async () => { + const provider = { + getValidAccessToken: vi.fn(async () => 'expired-token'), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + const rawFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + const fetch = createAuthFetch(provider, rawFetch); + + const response = await fetch('https://example.com/graphql'); + + expect(response.status).toBe(200); + expect(provider.refreshAccessToken).toHaveBeenCalledOnce(); + expect(provider.refreshAccessToken).toHaveBeenCalledWith( + 'https://example.com' + ); + expect(rawFetch).toHaveBeenCalledTimes(2); + expect( + (rawFetch.mock.calls[1][0] as Request).headers.get('Authorization') + ).toBe('Bearer refreshed-token'); + }); + + test('does not attach a token when the endpoint has no session', async () => { + const provider = { + getValidAccessToken: vi.fn(async () => null), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + const rawFetch = vi.fn( + async () => new Response(null, { status: 200 }) + ); + const fetch = createAuthFetch(provider, rawFetch); + + await fetch('https://cdn.example.com/presigned/blob'); + + expect( + (rawFetch.mock.calls[0][0] as Request).headers.has('Authorization') + ).toBe(false); + }); +}); + +describe('auth request XMLHttpRequest', () => { + test('does not send after abort while waiting for a token', async () => { + let resolveToken: (token: string | null) => void = () => {}; + const token = new Promise(resolve => { + resolveToken = resolve; + }); + const xhrCalls = stubXMLHttpRequest(); + installAuthRequestProxy({ + getValidAccessToken: vi.fn(() => token), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }); + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'https://example.com/graphql'); + xhr.send('body'); + xhr.abort(); + resolveToken('access-token'); + await Promise.resolve(); + + expect(xhrCalls.abort).toHaveBeenCalledOnce(); + expect(xhrCalls.send).not.toHaveBeenCalled(); + }); + + test('does not send a stale body after reopening', async () => { + const tokenResolvers: ((token: string | null) => void)[] = []; + const xhrCalls = stubXMLHttpRequest(); + installAuthRequestProxy({ + getValidAccessToken: vi.fn( + () => + new Promise(resolve => { + tokenResolvers.push(resolve); + }) + ), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }); + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'https://example.com/first'); + xhr.send('first-body'); + xhr.open('POST', 'https://example.com/second'); + xhr.send('second-body'); + tokenResolvers[1](null); + tokenResolvers[0](null); + await Promise.resolve(); + + expect(xhrCalls.send).toHaveBeenCalledOnce(); + expect(xhrCalls.send).toHaveBeenCalledWith('second-body'); + }); + + test('uses the native lifecycle when token lookup fails', async () => { + const xhrCalls = stubXMLHttpRequest(true); + installAuthRequestProxy({ + getValidAccessToken: vi.fn(async () => { + throw new Error('token lookup failed'); + }), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }); + const xhr = new XMLHttpRequest(); + const loadend = vi.fn(); + xhr.addEventListener('loadend', loadend); + + xhr.open('POST', 'https://example.com/graphql'); + xhr.send('body'); + await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledWith('body')); + + expect(xhr.readyState).toBe(XMLHttpRequest.DONE); + expect(loadend).toHaveBeenCalledOnce(); + }); + + test('preserves abort lifecycle while waiting to replay', async () => { + let resolveRefresh: (token: string) => void = () => {}; + const refresh = new Promise(resolve => { + resolveRefresh = resolve; + }); + const xhrCalls = stubXMLHttpRequest(); + const provider = { + getValidAccessToken: vi.fn(async () => null), + refreshAccessToken: vi.fn(() => refresh), + }; + installAuthRequestProxy(provider); + const xhr = new XMLHttpRequest(); + const loadend = vi.fn(); + xhr.addEventListener('loadend', loadend); + + xhr.open('POST', 'https://example.com/graphql'); + xhr.send('body'); + await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce()); + xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' })); + expect(provider.refreshAccessToken).toHaveBeenCalledOnce(); + + xhr.abort(); + resolveRefresh('refreshed-token'); + await Promise.resolve(); + + expect(loadend).toHaveBeenCalledOnce(); + expect(xhrCalls.send).toHaveBeenCalledOnce(); + }); +}); + +describe('auth message channel', () => { + test('serves get-valid and refresh operations', async () => { + const channel = new MessageChannel(); + const nativeProvider = { + getValidAccessToken: vi.fn(async () => 'access-token'), + refreshAccessToken: vi.fn(async () => 'refreshed-token'), + }; + serveAuthRequests(channel.port1, nativeProvider); + const workerProvider = new MessagePortAuthProvider(); + workerProvider.setPort(channel.port2); + + await expect( + workerProvider.getValidAccessToken('https://example.com') + ).resolves.toBe('access-token'); + await expect( + workerProvider.refreshAccessToken('https://example.com') + ).resolves.toBe('refreshed-token'); + + expect(nativeProvider.getValidAccessToken).toHaveBeenCalledWith( + 'https://example.com' + ); + expect(nativeProvider.refreshAccessToken).toHaveBeenCalledWith( + 'https://example.com' + ); + channel.port1.close(); + channel.port2.close(); + }); + + test('maps terminal get-valid errors to an empty session', async () => { + const channel = new MessageChannel(); + const error = Object.assign(new Error('expired'), { + code: 'AUTH_SESSION_EXPIRED', + }); + serveAuthRequests(channel.port1, { + getValidAccessToken: vi.fn(async () => { + throw error; + }), + refreshAccessToken: vi.fn(async () => { + throw error; + }), + }); + const workerProvider = new MessagePortAuthProvider(); + workerProvider.setPort(channel.port2); + + await expect( + workerProvider.getValidAccessToken('https://example.com') + ).resolves.toBeNull(); + await expect( + workerProvider.refreshAccessToken('https://example.com') + ).rejects.toThrow('AUTH_SESSION_EXPIRED'); + + channel.port1.close(); + channel.port2.close(); + }); +}); diff --git a/packages/frontend/apps/mobile-shared/src/auth/channel.ts b/packages/frontend/apps/mobile-shared/src/auth/channel.ts new file mode 100644 index 0000000000..f81219afb6 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/auth/channel.ts @@ -0,0 +1,137 @@ +import type { AuthRequestProvider } from './request'; + +type AuthOperation = 'get-valid' | 'refresh'; + +type AuthRequest = { + id: string; + operation: AuthOperation; + endpoint: string; +}; + +type AuthResponse = { + id: string; + token?: string | null; + error?: string; +}; + +const terminalAuthErrors = new Set([ + 'ACCESS_TOKEN_INVALID', + 'AUTH_SESSION_EXPIRED', + 'AUTH_SESSION_REVOKED', + 'REFRESH_TOKEN_INVALID', + 'REFRESH_TOKEN_REUSED', + 'UNSUPPORTED_CLIENT_VERSION', + 'AUTH_SESSION_EMPTY', +]); + +const temporaryAuthError = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE'; + +function errorCode(error: unknown) { + return typeof error === 'object' && + error && + 'code' in error && + typeof error.code === 'string' + ? error.code + : temporaryAuthError; +} + +export function serveAuthRequests( + port: MessagePort, + provider: AuthRequestProvider +) { + port.addEventListener('message', event => { + const { id, operation, endpoint } = event.data as Partial; + if ( + !id || + (operation !== 'get-valid' && operation !== 'refresh') || + !endpoint + ) { + return; + } + + const request = + operation === 'refresh' + ? provider.refreshAccessToken(endpoint) + : provider.getValidAccessToken(endpoint); + request.then( + token => port.postMessage({ id, token } satisfies AuthResponse), + error => + port.postMessage({ + id, + error: errorCode(error), + } satisfies AuthResponse) + ); + }); + port.start(); +} + +export class MessagePortAuthProvider implements AuthRequestProvider { + private port?: MessagePort; + private nextRequestId = 0; + private readonly pending = new Map< + string, + { + resolve: (token: string | null) => void; + reject: (error: Error) => void; + } + >(); + + setPort(port: MessagePort) { + this.port = port; + port.addEventListener('message', event => { + const { id, token, error } = event.data as Partial; + if (!id) return; + const pending = this.pending.get(id); + if (!pending) return; + + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(token ?? null); + } + this.pending.delete(id); + }); + port.start(); + } + + async getValidAccessToken(endpoint: string) { + try { + return await this.request('get-valid', endpoint); + } catch (error) { + if (error instanceof Error && terminalAuthErrors.has(error.message)) { + return null; + } + throw error; + } + } + + async refreshAccessToken(endpoint: string) { + const token = await this.request('refresh', endpoint); + if (!token) throw new Error(temporaryAuthError); + return token; + } + + private request(operation: AuthOperation, endpoint: string) { + const port = this.port; + if (!port) return Promise.reject(new Error(temporaryAuthError)); + + const id = String(++this.nextRequestId); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(temporaryAuthError)); + }, 5000); + this.pending.set(id, { + resolve: token => { + clearTimeout(timeout); + resolve(token); + }, + reject: error => { + clearTimeout(timeout); + reject(error); + }, + }); + port.postMessage({ id, operation, endpoint } satisfies AuthRequest); + }); + } +} diff --git a/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts b/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts deleted file mode 100644 index 62d76a34b7..0000000000 --- a/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { canonicalAuthEndpoint } from './endpoint'; - -describe('canonicalAuthEndpoint', () => { - test.each([ - ['https://AFFINE.PRO/path?query=1', 'https://affine.pro'], - ['https://affine.pro:443', 'https://affine.pro'], - ['http://localhost:80/path', 'http://localhost'], - ['http://localhost:8080/path', 'http://localhost:8080'], - ['capacitor://localhost/path', 'capacitor://localhost/path'], - ['invalid endpoint', 'invalid endpoint'], - ])('normalizes %s', (endpoint, expected) => { - expect(canonicalAuthEndpoint(endpoint)).toBe(expected); - }); -}); diff --git a/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts b/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts index 6086a6b479..9caa5f0808 100644 --- a/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts +++ b/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts @@ -8,3 +8,7 @@ export function canonicalAuthEndpoint(endpoint: string) { return endpoint; } } + +export function shouldRefreshAccessToken(code: unknown) { + return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID'; +} diff --git a/packages/frontend/apps/mobile-shared/src/auth/request.ts b/packages/frontend/apps/mobile-shared/src/auth/request.ts new file mode 100644 index 0000000000..f54ead9387 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/auth/request.ts @@ -0,0 +1,213 @@ +import { shouldRefreshAccessToken } from './endpoint'; + +export interface AuthRequestProvider { + getValidAccessToken(endpoint: string): Promise; + refreshAccessToken(endpoint: string): Promise; +} + +function authEndpointForUrl(url: string | URL) { + try { + const parsed = new URL( + url, + globalThis.location?.origin ?? 'http://localhost' + ); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.origin + : null; + } catch { + return null; + } +} + +export function createAuthFetch( + provider: AuthRequestProvider, + rawFetch: typeof globalThis.fetch +) { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const retry = request.clone(); + const endpoint = authEndpointForUrl(request.url); + const token = endpoint + ? await provider.getValidAccessToken(endpoint) + : null; + + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + + const response = await rawFetch(request); + if (response.status !== 401 || !endpoint) return response; + + const body = await response + .clone() + .json() + .catch(() => null); + if (!shouldRefreshAccessToken(body?.code)) return response; + + const refreshed = await provider.refreshAccessToken(endpoint); + retry.headers.set('Authorization', `Bearer ${refreshed}`); + return rawFetch(retry); + }; +} + +export function installAuthRequestProxy(provider: AuthRequestProvider) { + const rawFetch = globalThis.fetch; + globalThis.fetch = createAuthFetch(provider, rawFetch); + + const rawXMLHttpRequest = globalThis.XMLHttpRequest; + const xhrRequestUrls = new WeakMap(); + globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { + private request: + | { + method: string; + url: string | URL; + async: boolean; + username?: string | null; + password?: string | null; + } + | undefined; + private readonly headers = new Map(); + private requestBody?: Document | XMLHttpRequestBodyInit | null; + private replaying = false; + private hasReplayed = false; + private sendVersion = 0; + + constructor() { + super(); + const suppressExpiredResponse = (event: Event) => { + if (this.replaying) event.stopImmediatePropagation(); + }; + this.addEventListener('load', suppressExpiredResponse, true); + this.addEventListener('loadend', suppressExpiredResponse, true); + this.addEventListener( + 'readystatechange', + event => { + if ( + this.readyState !== rawXMLHttpRequest.DONE || + this.status !== 401 || + this.replaying || + this.hasReplayed || + !this.request?.async + ) { + return; + } + let code: unknown; + try { + code = + this.responseType === 'json' + ? this.response?.code + : JSON.parse(this.responseText)?.code; + } catch { + return; + } + if (!shouldRefreshAccessToken(code)) return; + event.stopImmediatePropagation(); + this.replaying = true; + this.hasReplayed = true; + this.replayWithFreshToken().catch(() => {}); + }, + true + ); + } + + override open( + method: string, + url: string | URL, + async: boolean = true, + username?: string | null, + password?: string | null + ): void { + this.sendVersion++; + this.request = { method, url, async, username, password }; + this.headers.clear(); + this.requestBody = undefined; + this.replaying = false; + this.hasReplayed = false; + xhrRequestUrls.set(this, url.toString()); + return super.open( + method, + url, + async, + username ?? undefined, + password ?? undefined + ); + } + + override setRequestHeader(name: string, value: string): void { + this.headers.set(name, value); + super.setRequestHeader(name, value); + } + + override send(body?: Document | XMLHttpRequestBodyInit | null): void { + this.requestBody = body; + const requestUrl = xhrRequestUrls.get(this); + const endpoint = authEndpointForUrl( + requestUrl ?? globalThis.location.href + ); + const sendVersion = this.sendVersion; + + const sendWithToken = (token: string | null) => { + if (sendVersion !== this.sendVersion) return; + if (token) { + super.setRequestHeader('Authorization', `Bearer ${token}`); + } + super.send(body); + }; + + (endpoint + ? provider.getValidAccessToken(endpoint) + : Promise.resolve(null) + ).then(sendWithToken, () => sendWithToken(null)); + } + + override abort(): void { + this.sendVersion++; + this.replaying = false; + super.abort(); + } + + private async replayWithFreshToken() { + const request = this.request; + if (!request) return this.failReplay(); + const endpoint = authEndpointForUrl(request.url); + if (!endpoint) return this.failReplay(); + const sendVersion = this.sendVersion; + try { + const token = await provider.refreshAccessToken(endpoint); + if (sendVersion !== this.sendVersion) return; + const responseType = this.responseType; + const timeout = this.timeout; + const withCredentials = this.withCredentials; + super.open( + request.method, + request.url, + true, + request.username ?? undefined, + request.password ?? undefined + ); + this.replaying = false; + this.headers.forEach((value, name) => { + if (name.toLowerCase() !== 'authorization') { + super.setRequestHeader(name, value); + } + }); + super.setRequestHeader('Authorization', `Bearer ${token}`); + this.responseType = responseType; + this.timeout = timeout; + this.withCredentials = withCredentials; + super.send(this.requestBody); + } catch { + if (sendVersion === this.sendVersion) { + this.failReplay(); + } + } + } + + private failReplay() { + this.replaying = false; + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('error')); + this.dispatchEvent(new Event('loadend')); + } + }; +} diff --git a/packages/frontend/apps/mobile-shared/src/index.ts b/packages/frontend/apps/mobile-shared/src/index.ts index 50516b4a08..c7c8076d6e 100644 --- a/packages/frontend/apps/mobile-shared/src/index.ts +++ b/packages/frontend/apps/mobile-shared/src/index.ts @@ -1,2 +1,5 @@ +export * from './auth/channel'; export * from './auth/endpoint'; +export * from './auth/request'; +export * from './nbstore/optional'; export * from './nbstore/payload'; diff --git a/packages/frontend/apps/mobile-shared/src/nbstore/optional.spec.ts b/packages/frontend/apps/mobile-shared/src/nbstore/optional.spec.ts new file mode 100644 index 0000000000..758ac5f1f6 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/nbstore/optional.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeNativeOptional } from './optional'; + +describe('normalizeNativeOptional', () => { + it.each([ + ['string', 'summary', 'summary'], + ['null', null, null], + ['missing key', undefined, null], + ['wrong type', 42, 42], + ])('normalizes %s', (_, input, expected) => { + expect(normalizeNativeOptional(input)).toBe(expected); + }); +}); diff --git a/packages/frontend/apps/mobile-shared/src/nbstore/optional.ts b/packages/frontend/apps/mobile-shared/src/nbstore/optional.ts new file mode 100644 index 0000000000..5f4eb95faf --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/nbstore/optional.ts @@ -0,0 +1,3 @@ +export function normalizeNativeOptional(value: T | null | undefined) { + return value ?? null; +} diff --git a/packages/frontend/component/src/ui/menu/menu.types.ts b/packages/frontend/component/src/ui/menu/menu.types.ts index b2f0a872de..c655b221c5 100644 --- a/packages/frontend/component/src/ui/menu/menu.types.ts +++ b/packages/frontend/component/src/ui/menu/menu.types.ts @@ -1,12 +1,11 @@ import type { DropdownMenuContentProps, - DropdownMenuItemProps as MenuItemPropsPrimitive, DropdownMenuPortalProps, DropdownMenuProps, DropdownMenuSubContentProps, DropdownMenuSubProps, } from '@radix-ui/react-dropdown-menu'; -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; export interface MenuRef { changeOpen: (open: boolean) => void; @@ -25,9 +24,12 @@ export interface MenuProps { } export interface MenuItemProps extends Omit< - MenuItemPropsPrimitive, - 'asChild' | 'textValue' | 'prefix' + HTMLAttributes, + 'onSelect' | 'prefix' > { + disabled?: boolean; + textValue?: string; + onSelect?: (event: Event) => void; type?: 'default' | 'warning' | 'danger'; prefix?: ReactNode; suffix?: ReactNode; diff --git a/packages/frontend/component/src/ui/menu/mobile/item.tsx b/packages/frontend/component/src/ui/menu/mobile/item.tsx index e7c3bd4ed4..fa09ba7b97 100644 --- a/packages/frontend/component/src/ui/menu/mobile/item.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/item.tsx @@ -1,47 +1,48 @@ -import { useCallback, useContext } from 'react'; +import { type MouseEvent, useCallback, useContext } from 'react'; import type { MenuItemProps } from '../menu.types'; import { useMenuItem } from '../use-menu-item'; import { MobileMenuContext } from './context'; -let preventDefaultFlag = false; -const preventDefault = () => { - preventDefaultFlag = true; -}; - export const MobileMenuItem = (props: MenuItemProps) => { const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext); const { className, children, otherProps } = useMenuItem(props); - const { onSelect, onClick, divide, ...restProps } = otherProps; + const { + onSelect, + onClick, + divide, + textValue: _textValue, + ...restProps + } = otherProps; const onItemClick = useCallback( - (e: any) => { - onSelect?.(e); - onClick?.({ ...e, preventDefault }); - if (preventDefaultFlag) { - preventDefaultFlag = false; + (event: MouseEvent) => { + onSelect?.(event.nativeEvent); + onClick?.(event); + if (event.defaultPrevented || event.nativeEvent.defaultPrevented) { + return; + } + if (subMenus.length > 1) { + // assume the user can only click the last menu + // (mimic the back button) + setSubMenus(subMenus.slice(0, -1)); } else { - if (subMenus.length > 1) { - // assume the user can only click the last menu - // (mimic the back button) - setSubMenus(subMenus.slice(0, -1)); - } else { - setOpen?.(false); - } + setOpen?.(false); } }, [onClick, onSelect, setOpen, setSubMenus, subMenus] ); return ( -
{children} -
+ ); }; diff --git a/packages/frontend/component/src/ui/menu/mobile/root.tsx b/packages/frontend/component/src/ui/menu/mobile/root.tsx index 668e108731..ad12618d85 100644 --- a/packages/frontend/component/src/ui/menu/mobile/root.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/root.tsx @@ -5,8 +5,8 @@ import clsx from 'clsx'; import { useCallback, useContext, - useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useState, } from 'react'; @@ -71,13 +71,15 @@ export const MobileMenu = ({ const activeIndex = subMenus.length; // dynamic height for slider - useEffect(() => { + useLayoutEffect(() => { if (sliderElement && finalOpen) { const active = sliderElement.querySelector( `.${styles.menuContent}[data-index="${activeIndex}"]` ); if (!active) return; + setSliderHeight(active.getBoundingClientRect().height); + // for the situation that content is loaded asynchronously return observeResize(active, entry => { setSliderHeight(entry.borderBoxSize[0].blockSize); @@ -156,6 +158,7 @@ export const MobileMenu = ({ onOpenChange={onOpenChange} width="100%" animation="slideBottom" + contentAnimation="none" withoutCloseButton={true} contentOptions={{ className: clsx(className, styles.mobileMenuModal), @@ -163,6 +166,7 @@ export const MobileMenu = ({ }} contentWrapperStyle={contentWrapperStyle} disableAutoFocus={true} + preserveEditingFocusOnAction >
, @@ -35,9 +35,14 @@ export const MobileMenuSub = ({ subContentOptions={contentOptions} title={title} > -
+
+ ); }; @@ -50,7 +55,7 @@ export const MobileMenuSubRaw = ({ subOptions, subContentOptions, }: MenuSubProps & { - onClick?: (e: MouseEvent) => void; + onClick?: (e: MouseEvent) => void; title?: string; }) => { const contentOptions = subContentOptions ?? EMPTY_SUB_CONTENT_OPTIONS; @@ -67,7 +72,7 @@ export const MobileMenuSubRaw = ({ }, [addSubMenu, subMenuContent]); const onItemClick = useCallback( - (e: MouseEvent) => { + (e: MouseEvent) => { onClick?.(e); doAddSubMenu(); }, diff --git a/packages/frontend/component/src/ui/modal/context.ts b/packages/frontend/component/src/ui/modal/context.ts index 24bdbef924..70380ee681 100644 --- a/packages/frontend/component/src/ui/modal/context.ts +++ b/packages/frontend/component/src/ui/modal/context.ts @@ -6,7 +6,7 @@ export interface ModalConfig { * add global callback for modal open, * return a function to handle close/unmount callback */ - onOpen?: () => OnClose; + onOpen?: (close: () => void) => OnClose; /** * For mobile */ diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index 3ee6aa6569..15a01f3b02 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -25,6 +25,8 @@ import { SafeArea } from '../safe-area'; import { InsideModalContext, ModalConfigContext } from './context'; import * as styles from './styles.css'; +type ModalAnimation = 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight'; + export interface ModalProps extends DialogProps { width?: CSSProperties['width']; height?: CSSProperties['height']; @@ -48,17 +50,29 @@ export interface ModalProps extends DialogProps { /** * @default 'fadeScaleTop' */ - animation?: 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight'; + animation?: ModalAnimation; + contentAnimation?: ModalAnimation; /** * Whether to show the modal in full screen mode */ fullScreen?: boolean; disableAutoFocus?: boolean; + preserveEditingFocusOnAction?: boolean; } type PointerDownOutsideEvent = Parameters< Exclude >[0]; +const isTextEditingElement = (element: Element) => + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element instanceof HTMLElement && element.isContentEditable); + +const getEnabledModalAction = (element: Element) => + element.closest( + 'button:not(:disabled), [role="button"]:not([aria-disabled="true"]), [role="menuitem"]:not([aria-disabled="true"]), [data-modal-action]:not([aria-disabled="true"])' + ); + const getVar = (style: number | string = '', defaultValue = '') => { return style ? typeof style === 'number' @@ -151,6 +165,7 @@ export const ModalInner = forwardRef( style: contentStyle, className: contentClassName, onPointerDownOutside, + onPointerDown, onEscapeKeyDown, ...otherContentOptions } = {}, @@ -164,8 +179,10 @@ export const ModalInner = forwardRef( contentWrapperClassName, contentWrapperStyle, animation = BUILD_CONFIG.isMobileEdition ? 'slideBottom' : 'fadeScaleTop', + contentAnimation = animation, fullScreen, disableAutoFocus, + preserveEditingFocusOnAction = false, ...otherProps } = props; const { className: closeButtonClassName, ...otherCloseButtonProps } = @@ -176,9 +193,9 @@ export const ModalInner = forwardRef( ); useEffect(() => { - if (open) return modalConfigOnOpen?.(); + if (open) return modalConfigOnOpen?.(() => onOpenChange?.(false)); return; - }, [modalConfigOnOpen, open]); + }, [modalConfigOnOpen, onOpenChange, open]); useEffect(() => { if (open) { @@ -211,6 +228,29 @@ export const ModalInner = forwardRef( [onEscapeKeyDown, persistent] ); + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + onPointerDown?.(event); + if ( + !preserveEditingFocusOnAction || + event.defaultPrevented || + !(event.target instanceof Element) + ) { + return; + } + const activeElement = document.activeElement; + if ( + activeElement && + event.currentTarget.contains(activeElement) && + isTextEditingElement(activeElement) && + getEnabledModalAction(event.target) + ) { + event.preventDefault(); + } + }, + [onPointerDown, preserveEditingFocusOnAction] + ); + const handleAutoFocus = useCallback( (e: Event) => { disableAutoFocus && e.preventDefault(); @@ -244,19 +284,34 @@ export const ModalInner = forwardRef( > ( ), [styles.heightVar]: getVar( height, - fullScreen ? '100dvh' : 'unset' + fullScreen + ? `calc(100dvh - ${styles.keyboardInsetVar})` + : 'unset' ), - [styles.minHeightVar]: getVar(minHeight, '26px'), + [styles.minHeightVar]: fullScreen + ? `calc(100dvh - ${styles.keyboardInsetVar})` + : getVar(minHeight, '26px'), }), ...contentStyle, }} diff --git a/packages/frontend/component/src/ui/modal/prompt-modal.tsx b/packages/frontend/component/src/ui/modal/prompt-modal.tsx index d5123e4b30..3f1ac94c9e 100644 --- a/packages/frontend/component/src/ui/modal/prompt-modal.tsx +++ b/packages/frontend/component/src/ui/modal/prompt-modal.tsx @@ -86,6 +86,7 @@ export const PromptModal = ({ return ( { diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts index f64c2bbf4a..a6ee7e26fe 100644 --- a/packages/frontend/component/src/ui/modal/styles.css.ts +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -12,6 +12,7 @@ import { vtScopeSelector } from '../../utils/view-transition'; export const widthVar = createVar('widthVar'); export const heightVar = createVar('heightVar'); export const minHeightVar = createVar('minHeightVar'); +export const keyboardInsetVar = createVar('keyboardInsetVar'); export const modalVTScope = generateIdentifier('modal'); @@ -89,14 +90,24 @@ export const modalContentWrapper = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', + boxSizing: 'border-box', zIndex: cssVar('zIndexModal'), + transition: 'padding-bottom 230ms ease-out', + + '@media': { + '(prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, selectors: { '&[data-mobile]': { alignItems: 'flex-end', - paddingBottom: 'env(safe-area-inset-bottom, 20px)', + paddingBottom: + 'var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 20px))', }, '&[data-full-screen="true"]': { + alignItems: 'flex-start', padding: '0 !important', }, '&.anim-none': { @@ -174,8 +185,8 @@ export const modalContent = style({ '[data-full-screen="true"] &': { vars: { [widthVar]: '100dvw', - [heightVar]: '100dvh', - [minHeightVar]: '100dvh', + [heightVar]: `calc(100dvh - ${keyboardInsetVar})`, + [minHeightVar]: `calc(100dvh - ${keyboardInsetVar})`, }, maxWidth: '100dvw', maxHeight: '100dvh', diff --git a/packages/frontend/component/src/ui/notification/mobile/notification-center.tsx b/packages/frontend/component/src/ui/notification/mobile/notification-center.tsx index 924a389653..5b8c6ee6b7 100644 --- a/packages/frontend/component/src/ui/notification/mobile/notification-center.tsx +++ b/packages/frontend/component/src/ui/notification/mobile/notification-center.tsx @@ -14,7 +14,7 @@ export function MobileNotificationCenter() { position="top-center" style={{ width: '100%', - top: 'calc(env(safe-area-inset-top) + 16px)', + top: 'calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 16px)', pointerEvents: 'auto', }} theme={resolvedTheme} diff --git a/packages/frontend/component/src/ui/safe-area/style.css.ts b/packages/frontend/component/src/ui/safe-area/style.css.ts index 042ececeac..e18ea54bb0 100644 --- a/packages/frontend/component/src/ui/safe-area/style.css.ts +++ b/packages/frontend/component/src/ui/safe-area/style.css.ts @@ -12,10 +12,10 @@ export const safeArea = style({ paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`, }, '&[data-standalone][data-top]': { - paddingTop: `calc(env(safe-area-inset-top, 12px) + ${topOffsetVar})`, + paddingTop: `calc(var(--safe-area-inset-top, env(safe-area-inset-top, 12px)) + ${topOffsetVar})`, }, '&[data-standalone][data-bottom]': { - paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${fallbackVar(bottomOffsetVar, '0px')})`, + paddingBottom: `calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + ${fallbackVar(bottomOffsetVar, '0px')})`, }, }, }); diff --git a/packages/frontend/core/src/commands/affine-creation.tsx b/packages/frontend/core/src/commands/affine-creation.tsx index b76f844447..55df32b277 100644 --- a/packages/frontend/core/src/commands/affine-creation.tsx +++ b/packages/frontend/core/src/commands/affine-creation.tsx @@ -53,19 +53,21 @@ export function registerAffineCreationCommands({ }) ); - unsubs.push( - registerAffineCommand({ - id: 'affine:new-workspace', - category: 'affine:creation', - icon: , - label: t['com.affine.cmdk.affine.new-workspace'](), - run() { - track.$.cmdk.workspace.createWorkspace(); + if (!BUILD_CONFIG.isMobileEdition) { + unsubs.push( + registerAffineCommand({ + id: 'affine:new-workspace', + category: 'affine:creation', + icon: , + label: t['com.affine.cmdk.affine.new-workspace'](), + run() { + track.$.cmdk.workspace.createWorkspace(); - globalDialogService.open('create-workspace', {}); - }, - }) - ); + globalDialogService.open('create-workspace', {}); + }, + }) + ); + } unsubs.push( registerAffineCommand({ id: 'affine:import-workspace', diff --git a/packages/frontend/core/src/components/mobile/config-modal/index.tsx b/packages/frontend/core/src/components/mobile/config-modal/index.tsx index 2661ff2d74..4f364e12b5 100644 --- a/packages/frontend/core/src/components/mobile/config-modal/index.tsx +++ b/packages/frontend/core/src/components/mobile/config-modal/index.tsx @@ -36,6 +36,7 @@ export const ConfigModal = ({ const t = useI18n(); return ( { const t = useI18n(); const notificationListService = useService(NotificationListService); + const authService = useService(AuthService); + const globalDialogService = useService(GlobalDialogService); + const authStatus = useLiveData(authService.session.status$); const notifications = useLiveData(notificationListService.notifications$); const isLoading = useLiveData(notificationListService.isLoading$); const error = useLiveData(notificationListService.error$); const hasMore = useLiveData(notificationListService.hasMore$); + const showLoadingMore = + authStatus === 'authenticated' && + notifications.length > 0 && + hasMore && + isLoading; const loadMoreIndicatorRef = useRef(null); const userFriendlyError = useMemo(() => { @@ -65,23 +74,31 @@ export const NotificationList = () => { }, [error]); useLayoutEffect(() => { - // reset the notification list when the component is mounted notificationListService.reset(); - notificationListService.loadMore(); - }, [notificationListService]); + if (authStatus === 'authenticated') notificationListService.loadMore(); + }, [authStatus, notificationListService]); useEffect(() => { if (loadMoreIndicatorRef.current) { let previousIsIntersecting = false; return observeIntersection(loadMoreIndicatorRef.current, entity => { - if (entity.isIntersecting && !previousIsIntersecting && hasMore) { + if ( + authStatus === 'authenticated' && + entity.isIntersecting && + !previousIsIntersecting && + hasMore + ) { notificationListService.loadMore(); } previousIsIntersecting = entity.isIntersecting; }); } return; - }, [hasMore, notificationListService]); + }, [authStatus, hasMore, notificationListService]); + + const handleSignIn = useCallback(() => { + globalDialogService.open('sign-in', {}); + }, [globalDialogService]); const handleDeleteAll = useCallback(() => { notificationListService.readAllNotifications().catch(err => { @@ -110,7 +127,9 @@ export const NotificationList = () => {
- {notifications.length > 0 ? ( + {authStatus === 'unauthenticated' ? ( + + ) : notifications.length > 0 ? (
    {notifications.map(notification => (
  • @@ -118,22 +137,32 @@ export const NotificationList = () => {
  • ))} {userFriendlyError && ( -
    {userFriendlyError.message}
    +
  • + notificationListService.retry()} + /> +
  • )}
) : isLoading ? ( ) : userFriendlyError ? ( -
{userFriendlyError.message}
+ notificationListService.retry()} + /> ) : ( )}
- {hasMore ? t['com.affine.notification.loading-more']() : null} + {showLoadingMore + ? t['com.affine.notification.loading-more']() + : null}
@@ -142,6 +171,61 @@ export const NotificationList = () => { ); }; +const NotificationSignIn = ({ onSignIn }: { onSignIn: () => void }) => { + const t = useI18n(); + return ( +
+
+ +
+
+ {t['com.affine.ai.login-required.dialog-title']()} +
+
+ {t['com.affine.notification.empty.description']()} +
+ +
+ ); +}; + +const NotificationErrorEmpty = ({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) => { + const t = useI18n(); + return ( +
+
+ +
+
{message}
+ +
+ ); +}; + +const NotificationError = ({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) => { + const t = useI18n(); + return ( +
+ {message} + +
+ ); +}; + const NotificationListEmpty = () => { const t = useI18n(); return ( diff --git a/packages/frontend/core/src/components/tags/tags-editor.tsx b/packages/frontend/core/src/components/tags/tags-editor.tsx index 248a18443f..443b66f718 100644 --- a/packages/frontend/core/src/components/tags/tags-editor.tsx +++ b/packages/frontend/core/src/components/tags/tags-editor.tsx @@ -311,6 +311,7 @@ export const TagsEditor = ({ onClick: () => onSelectTagOption(tag), onMouseEnter: () => setFocusedIndex(idx), ['data-testid']: 'tag-selector-item', + ['data-modal-action']: '', ['data-focused']: safeFocusedIndex === idx, className: styles.tagSelectorItem, }; diff --git a/packages/frontend/core/src/desktop/pages/index/index.tsx b/packages/frontend/core/src/desktop/pages/index/index.tsx index ac87e1d07f..a368a0d2b3 100644 --- a/packages/frontend/core/src/desktop/pages/index/index.tsx +++ b/packages/frontend/core/src/desktop/pages/index/index.tsx @@ -39,14 +39,18 @@ export const Component = ({ defaultIndexRoute = 'all', children, fallback, + createErrorFallback, }: { defaultIndexRoute?: string; children?: ReactNode; fallback?: ReactNode; + createErrorFallback?: (retry: () => void) => ReactNode; }) => { // navigating and creating may be slow, to avoid flickering, we show workspace fallback const [navigating, setNavigating] = useState(true); const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(false); + const [createAttempt, setCreateAttempt] = useState(0); const authService = useService(AuthService); const defaultServerService = useService(DefaultServerService); @@ -118,6 +122,9 @@ export const Component = ({ } } else { if (list.length === 0) { + if (BUILD_CONFIG.isMobileEdition && enableLocalWorkspace) { + return; + } setNavigating(false); return; } @@ -151,7 +158,12 @@ export const Component = ({ return; } - createFirstAppData(workspacesService) + const creation = createFirstAppData(workspacesService); + if (!creation) return; + + setCreateError(false); + setCreating(true); + creation .then(createdWorkspace => { if (createdWorkspace) { if (createdWorkspace.defaultPageId) { @@ -166,21 +178,29 @@ export const Component = ({ }) .catch(err => { console.error('Failed to create first app data', err); + setCreateError(true); }) .finally(() => { setCreating(false); }); }, [ jumpToPage, - jumpToSignIn, openPage, workspacesService, - loggedIn, listIsLoading, list, enableLocalWorkspace, + createAttempt, ]); + const retryCreate = useCallback(() => { + setCreateAttempt(attempt => attempt + 1); + }, []); + + if (createError && createErrorFallback) { + return createErrorFallback(retryCreate); + } + if (navigating || creating) { return fallback ?? ; } diff --git a/packages/frontend/core/src/mobile/components/app-fallback/index.tsx b/packages/frontend/core/src/mobile/components/app-fallback/index.tsx index 66690b23d1..a03246b16d 100644 --- a/packages/frontend/core/src/mobile/components/app-fallback/index.tsx +++ b/packages/frontend/core/src/mobile/components/app-fallback/index.tsx @@ -1,6 +1,6 @@ -import { SafeArea, Skeleton } from '@affine/component'; - -import { WorkspaceSelector } from '../workspace-selector'; +import { Button, SafeArea, Skeleton } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { cssVarV2 } from '@toeverything/theme/v2'; const SectionTitleFallback = () => { return ( @@ -49,7 +49,9 @@ const Section = () => { ); }; -export const AppFallback = () => { +export const AppFallback = ({ onRetry }: { onRetry?: () => void }) => { + const t = useI18n(); + return ( {/* notification and setting */} @@ -73,7 +75,10 @@ export const AppFallback = () => { {/* workspace card */}
- +
{/* search */}
@@ -92,6 +97,33 @@ export const AppFallback = () => {
+ {onRetry ? ( +
+
+ {t['error.INTERNAL_SERVER_ERROR']()} +
+ +
+ ) : null} ); }; diff --git a/packages/frontend/core/src/mobile/components/app-tabs/create.tsx b/packages/frontend/core/src/mobile/components/app-tabs/create.tsx index aa6aac0473..a8fa272a6b 100644 --- a/packages/frontend/core/src/mobile/components/app-tabs/create.tsx +++ b/packages/frontend/core/src/mobile/components/app-tabs/create.tsx @@ -32,10 +32,10 @@ export const AppTabCreate = ({ tab }: AppTabCustomFCProps) => { if (enablePageTemplate && pageTemplateDocId) { const docId = await docsService.duplicateFromTemplate(pageTemplateDocId); - workbench.openDoc({ docId, fromTab: 'true' }); + workbench.openDoc(docId); } else { const doc = pageHelper.createPage(undefined, { show: false }); - workbench.openDoc({ docId: doc.id, fromTab: 'true' }); + workbench.openDoc(doc.id); } track.$.navigationPanel.$.createDoc(); }, diff --git a/packages/frontend/core/src/mobile/components/app-tabs/journal.tsx b/packages/frontend/core/src/mobile/components/app-tabs/journal.tsx index 71310c54d7..99ecf4d858 100644 --- a/packages/frontend/core/src/mobile/components/app-tabs/journal.tsx +++ b/packages/frontend/core/src/mobile/components/app-tabs/journal.tsx @@ -19,7 +19,7 @@ export const AppTabJournal = ({ tab }: AppTabCustomFCProps) => { const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId)); const handleOpenToday = useCallback(() => { - workbench.open('/journals', { at: 'active' }); + workbench.open('/journals', { at: 'active', replaceHistory: true }); }, [workbench]); const Icon = journalDate ? JournalIcon : TodayIcon; diff --git a/packages/frontend/core/src/mobile/components/index.ts b/packages/frontend/core/src/mobile/components/index.ts index ae3c6a47ee..25203a48a5 100644 --- a/packages/frontend/core/src/mobile/components/index.ts +++ b/packages/frontend/core/src/mobile/components/index.ts @@ -1,6 +1,8 @@ export * from './app-tabs'; export * from './doc-card'; export * from './doc-info'; +export * from './mobile-shell-host'; +export * from './navigation/menu-host'; export * from './navigation-back'; export * from './page-header'; export * from './rename'; diff --git a/packages/frontend/core/src/mobile/components/mobile-modal-config-provider.tsx b/packages/frontend/core/src/mobile/components/mobile-modal-config-provider.tsx new file mode 100644 index 0000000000..e32e405d3b --- /dev/null +++ b/packages/frontend/core/src/mobile/components/mobile-modal-config-provider.tsx @@ -0,0 +1,34 @@ +import { ModalConfigContext } from '@affine/component'; +import { useService } from '@toeverything/infra'; +import { fallbackVar } from '@vanilla-extract/css'; +import { useCallback, useMemo } from 'react'; + +import { MobileBackCoordinator } from '../modules/back-coordinator'; +import { VirtualKeyboardService } from '../modules/virtual-keyboard'; +import { globalVars } from '../styles/variables.css'; + +export const MobileModalConfigProvider = ({ + children, +}: React.PropsWithChildren) => { + const backCoordinator = useService(MobileBackCoordinator); + useService(VirtualKeyboardService); + + const onOpen = useCallback( + (close: () => void) => + backCoordinator.registerVisual({ handle: close }).dispose, + [backCoordinator] + ); + const value = useMemo( + () => ({ + onOpen, + dynamicKeyboardHeight: fallbackVar(globalVars.appKeyboardHeight, '0px'), + }), + [onOpen] + ); + + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/mobile-shell-host.tsx b/packages/frontend/core/src/mobile/components/mobile-shell-host.tsx new file mode 100644 index 0000000000..77ac2ab565 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/mobile-shell-host.tsx @@ -0,0 +1,62 @@ +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { + createContext, + type PropsWithChildren, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { MobileBackCoordinator } from '../modules/back-coordinator'; +import { AppTabs } from './app-tabs'; +import { MobileNavigationMenuHost } from './navigation/menu-host'; + +type TabsMetadata = { + background?: string; + hidden?: boolean; +}; + +const TabsMetadataContext = createContext< + ((metadata: TabsMetadata | null) => void) | null +>(null); + +export const useMobileShellTabs = (metadata: TabsMetadata = {}) => { + const setMetadata = useContext(TabsMetadataContext); + const background = metadata.background; + const hidden = metadata.hidden; + useEffect(() => { + setMetadata?.({ background, hidden }); + return () => setMetadata?.(null); + }, [background, hidden, setMetadata]); +}; + +export const MobileShellHost = ({ children }: PropsWithChildren) => { + const [metadata, setMetadata] = useState(null); + const value = useMemo(() => setMetadata, []); + const workbench = useService(WorkbenchService).workbench; + const workspaceSelectorOpen = useLiveData(workbench.workspaceSelectorOpen$); + const backCoordinator = useService(MobileBackCoordinator); + + useEffect(() => { + if (!workspaceSelectorOpen) return; + const registration = backCoordinator.registerVisual({ + handle: () => workbench.closeWorkspaceSelector(), + }); + return registration.dispose; + }, [backCoordinator, workbench, workspaceSelectorOpen]); + + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/navigation-back/index.tsx b/packages/frontend/core/src/mobile/components/navigation-back/index.tsx index 1df2751edc..92acb34f10 100644 --- a/packages/frontend/core/src/mobile/components/navigation-back/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation-back/index.tsx @@ -5,9 +5,9 @@ import { } from '@affine/component'; import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; -import { NavigationGestureService } from '../../modules/navigation-gesture'; +import { MobileBackCoordinator } from '../../modules/back-coordinator'; export interface NavigationBackButtonProps extends IconButtonProps { backAction?: () => void; @@ -23,23 +23,15 @@ export const NavigationBackButton = ({ style: propsStyle, ...otherProps }: NavigationBackButtonProps) => { - const navigationGesture = useService(NavigationGestureService); + const backCoordinator = useService(MobileBackCoordinator); const isInsideModal = useIsInsideModal(); const handleRouteBack = useCallback(() => { - backAction ? backAction() : history.back(); - }, [backAction]); - - useEffect(() => { - if (isInsideModal) return; - - const prev = navigationGesture.enabled$.value; - navigationGesture.setEnabled(true); - - return () => { - navigationGesture.setEnabled(prev); - }; - }, [isInsideModal, navigationGesture]); + if (backAction) return backAction(); + if (!backCoordinator.request('ui-back')) { + backCoordinator.request('ui-up'); + } + }, [backAction, backCoordinator]); const style = useMemo(() => ({ padding: 10, ...propsStyle }), [propsStyle]); diff --git a/packages/frontend/core/src/mobile/components/navigation/index.ts b/packages/frontend/core/src/mobile/components/navigation/index.ts index 1349d10c1e..f164ad50c2 100644 --- a/packages/frontend/core/src/mobile/components/navigation/index.ts +++ b/packages/frontend/core/src/mobile/components/navigation/index.ts @@ -3,3 +3,4 @@ export { NavigationPanelCollections } from './sections/collections'; export { NavigationPanelFavorites } from './sections/favorites'; export { NavigationPanelOrganize } from './sections/organize'; export { NavigationPanelTags } from './sections/tags'; +export { MobileNavigationVirtualScroller } from './virtual-scroller'; diff --git a/packages/frontend/core/src/mobile/components/navigation/layouts/collapsible-section.tsx b/packages/frontend/core/src/mobile/components/navigation/layouts/collapsible-section.tsx index 0a1b4c6119..ac2a8d986b 100644 --- a/packages/frontend/core/src/mobile/components/navigation/layouts/collapsible-section.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/layouts/collapsible-section.tsx @@ -1,6 +1,5 @@ import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; import { ToggleRightIcon } from '@blocksuite/icons/rc'; -import * as Collapsible from '@radix-ui/react-collapsible'; import { useLiveData, useService } from '@toeverything/infra'; import clsx from 'clsx'; import { @@ -91,9 +90,9 @@ export const CollapsibleSection = ({ ); return ( - @@ -105,12 +104,15 @@ export const CollapsibleSection = ({ data-testid={headerTestId} className={headerClassName} /> - - {children} - - + {collapsed ? null : ( +
+ {children} +
+ )} + ); }; diff --git a/packages/frontend/core/src/mobile/components/navigation/menu-host.tsx b/packages/frontend/core/src/mobile/components/navigation/menu-host.tsx new file mode 100644 index 0000000000..99f93d0235 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/navigation/menu-host.tsx @@ -0,0 +1,74 @@ +import { MobileMenu } from '@affine/component'; +import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useLiveData, useService } from '@toeverything/infra'; +import { + createContext, + Fragment, + type PropsWithChildren, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +type MenuHostContextValue = { + open(items: ReactNode): void; + close(): void; +}; + +const MenuHostContext = createContext(null); + +export const useMobileNavigationMenuHost = () => { + const context = useContext(MenuHostContext); + if (!context) { + throw new Error('MobileNavigationMenuHost is not mounted'); + } + return context; +}; + +export const MobileNavigationMenuItems = ({ + operations, +}: { + operations: NodeOperation[]; +}) => ( + <> + {[...operations] + .sort((a, b) => a.index - b.index) + .filter(operation => !operation.inline) + .map(({ view, index }) => ( + {view} + ))} + +); + +export const MobileNavigationMenuHost = ({ children }: PropsWithChildren) => { + const location = useLiveData( + useService(WorkbenchService).workbench.location$ + ); + const [items, setItems] = useState(null); + const close = useCallback(() => setItems(null), []); + const value = useMemo( + () => ({ open: setItems, close }), + [close] + ); + + useEffect(() => close(), [close, location.pathname]); + + return ( + + {children} + !open && close(), + }} + items={items} + > + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx index 01c8cf8fcd..b15bd6b407 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/index.tsx @@ -18,8 +18,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { NavigationPanelTreeNode } from '../../tree/node'; import { NavigationPanelDocNode } from '../doc'; import { - useNavigationPanelCollectionNodeOperations, - useNavigationPanelCollectionNodeOperationsMenu, + NavigationPanelCollectionNodeMenu, + useNavigationPanelCollectionAddDoc, } from './operations'; const CollectionIcon = () => ; @@ -72,24 +72,26 @@ export const NavigationPanelCollectionNode = ({ }); }, [collection, workspaceDialogService]); - const collectionOperations = useNavigationPanelCollectionNodeOperationsMenu( + const handleAddDocToCollection = useNavigationPanelCollectionAddDoc( collectionId, - handleOpenCollapsed, - handleEditCollection + handleOpenCollapsed ); - const { handleAddDocToCollection } = - useNavigationPanelCollectionNodeOperations( + const menuTarget = useMemo( + () => ( + + ), + [ + additionalOperations, collectionId, - handleOpenCollapsed, - handleEditCollection - ); - - const finalOperations = useMemo(() => { - if (additionalOperations) { - return [...additionalOperations, ...collectionOperations]; - } - return collectionOperations; - }, [additionalOperations, collectionOperations]); + handleAddDocToCollection, + handleEditCollection, + ] + ); if (!collection) { return null; @@ -103,7 +105,7 @@ export const NavigationPanelCollectionNode = ({ setCollapsed={setCollapsed} to={`/collection/${collection.id}`} active={active} - operations={finalOperations} + menuTarget={menuTarget} data-testid={`navigation-panel-collection-${collectionId}`} > void, - onOpenEdit: () => void + onOpenCollapsed: () => void ) => { const t = useI18n(); - const { - workbenchService, - workspaceService, - collectionService, - compatibleFavoriteItemsAdapter, - } = useServices({ - WorkbenchService, + const { workspaceService, collectionService } = useServices({ WorkspaceService, CollectionService, - CompatibleFavoriteItemsAdapter, }); - const { createPage } = usePageHelper( workspaceService.workspace.docCollection ); - - const favorite = useLiveData( - useMemo( - () => - compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'), - [collectionId, compatibleFavoriteItemsAdapter] - ) - ); const { openConfirmModal } = useConfirmModal(); - const createAndAddDocument = useCallback(() => { const newDoc = createPage(); collectionService.addDocToCollection(collectionId, newDoc.id); @@ -66,7 +49,41 @@ export const useNavigationPanelCollectionNodeOperations = ( }); onOpenCollapsed(); }, [collectionId, collectionService, createPage, onOpenCollapsed]); + return useCallback(() => { + openConfirmModal({ + title: t['com.affine.collection.add-doc.confirm.title'](), + description: t['com.affine.collection.add-doc.confirm.description'](), + cancelText: t['Cancel'](), + confirmText: t['Confirm'](), + confirmButtonOptions: { variant: 'primary' }, + onConfirm: createAndAddDocument, + }); + }, [createAndAddDocument, openConfirmModal, t]); +}; +export const useNavigationPanelCollectionNodeOperations = ( + collectionId: string, + handleAddDocToCollection: () => void, + onOpenEdit: () => void +) => { + const t = useI18n(); + const { + workbenchService, + collectionService, + compatibleFavoriteItemsAdapter, + } = useServices({ + WorkbenchService, + CollectionService, + CompatibleFavoriteItemsAdapter, + }); + + const favorite = useLiveData( + useMemo( + () => + compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'), + [collectionId, compatibleFavoriteItemsAdapter] + ) + ); const handleToggleFavoriteCollection = useCallback(() => { compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection'); track.$.navigationPanel.organize.toggleFavorite({ @@ -74,19 +91,6 @@ export const useNavigationPanelCollectionNodeOperations = ( }); }, [compatibleFavoriteItemsAdapter, collectionId]); - const handleAddDocToCollection = useCallback(() => { - openConfirmModal({ - title: t['com.affine.collection.add-doc.confirm.title'](), - description: t['com.affine.collection.add-doc.confirm.description'](), - cancelText: t['Cancel'](), - confirmText: t['Confirm'](), - confirmButtonOptions: { - variant: 'primary', - }, - onConfirm: createAndAddDocument, - }); - }, [createAndAddDocument, openConfirmModal, t]); - const handleOpenInSplitView = useCallback(() => { workbenchService.workbench.openCollection(collectionId, { at: 'beside' }); track.$.navigationPanel.organize.openInSplitView({ @@ -153,14 +157,14 @@ export const useNavigationPanelCollectionNodeOperations = ( export const useNavigationPanelCollectionNodeOperationsMenu = ( collectionId: string, - onOpenCollapsed: () => void, + handleAddDocToCollection: () => void, onOpenEdit: () => void ): NodeOperation[] => { const t = useI18n(); const { favorite, - handleAddDocToCollection, + handleAddDocToCollection: addDocToCollection, handleDeleteCollection, handleOpenInNewTab, handleOpenInSplitView, @@ -169,7 +173,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = ( handleRename, } = useNavigationPanelCollectionNodeOperations( collectionId, - onOpenCollapsed, + handleAddDocToCollection, onOpenEdit ); @@ -181,7 +185,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = ( view: ( } - onClick={handleAddDocToCollection} - > + } onClick={addDocToCollection}> {t['New Page']()} ), @@ -272,7 +273,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = ( ], [ favorite, - handleAddDocToCollection, + addDocToCollection, handleDeleteCollection, handleOpenInNewTab, handleOpenInSplitView, @@ -283,3 +284,26 @@ export const useNavigationPanelCollectionNodeOperationsMenu = ( ] ); }; + +export const NavigationPanelCollectionNodeMenu = ({ + collectionId, + handleAddDocToCollection, + onOpenEdit, + additionalOperations, +}: { + collectionId: string; + handleAddDocToCollection: () => void; + onOpenEdit: () => void; + additionalOperations?: NodeOperation[]; +}) => { + const operations = useNavigationPanelCollectionNodeOperationsMenu( + collectionId, + handleAddDocToCollection, + onOpenEdit + ); + const allOperations = useMemo( + () => [...(additionalOperations ?? []), ...operations], + [additionalOperations, operations] + ); + return ; +}; diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/doc/index.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/doc/index.tsx index ff71988e70..55c3aac9bd 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/doc/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/doc/index.tsx @@ -1,29 +1,18 @@ import { Loading } from '@affine/component'; -import { Guard } from '@affine/core/components/guard'; import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel'; -import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; -import { DocsService } from '@affine/core/modules/doc'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; -import { DocsSearchService } from '@affine/core/modules/docs-search'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; -import { GlobalContextService } from '@affine/core/modules/global-context'; import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; -import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; -import { - LiveData, - MANUALLY_STOP, - useLiveData, - useService, - useServices, -} from '@toeverything/infra'; -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useLiveData, useService, useServices } from '@toeverything/infra'; +import { useCallback, useEffect, useMemo } from 'react'; +import { MobileShellDataProjection } from '../../../../modules/navigation-projection'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { NavigationPanelTreeNode } from '../../tree/node'; import { - useNavigationPanelDocNodeOperations, - useNavigationPanelDocNodeOperationsMenu, + NavigationPanelDocNodeMenu, + useNavigationPanelDocNodeAddLinkedPage, } from './operations'; import * as styles from './styles.css'; @@ -39,24 +28,13 @@ export const NavigationPanelDocNode = ({ parentPath: string[]; }) => { const t = useI18n(); - const { - docsSearchService, - docsService, - globalContextService, - docDisplayMetaService, - featureFlagService, - workspaceService, - } = useServices({ - DocsSearchService, - WorkspaceService, - DocsService, - GlobalContextService, + const { docDisplayMetaService, featureFlagService } = useServices({ DocDisplayMetaService, FeatureFlagService, }); const navigationPanelService = useService(NavigationPanelService); - const active = - useLiveData(globalContextService.globalContext.docId.$) === docId; + const dataProjection = useService(MobileShellDataProjection); + const entry = useLiveData(dataProjection.entry$(docId)); const path = useMemo( () => [...parentPath, `doc-${docId}`], [parentPath, docId] @@ -69,15 +47,12 @@ export const NavigationPanelDocNode = ({ [navigationPanelService, path] ); - const docRecord = useLiveData(docsService.list.doc$(docId)); const DocIcon = useLiveData( docDisplayMetaService.icon$(docId, { reference: isLinked, }) ); - const docTitle = useLiveData(docDisplayMetaService.title$(docId)); - const isInTrash = useLiveData(docRecord?.trash$); const enableEmojiIcon = useLiveData( featureFlagService.flags.enable_emoji_doc_icon.$ ); @@ -90,109 +65,72 @@ export const NavigationPanelDocNode = ({ ); const children = useLiveData( - useMemo( - () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), - [docsSearchService, docId] - ) + dataProjection.refsByDoc$.selector(refs => refs.get(docId)) + ); + useEffect( + () => (collapsed ? undefined : dataProjection.registerExpandedDoc(docId)), + [collapsed, dataProjection, docId] ); - const [referencesLoading, setReferencesLoading] = useState(true); - useLayoutEffect(() => { - if (collapsed) { - return; - } - const abortController = new AbortController(); - const undoSync = workspaceService.workspace.engine.doc.addPriority( - docId, - 10 - ); - const undoIndexer = docsSearchService.indexer.addPriority(docId, 10); - docsSearchService.indexer - .waitForDocCompleted(docId, abortController.signal) - .then(() => { - setReferencesLoading(false); - }) - .catch(err => { - if (err !== MANUALLY_STOP) { - console.error(err); - } - }); - return () => { - undoSync(); - undoIndexer(); - abortController.abort(MANUALLY_STOP); - }; - }, [docId, docsSearchService, workspaceService, collapsed]); - - const workspaceDialogService = useService(WorkspaceDialogService); - const option = useMemo( - () => ({ - openInfoModal: () => workspaceDialogService.open('doc-info', { docId }), - openNodeCollapsed: () => setCollapsed(false), - }), - [docId, setCollapsed, workspaceDialogService] + const openNodeCollapsed = useCallback( + () => setCollapsed(false), + [setCollapsed] ); - const operations = useNavigationPanelDocNodeOperationsMenu(docId, option); - const { handleAddLinkedPage } = useNavigationPanelDocNodeOperations( + const handleAddLinkedPage = useNavigationPanelDocNodeAddLinkedPage( docId, - option + openNodeCollapsed + ); + const menuTarget = useMemo( + () => ( + + ), + [additionalOperations, docId, handleAddLinkedPage] ); - const finalOperations = useMemo(() => { - if (additionalOperations) { - return [...operations, ...additionalOperations]; - } - return operations; - }, [additionalOperations, operations]); - - if (isInTrash || !docRecord) { + if (entry?.trash !== false) { return null; } return ( ) } - operations={finalOperations} + menuTarget={menuTarget} data-testid={`navigation-panel-doc-${docId}`} > - - {canRead => - canRead - ? children?.map((child, index) => ( - - )) - : null - } - - - {canEdit => - canEdit ? ( - ( + - ) : null - } - + )) + : null} + {entry.canUpdate ? ( + + ) : null} ); }; diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/doc/operations.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/doc/operations.tsx index 52b3ad0cee..60fdd7a530 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/doc/operations.tsx @@ -29,35 +29,42 @@ import { useLiveData, useService, useServices } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; import { DocFrameScope, DocInfoSheet } from '../../../doc-info'; +import { MobileNavigationMenuItems } from '../../menu-host'; import { DocRenameSubMenu } from './dialog'; -export const useNavigationPanelDocNodeOperations = ( +export const useNavigationPanelDocNodeAddLinkedPage = ( docId: string, - options: { - openNodeCollapsed: () => void; - } + openNodeCollapsed: () => void ) => { - const t = useI18n(); - const { - workbenchService, - workspaceService, - docsService, - compatibleFavoriteItemsAdapter, - } = useServices({ + const { docsService, workspaceService } = useServices({ DocsService, - WorkbenchService, WorkspaceService, - CompatibleFavoriteItemsAdapter, }); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + return useAsyncCallback(async () => { + const newDoc = createPage(); + await docsService.addLinkedDoc(docId, newDoc.id); + track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' }); + track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' }); + openNodeCollapsed(); + }, [createPage, docId, docsService, openNodeCollapsed]); +}; + +export const useNavigationPanelDocNodeOperations = (docId: string) => { + const t = useI18n(); + const { workbenchService, docsService, compatibleFavoriteItemsAdapter } = + useServices({ + DocsService, + WorkbenchService, + CompatibleFavoriteItemsAdapter, + }); const { openConfirmModal } = useConfirmModal(); const docRecord = useLiveData(docsService.list.doc$(docId)); - const { createPage } = usePageHelper( - workspaceService.workspace.docCollection - ); - const favorite = useLiveData( useMemo(() => { return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc'); @@ -112,15 +119,6 @@ export const useNavigationPanelDocNodeOperations = ( }); }, [docId, workbenchService.workbench]); - const handleAddLinkedPage = useAsyncCallback(async () => { - const newDoc = createPage(); - // TODO: handle timeout & error - await docsService.addLinkedDoc(docId, newDoc.id); - track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' }); - track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' }); - options.openNodeCollapsed(); - }, [createPage, docId, docsService, options]); - const handleToggleFavoriteDoc = useCallback(() => { compatibleFavoriteItemsAdapter.toggle(docId, 'doc'); track.$.navigationPanel.organize.toggleFavorite({ @@ -139,7 +137,6 @@ export const useNavigationPanelDocNodeOperations = ( return useMemo( () => ({ favorite, - handleAddLinkedPage, handleDuplicate, handleToggleFavoriteDoc, handleOpenInSplitView, @@ -149,7 +146,6 @@ export const useNavigationPanelDocNodeOperations = ( }), [ favorite, - handleAddLinkedPage, handleDuplicate, handleMoveToTrash, handleOpenInNewTab, @@ -163,26 +159,28 @@ export const useNavigationPanelDocNodeOperations = ( export const useNavigationPanelDocNodeOperationsMenu = ( docId: string, options: { - openInfoModal: () => void; - openNodeCollapsed: () => void; + handleAddLinkedPage: () => void; } -): NodeOperation[] => { +): { + operations: NodeOperation[]; + handleAddLinkedPage: () => void; +} => { const t = useI18n(); + const { handleAddLinkedPage } = options; const { favorite, - handleAddLinkedPage, handleDuplicate, handleToggleFavoriteDoc, handleOpenInNewTab, handleMoveToTrash, handleRename, - } = useNavigationPanelDocNodeOperations(docId, options); + } = useNavigationPanelDocNodeOperations(docId); const docService = useService(DocsService); const docRecord = useLiveData(docService.list.doc$(docId)); const title = useLiveData(docRecord?.title$); - return useMemo( + const operations = useMemo( () => [ { index: 10, @@ -301,4 +299,28 @@ export const useNavigationPanelDocNodeOperationsMenu = ( title, ] ); + + return useMemo( + () => ({ operations, handleAddLinkedPage }), + [handleAddLinkedPage, operations] + ); +}; + +export const NavigationPanelDocNodeMenu = ({ + docId, + handleAddLinkedPage, + additionalOperations, +}: { + docId: string; + handleAddLinkedPage: () => void; + additionalOperations?: NodeOperation[]; +}) => { + const { operations } = useNavigationPanelDocNodeOperationsMenu(docId, { + handleAddLinkedPage, + }); + const allOperations = useMemo( + () => [...operations, ...(additionalOperations ?? [])], + [additionalOperations, operations] + ); + return ; }; diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/folder/index.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/folder/index.tsx index 7d70af5aa5..68bf2be330 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/folder/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/folder/index.tsx @@ -1,6 +1,5 @@ import { AnimatedFolderIcon, - IconButton, MenuItem, MenuSeparator, MenuSub, @@ -12,7 +11,6 @@ import type { NodeOperation, } from '@affine/core/desktop/components/navigation-panel'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; -import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; import { @@ -27,16 +25,16 @@ import { FolderIcon, LayerIcon, PageIcon, - PlusIcon, PlusThickIcon, RemoveFolderIcon, TagsIcon, } from '@blocksuite/icons/rc'; import { useLiveData, useService, useServices } from '@toeverything/infra'; import { difference } from 'lodash-es'; -import { useCallback, useMemo } from 'react'; +import { type ReactNode, useCallback, useMemo } from 'react'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { MobileNavigationMenuItems } from '../../menu-host'; import { NavigationPanelTreeNode } from '../../tree/node'; import { NavigationPanelCollectionNode } from '../collection'; import { NavigationPanelDocNode } from '../doc'; @@ -129,169 +127,39 @@ const NavigationPanelFolderIcon: NavigationPanelTreeNodeIcon = ({ /> ); -const NavigationPanelFolderNodeFolder = ({ - node, - operations: additionalOperations, - parentPath, +const NavigationPanelFolderMenu = ({ + nodeId, + name, + handleDelete, + handleRename, + handleCreateSubfolder, + handleAddToFolder, + createSubTipRenderer, + additionalOperations, }: { - node: FolderNode; - operations?: NodeOperation[]; - parentPath: string[]; + nodeId: string; + name: string; + handleDelete: () => void; + handleRename: (name: string) => void; + handleCreateSubfolder: (name: string) => void; + handleAddToFolder: (type: 'doc' | 'collection' | 'tag') => void; + createSubTipRenderer: (props: { input: string }) => ReactNode; + additionalOperations?: NodeOperation[]; }) => { const t = useI18n(); - const { workspaceService, featureFlagService, workspaceDialogService } = - useServices({ - WorkspaceService, - CompatibleFavoriteItemsAdapter, - FeatureFlagService, - WorkspaceDialogService, - }); - const name = useLiveData(node.name$); - const enableEmojiIcon = useLiveData( - featureFlagService.flags.enable_emoji_folder_icon.$ - ); - const navigationPanelService = useService(NavigationPanelService); - const path = useMemo( - () => [...parentPath, `folder-${node.id}`], - [parentPath, node.id] - ); - const collapsed = useLiveData(navigationPanelService.collapsed$(path)); - const setCollapsed = useCallback( - (value: boolean) => { - navigationPanelService.setCollapsed(path, value); - }, - [navigationPanelService, path] - ); - - const { createPage } = usePageHelper( - workspaceService.workspace.docCollection - ); - const handleDelete = useCallback(() => { - node.delete(); - track.$.navigationPanel.organize.deleteOrganizeItem({ - type: 'folder', - }); - notify.success({ - title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({ - name, - }), - message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](), - }); - }, [name, node, t]); - - const children = useLiveData(node.sortedChildren$); - - const handleRename = useCallback( - (newName: string) => { - node.rename(newName); - }, - [node] - ); - - const handleNewDoc = useCallback(() => { - const newDoc = createPage(); - node.createLink('doc', newDoc.id, node.indexAt('before')); - track.$.navigationPanel.folders.createDoc(); - track.$.navigationPanel.organize.createOrganizeItem({ - type: 'link', - target: 'doc', - }); - setCollapsed(false); - }, [createPage, node, setCollapsed]); - - const handleCreateSubfolder = useCallback( - (name: string) => { - node.createFolder(name, node.indexAt('before')); - track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); - setCollapsed(false); - }, - [node, setCollapsed] - ); - - const handleAddToFolder = useCallback( - (type: 'doc' | 'collection' | 'tag') => { - const initialIds = children - .filter(node => node.type$.value === type) - .map(node => node.data$.value) - .filter(Boolean) as string[]; - const selector = - type === 'doc' - ? 'doc-selector' - : type === 'collection' - ? 'collection-selector' - : 'tag-selector'; - workspaceDialogService.open( - selector, - { - init: initialIds, - }, - selectedIds => { - if (selectedIds === undefined) { - return; - } - const newItemIds = difference(selectedIds, initialIds); - const removedItemIds = difference(initialIds, selectedIds); - const removedItems = children.filter( - node => - !!node.data$.value && removedItemIds.includes(node.data$.value) - ); - - newItemIds.forEach(id => { - node.createLink(type, id, node.indexAt('after')); - }); - removedItems.forEach(node => node.delete()); - const updated = newItemIds.length + removedItems.length; - updated && setCollapsed(false); - } - ); - track.$.navigationPanel.organize.createOrganizeItem({ - type: 'link', - target: type, - }); - }, - [children, node, setCollapsed, workspaceDialogService] - ); - - const createSubTipRenderer = useCallback( - ({ input }: { input: string }) => { - return ; - }, - [name] - ); - - const folderOperations = useMemo(() => { - return [ - { - index: 0, - inline: true, - view: ( - - - - ), - }, + const operations = useMemo( + () => [ { index: 98, view: ( ), }, - { - index: 99, - view: , - }, + { index: 99, view: }, { index: 100, view: ( @@ -315,9 +183,7 @@ const NavigationPanelFolderNodeFolder = ({ index: 102, view: ( , - }} + triggerOptions={{ prefixIcon: }} items={ <> handleAddToFolder('tag')} prefixIcon={} + onClick={() => handleAddToFolder('tag')} > {t['com.affine.rootAppSidebar.organize.folder.add-tags']()} handleAddToFolder('collection')} prefixIcon={} + onClick={() => handleAddToFolder('collection')} > {t[ 'com.affine.rootAppSidebar.organize.folder.add-collections' @@ -347,21 +213,16 @@ const NavigationPanelFolderNodeFolder = ({ ), }, - { index: 200, - view: node.id ? : null, - }, - - { - index: 9999, - view: , + view: nodeId ? : null, }, + { index: 9999, view: }, { index: 10000, view: ( } onClick={handleDelete} > @@ -369,25 +230,182 @@ const NavigationPanelFolderNodeFolder = ({ ), }, - ]; - }, [ - createSubTipRenderer, - handleAddToFolder, - handleCreateSubfolder, - handleDelete, - handleNewDoc, - handleRename, - name, - node.id, - t, - ]); + ], + [ + createSubTipRenderer, + handleAddToFolder, + handleCreateSubfolder, + handleDelete, + handleRename, + name, + nodeId, + t, + ] + ); + return ( + + ); +}; - const finalOperations = useMemo(() => { - if (additionalOperations) { - return [...additionalOperations, ...folderOperations]; - } - return folderOperations; - }, [additionalOperations, folderOperations]); +export const NavigationPanelFolderNodeMenu = ({ + nodeId, + additionalOperations, +}: { + nodeId: string; + additionalOperations?: NodeOperation[]; +}) => { + const t = useI18n(); + const { organizeService, workspaceDialogService } = useServices({ + OrganizeService, + WorkspaceDialogService, + }); + const node = useLiveData(organizeService.folderTree.folderNode$(nodeId)); + const name = useLiveData(node?.name$) ?? ''; + const children = useLiveData(node?.sortedChildren$); + + const handleDelete = useCallback(() => { + if (!node) return; + node.delete(); + track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'folder' }); + notify.success({ + title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({ + name, + }), + message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](), + }); + }, [name, node, t]); + const handleRename = useCallback( + (newName: string) => node?.rename(newName), + [node] + ); + const handleCreateSubfolder = useCallback( + (newName: string) => { + if (!node) return; + node.createFolder(newName, node.indexAt('before')); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + }, + [node] + ); + const handleAddToFolder = useCallback( + (type: 'doc' | 'collection' | 'tag') => { + if (!node) return; + const currentChildren = children ?? []; + const initialIds = currentChildren + .filter(child => child.type$.value === type) + .map(child => child.data$.value) + .filter(Boolean) as string[]; + const selector = + type === 'doc' + ? 'doc-selector' + : type === 'collection' + ? 'collection-selector' + : 'tag-selector'; + workspaceDialogService.open( + selector, + { init: initialIds }, + selectedIds => { + if (selectedIds === undefined) return; + const newItemIds = difference(selectedIds, initialIds); + const removedItemIds = difference(initialIds, selectedIds); + newItemIds.forEach(id => + node.createLink(type, id, node.indexAt('after')) + ); + currentChildren + .filter( + child => + !!child.data$.value && + removedItemIds.includes(child.data$.value) + ) + .forEach(child => child.delete()); + } + ); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'link', + target: type, + }); + }, + [children, node, workspaceDialogService] + ); + const createSubTipRenderer = useCallback( + ({ input }: { input: string }) => ( + + ), + [name] + ); + + if (!node) return null; + return ( + + ); +}; + +const NavigationPanelFolderNodeFolder = ({ + node, + operations: additionalOperations, + parentPath, +}: { + node: FolderNode; + operations?: NodeOperation[]; + parentPath: string[]; +}) => { + const t = useI18n(); + const { workspaceService, featureFlagService } = useServices({ + WorkspaceService, + FeatureFlagService, + }); + const name = useLiveData(node.name$); + const enableEmojiIcon = useLiveData( + featureFlagService.flags.enable_emoji_folder_icon.$ + ); + const navigationPanelService = useService(NavigationPanelService); + const path = useMemo( + () => [...parentPath, `folder-${node.id}`], + [parentPath, node.id] + ); + const collapsed = useLiveData(navigationPanelService.collapsed$(path)); + const setCollapsed = useCallback( + (value: boolean) => { + navigationPanelService.setCollapsed(path, value); + }, + [navigationPanelService, path] + ); + + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + const children = useLiveData(node.sortedChildren$); + + const handleNewDoc = useCallback(() => { + const newDoc = createPage(); + node.createLink('doc', newDoc.id, node.indexAt('before')); + track.$.navigationPanel.folders.createDoc(); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'link', + target: 'doc', + }); + setCollapsed(false); + }, [createPage, node, setCollapsed]); + + const menuTarget = useMemo( + () => ( + + ), + [additionalOperations, node.id] + ); const childrenOperations = useCallback( (type: string, node: FolderNode) => { @@ -432,7 +450,7 @@ const NavigationPanelFolderNodeFolder = ({ extractEmojiAsIcon={enableEmojiIcon} collapsed={collapsed} setCollapsed={handleCollapsedChange} - operations={finalOperations} + menuTarget={menuTarget} data-testid={`navigation-panel-folder-${node.id}`} aria-label={name} data-role="navigation-panel-folder" diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/tag/index.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/tag/index.tsx index ee65ca28ee..8cae44cdea 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/tag/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/tag/index.tsx @@ -12,8 +12,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { NavigationPanelTreeNode } from '../../tree/node'; import { NavigationPanelDocNode } from '../doc'; import { - useNavigationPanelTagNodeOperations, - useNavigationPanelTagNodeOperationsMenu, + NavigationPanelTagNodeMenu, + useNavigationPanelTagNodeNewDoc, } from './operations'; import * as styles from './styles.css'; @@ -67,21 +67,24 @@ export const NavigationPanelTagNode = ({ [tagColor] ); - const option = useMemo( - () => ({ - openNodeCollapsed: () => setCollapsed(false), - }), + const openNodeCollapsed = useCallback( + () => setCollapsed(false), [setCollapsed] ); - const operations = useNavigationPanelTagNodeOperationsMenu(tagId, option); - const { handleNewDoc } = useNavigationPanelTagNodeOperations(tagId, option); - - const finalOperations = useMemo(() => { - if (additionalOperations) { - return [...operations, ...additionalOperations]; - } - return operations; - }, [additionalOperations, operations]); + const handleNewDoc = useNavigationPanelTagNodeNewDoc( + tagId, + openNodeCollapsed + ); + const menuTarget = useMemo( + () => ( + + ), + [additionalOperations, handleNewDoc, tagId] + ); if (!tagRecord) { return null; @@ -95,7 +98,7 @@ export const NavigationPanelTagNode = ({ setCollapsed={setCollapsed} to={`/tag/${tagId}`} active={active} - operations={finalOperations} + menuTarget={menuTarget} data-testid={`navigation-panel-tag-${tagId}`} aria-label={tagName} data-role="navigation-panel-tag" diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/tag/operations.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/tag/operations.tsx index dccb0790bc..f7ded7fa60 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/tag/operations.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/tag/operations.tsx @@ -26,27 +26,47 @@ import { import { useLiveData, useServices } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { MobileNavigationMenuItems } from '../../menu-host'; import { TagRenameSubMenu } from './dialog'; +export const useNavigationPanelTagNodeNewDoc = ( + tagId: string, + openNodeCollapsed: () => void +) => { + const { workspaceService, tagService } = useServices({ + WorkspaceService, + TagService, + }); + const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + return useCallback(() => { + if (!tagRecord) return; + const newDoc = createPage(); + tagRecord.tag(newDoc.id); + track.$.navigationPanel.tags.createDoc(); + openNodeCollapsed(); + }, [createPage, openNodeCollapsed, tagRecord]); +}; + export const useNavigationPanelTagNodeOperations = ( tagId: string, { - openNodeCollapsed, + handleNewDoc, }: { - openNodeCollapsed: () => void; + handleNewDoc: () => void; } ) => { const t = useI18n(); const { workbenchService, - workspaceService, tagService, favoriteService, workspaceDialogService, globalCacheService, } = useServices({ WorkbenchService, - WorkspaceService, TagService, DocsService, FavoriteService, @@ -60,19 +80,6 @@ export const useNavigationPanelTagNodeOperations = ( ); const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); - const { createPage } = usePageHelper( - workspaceService.workspace.docCollection - ); - - const handleNewDoc = useCallback(() => { - if (tagRecord) { - const newDoc = createPage(); - tagRecord?.tag(newDoc.id); - track.$.navigationPanel.tags.createDoc(); - openNodeCollapsed(); - } - }, [createPage, openNodeCollapsed, tagRecord]); - const handleMoveToTrash = useCallback(() => { tagService.tagList.deleteTag(tagId); track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' }); @@ -214,7 +221,7 @@ export const useNavigationPanelTagNodeOperations = ( export const useNavigationPanelTagNodeOperationsMenu = ( tagId: string, option: { - openNodeCollapsed: () => void; + handleNewDoc: () => void; } ): NodeOperation[] => { const t = useI18n(); @@ -319,3 +326,22 @@ export const useNavigationPanelTagNodeOperationsMenu = ( ] ); }; + +export const NavigationPanelTagNodeMenu = ({ + tagId, + handleNewDoc, + additionalOperations, +}: { + tagId: string; + handleNewDoc: () => void; + additionalOperations?: NodeOperation[]; +}) => { + const operations = useNavigationPanelTagNodeOperationsMenu(tagId, { + handleNewDoc, + }); + const allOperations = useMemo( + () => [...operations, ...(additionalOperations ?? [])], + [additionalOperations, operations] + ); + return ; +}; diff --git a/packages/frontend/core/src/mobile/components/navigation/tree/node.tsx b/packages/frontend/core/src/mobile/components/navigation/tree/node.tsx index c60e91504e..7514f1c6b5 100644 --- a/packages/frontend/core/src/mobile/components/navigation/tree/node.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/tree/node.tsx @@ -1,4 +1,3 @@ -import { MobileMenu } from '@affine/component'; import { type BaseNavigationPanelTreeNodeProps, NavigationPanelTreeContext, @@ -6,21 +5,32 @@ import { import { WorkbenchLink } from '@affine/core/modules/workbench'; import { extractEmojiIcon } from '@affine/core/utils'; import { ArrowDownSmallIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc'; -import * as Collapsible from '@radix-ui/react-collapsible'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { - Fragment, + type AriaAttributes, + type AriaRole, + type ReactNode, useCallback, useContext, useMemo, - useRef, useState, } from 'react'; import { SwipeMenu } from '../../swipe-menu'; +import { + MobileNavigationMenuItems, + useMobileNavigationMenuHost, +} from '../menu-host'; import * as styles from './node.css'; -interface NavigationPanelTreeNodeProps extends BaseNavigationPanelTreeNodeProps {} +interface NavigationPanelTreeNodeProps extends BaseNavigationPanelTreeNodeProps { + menuTarget?: ReactNode; + role?: AriaRole; + 'aria-label'?: string; + 'aria-level'?: number; + 'aria-expanded'?: AriaAttributes['aria-expanded']; + 'data-role'?: string; +} const EMPTY_OPERATIONS: BaseNavigationPanelTreeNodeProps['operations'] = []; @@ -39,6 +49,7 @@ export const NavigationPanelTreeNode = ({ postfix, childrenOperations = EMPTY_OPERATIONS, childrenPlaceholder, + menuTarget, linkComponent: LinkComponent = WorkbenchLink, ...otherProps }: NavigationPanelTreeNodeProps) => { @@ -47,8 +58,7 @@ export const NavigationPanelTreeNode = ({ // If no onClick or to is provided, clicking on the node will toggle the collapse state const clickForCollapse = !onClick && !to && !disabled; const [childCount, setChildCount] = useState(0); - const rootRef = useRef(null); - const [menuOpen, setMenuOpen] = useState(false); + const menuHost = useMobileNavigationMenuHost(); const { emoji, name } = useMemo(() => { if (!extractEmojiAsIcon || !rawName) { @@ -71,6 +81,13 @@ export const NavigationPanelTreeNode = ({ inlineOperations: sorted.filter(({ inline }) => !!inline), }; }, [operations]); + const openMenu = useCallback(() => { + if (menuTarget || menuOperations.length > 0) { + menuHost.open( + menuTarget ?? + ); + } + }, [menuHost, menuOperations, menuTarget]); const contextValue = useMemo(() => { return { @@ -114,22 +131,17 @@ export const NavigationPanelTreeNode = ({ data-disabled={disabled} >
- {menuOperations.length > 0 ? ( + {menuTarget || menuOperations.length > 0 ? (
{ // prevent jump to page e.preventDefault(); + openMenu(); }} + className={styles.iconContainer} + data-testid="menu-trigger" > - ( - {view} - ))} - > -
- {emoji ?? (Icon && )} -
-
+ {emoji ?? (Icon && )}
) : (
@@ -157,29 +169,23 @@ export const NavigationPanelTreeNode = ({ ); return ( - setMenuOpen(true), [])} + onExecute={openMenu} menu={ - ({ open: menuOpen, onOpenChange: setMenuOpen }), - [menuOpen] - )} - items={menuOperations.map(({ view, index }) => ( - {view} - ))} + } >
@@ -196,15 +202,17 @@ export const NavigationPanelTreeNode = ({ )}
- - {/* For lastInGroup check, the placeholder must be placed above all children in the dom */} -
- {childCount === 0 && !collapsed && childrenPlaceholder} + {collapsed ? null : ( +
+ {/* For lastInGroup check, the placeholder must be placed above all children in the dom */} +
+ {childCount === 0 && childrenPlaceholder} +
+ + {children} +
- - {collapsed ? null : children} - - - + )} +
); }; diff --git a/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/index.tsx b/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/index.tsx new file mode 100644 index 0000000000..b8bff42f3e --- /dev/null +++ b/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/index.tsx @@ -0,0 +1,927 @@ +import { MenuItem, SafeArea, usePromptModal } from '@affine/component'; +import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils'; +import { NavigationPanelTreeRoot } from '@affine/core/desktop/components/navigation-panel'; +import { CollectionService } from '@affine/core/modules/collection'; +import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; +import { FavoriteService } from '@affine/core/modules/favorite'; +import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; +import { OrganizeService } from '@affine/core/modules/organize'; +import { TagService } from '@affine/core/modules/tag'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { + AddCollectionIcon, + AddOrganizeIcon, + AddTagIcon, + FolderIcon, + PageIcon, + RemoveFolderIcon, + ToggleRightIcon, + ViewLayersIcon, +} from '@blocksuite/icons/rc'; +import { useLiveData, useService, useServices } from '@toeverything/infra'; +import { + forwardRef, + type HTMLAttributes, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + type Components, + type ListRange, + Virtuoso, + type VirtuosoHandle, +} from 'react-virtuoso'; + +import { MobileBackCoordinator } from '../../../modules/back-coordinator'; +import { + MobileNavigationProjection, + type MobileNavigationRow, + MobileShellDataProjection, + type MobileShellNavigationEntry, +} from '../../../modules/navigation-projection'; +import { HomeHeader } from '../../../views/home-header'; +import { RecentDocs } from '../../../views/recent-docs'; +import { AddItemPlaceholder } from '../layouts/add-item-placeholder'; +import { + NavigationPanelCollectionNodeMenu, + useNavigationPanelCollectionAddDoc, +} from '../nodes/collection/operations'; +import { + NavigationPanelDocNodeMenu, + useNavigationPanelDocNodeAddLinkedPage, +} from '../nodes/doc/operations'; +import { NavigationPanelFolderNodeMenu } from '../nodes/folder'; +import { FolderCreateTip, FolderRenameDialog } from '../nodes/folder/dialog'; +import { TagRenameDialog } from '../nodes/tag/dialog'; +import { + NavigationPanelTagNodeMenu, + useNavigationPanelTagNodeNewDoc, +} from '../nodes/tag/operations'; +import { NavigationPanelTreeNode } from '../tree/node'; +import * as styles from './styles.css'; + +const sectionTitles = { + favorites: 'com.affine.rootAppSidebar.favorites', + organize: 'com.affine.rootAppSidebar.organize', + collections: 'com.affine.rootAppSidebar.collections', + tags: 'com.affine.rootAppSidebar.tags', +} as const; + +const Header = () => ( + <> + + + +); + +const Footer = () => ; + +const List = forwardRef>( + function NavigationList(props, ref) { + return
; + } +); + +const virtuosoComponents: Components = { + Footer, + Header, + List, +}; + +const afterNextPaint = (callback: () => void) => { + let nextFrame = 0; + const frame = requestAnimationFrame(() => { + nextFrame = requestAnimationFrame(callback); + }); + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(nextFrame); + }; +}; + +const rowPath = (rowId: string) => + rowId.split('/').map(segment => { + const decoded = decodeURIComponent(segment); + const separator = decoded.indexOf(':'); + return separator === -1 + ? decoded + : `${decoded.slice(0, separator)}-${decoded.slice(separator + 1)}`; + }); + +const rowIndent = (depth: number) => Math.max(0, depth - 1) * 20; + +const RowIcon = ({ entry }: { entry: MobileShellNavigationEntry }) => { + if (entry.kind === 'collection') return ; + if (entry.kind === 'folder') return ; + if (entry.kind === 'tag') { + return ( + + ); + } + return ; +}; + +const EntityRowShell = ({ + row, + entry, + menuTarget, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + menuTarget?: ReactNode; + toggle: () => void; +}) => { + const to = + entry.kind === 'doc' + ? `/${entry.id}` + : entry.kind === 'collection' + ? `/collection/${entry.id}` + : entry.kind === 'tag' + ? `/tag/${entry.id}` + : undefined; + const Icon = useCallback(() => , [entry]); + return ( +
+ + + +
+ ); +}; + +const DocRow = ({ + row, + entry, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + toggle: () => void; +}) => { + const expand = useCallback(() => { + if (!row.expanded) toggle(); + }, [row.expanded, toggle]); + const handleAddLinkedPage = useNavigationPanelDocNodeAddLinkedPage( + entry.id, + expand + ); + const menuTarget = useMemo( + () => ( + + ), + }, + ] + : undefined + } + /> + ), + [entry.id, handleAddLinkedPage, row.relationId] + ); + return ( + + ); +}; + +const CollectionRow = ({ + row, + entry, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + toggle: () => void; +}) => { + const { collectionService, workspaceDialogService } = useServices({ + CollectionService, + WorkspaceDialogService, + }); + const expand = useCallback(() => { + if (!row.expanded) toggle(); + }, [row.expanded, toggle]); + const handleAdd = useNavigationPanelCollectionAddDoc(entry.id, expand); + const handleEdit = useCallback(() => { + const collection = collectionService.collection$(entry.id).value; + if (collection) { + workspaceDialogService.open('collection-editor', { + collectionId: entry.id, + }); + } + }, [collectionService, entry.id, workspaceDialogService]); + const menuTarget = useMemo( + () => ( + + ), + }, + ] + : undefined + } + /> + ), + [entry.id, handleAdd, handleEdit, row.relationId] + ); + return ( + + ); +}; + +const TagRow = ({ + row, + entry, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + toggle: () => void; +}) => { + const expand = useCallback(() => { + if (!row.expanded) toggle(); + }, [row.expanded, toggle]); + const handleNewDoc = useNavigationPanelTagNodeNewDoc(entry.id, expand); + const menuTarget = useMemo( + () => ( + + ), + }, + ] + : undefined + } + /> + ), + [entry.id, handleNewDoc, row.relationId] + ); + return ( + + ); +}; + +const RemoveFromFolderMenuItem = ({ relationId }: { relationId: string }) => { + const t = useI18n(); + const organizeService = useService(OrganizeService); + return ( + } + onClick={() => + organizeService.folderTree.folderNode$(relationId).value?.delete() + } + > + {t['com.affine.rootAppSidebar.organize.delete-from-folder']()} + + ); +}; + +const useFolderNewDoc = (folderId: string, onCreated?: () => void) => { + const { organizeService, workspaceService } = useServices({ + OrganizeService, + WorkspaceService, + }); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + return useCallback(() => { + const folder = organizeService.folderTree.folderNode$(folderId).value; + if (!folder) return; + const doc = createPage(); + folder.createLink('doc', doc.id, folder.indexAt('before')); + track.$.navigationPanel.folders.createDoc(); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'link', + target: 'doc', + }); + onCreated?.(); + }, [createPage, folderId, onCreated, organizeService.folderTree]); +}; + +const FolderRow = ({ + row, + entry, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + toggle: () => void; +}) => { + const t = useI18n(); + const expand = useCallback(() => { + if (!row.expanded) toggle(); + }, [row.expanded, toggle]); + const handleNewDoc = useFolderNewDoc(entry.id, expand); + const menuTarget = useMemo( + () => ( + } onClick={handleNewDoc}> + {t['com.affine.rootAppSidebar.organize.folder.new-doc']()} + + ), + }, + ]} + /> + ), + [entry.id, handleNewDoc, t] + ); + return ( + + ); +}; + +const EntityRow = ({ + row, + entry, + toggle, +}: { + row: MobileNavigationRow; + entry: MobileShellNavigationEntry; + toggle: () => void; +}) => { + if (entry.kind === 'doc') { + return ; + } + if (entry.kind === 'collection') { + return ; + } + if (entry.kind === 'tag') { + return ; + } + return ; +}; + +const FolderNewDocAction = ({ row }: { row: MobileNavigationRow }) => { + const t = useI18n(); + const handleCreate = useFolderNewDoc(row.entityId ?? ''); + return ( +
+ +
+ ); +}; + +const DocNewLinkedActionEnabled = ({ + row, + docId, +}: { + row: MobileNavigationRow; + docId: string; +}) => { + const t = useI18n(); + const keepExpanded = useCallback(() => {}, []); + const handleCreate = useNavigationPanelDocNodeAddLinkedPage( + docId, + keepExpanded + ); + return ( +
+ +
+ ); +}; + +const DocNewLinkedAction = ({ row }: { row: MobileNavigationRow }) => { + const dataProjection = useService(MobileShellDataProjection); + const docId = row.entityId ?? ''; + const entry = useLiveData(dataProjection.navigationEntry$('doc', docId)); + return entry?.canUpdate === true ? ( + + ) : null; +}; + +const CollectionNewDocAction = ({ row }: { row: MobileNavigationRow }) => { + const t = useI18n(); + const keepExpanded = useCallback(() => {}, []); + const handleCreate = useNavigationPanelCollectionAddDoc( + row.entityId ?? '', + keepExpanded + ); + return ( +
+ +
+ ); +}; + +const TagNewDocAction = ({ row }: { row: MobileNavigationRow }) => { + const t = useI18n(); + const keepExpanded = useCallback(() => {}, []); + const handleCreate = useNavigationPanelTagNodeNewDoc( + row.entityId ?? '', + keepExpanded + ); + return ( +
+ +
+ ); +}; + +const ProjectedEntityRow = ({ + row, + toggle, +}: { + row: MobileNavigationRow; + toggle: () => void; +}) => { + const dataProjection = useService(MobileShellDataProjection); + const entry = useLiveData( + dataProjection.navigationEntry$( + row.kind as MobileShellNavigationEntry['kind'], + row.entityId ?? '' + ) + ); + const t = useI18n(); + if (!entry) return null; + if (entry.kind === 'doc' && entry.canRead !== true) { + return ( +
+ {entry.canRead === false + ? t['com.affine.no-permission']() + : t['Loading']()} +
+ ); + } + return ; +}; + +const FavoritesAction = () => { + const t = useI18n(); + const { favoriteService, workspaceService } = useServices({ + FavoriteService, + WorkspaceService, + }); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + const handleCreate = useCallback(() => { + const doc = createPage(); + favoriteService.favoriteList.add( + 'doc', + doc.id, + favoriteService.favoriteList.indexAt('before') + ); + }, [createPage, favoriteService.favoriteList]); + return ( + + ); +}; + +const OrganizeAction = () => { + const t = useI18n(); + const organizeService = useService(OrganizeService); + const [open, setOpen] = useState(false); + const root = organizeService.folderTree.rootFolder; + const handleCreate = useCallback( + (name: string) => { + const id = root.createFolder(name, root.indexAt('before')); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + return id; + }, + [root] + ); + return ( + <> + } + data-testid="navigation-panel-bar-add-organize-button" + label={t['com.affine.rootAppSidebar.organize.add-folder']()} + onClick={() => setOpen(true)} + /> + + + ); +}; + +const CollectionsAction = () => { + const t = useI18n(); + const { collectionService, workbenchService } = useServices({ + CollectionService, + WorkbenchService, + }); + const { openPromptModal } = usePromptModal(); + const handleCreate = useCallback(() => { + openPromptModal({ + title: t['com.affine.editCollection.saveCollection'](), + label: t['com.affine.editCollectionName.name'](), + inputOptions: { + placeholder: t['com.affine.editCollectionName.name.placeholder'](), + }, + confirmText: t['com.affine.editCollection.save'](), + cancelText: t['com.affine.editCollection.button.cancel'](), + onConfirm(name) { + const id = collectionService.createCollection({ name }); + workbenchService.workbench.openCollection(id); + }, + }); + }, [collectionService, openPromptModal, t, workbenchService.workbench]); + return ( + } + data-testid="navigation-panel-bar-add-collection-button" + label={t['com.affine.rootAppSidebar.collection.new']()} + onClick={handleCreate} + /> + ); +}; + +const TagsAction = () => { + const t = useI18n(); + const tagService = useService(TagService); + const [open, setOpen] = useState(false); + const handleCreate = useCallback( + (name: string, color: string) => { + tagService.tagList.createTag(name, color); + setOpen(false); + }, + [tagService.tagList] + ); + return ( + <> + } + data-testid="navigation-panel-add-tag-button" + label={t[ + 'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip' + ]()} + onClick={() => setOpen(true)} + /> + + + ); +}; + +const SectionAction = ({ section }: { section: string }) => { + if (section === 'favorites') return ; + if (section === 'organize') return ; + if (section === 'collections') return ; + if (section === 'tags') return ; + return null; +}; + +const NavigationAction = ({ row }: { row: MobileNavigationRow }) => { + if (row.action === 'section') { + return ( +
+ +
+ ); + } + if (row.action === 'folder-new-doc') return ; + if (row.action === 'doc-new-linked') return ; + if (row.action === 'collection-new-doc') { + return ; + } + if (row.action === 'tag-new-doc') return ; + return null; +}; + +export const MobileNavigationVirtualScroller = () => { + const t = useI18n(); + const dataProjection = useService(MobileShellDataProjection); + const navigationPanelService = useService(NavigationPanelService); + const backCoordinator = useService(MobileBackCoordinator); + const sections = useLiveData(dataProjection.navigationSections$); + const projection = useMemo(() => new MobileNavigationProjection(), []); + const restoredSnapshot = useMemo( + () => backCoordinator.snapshot('home'), + [backCoordinator] + ); + const [expanded, setExpanded] = useState>(() => { + if (restoredSnapshot) return new Set(restoredSnapshot.expanded); + const initial = new Set(); + let previousSize = -1; + while (previousSize !== initial.size) { + previousSize = initial.size; + for (const row of projection.flatten(sections, initial)) { + if ( + row.expandable && + !navigationPanelService.collapsed$(rowPath(row.id)).value + ) { + initial.add(row.id); + } + } + } + return initial; + }); + const listRef = useRef(null); + const restoredAnchor = useRef(false); + const restoredFocus = useRef(false); + const [visibleRange, setVisibleRange] = useState({ + startIndex: 0, + endIndex: 30, + }); + const rows = useMemo( + () => projection.flatten(sections, expanded), + [expanded, projection, sections] + ); + + const hydratedRows = useRef(new Set()); + useEffect(() => { + if (restoredSnapshot) return; + setExpanded(current => { + const next = new Set(current); + let changed = false; + for (const row of rows) { + if (!row.expandable || hydratedRows.current.has(row.id)) continue; + hydratedRows.current.add(row.id); + if (!navigationPanelService.collapsed$(rowPath(row.id)).value) { + changed = !next.has(row.id) || changed; + next.add(row.id); + } + } + return changed ? next : current; + }); + }, [navigationPanelService, restoredSnapshot, rows]); + + useEffect(() => { + const cancel = afterNextPaint(() => dataProjection.visible$.next(true)); + return () => { + cancel(); + dataProjection.setExpandedVisibleDocIds([]); + dataProjection.visible$.next(false); + }; + }, [dataProjection]); + + useEffect(() => { + return afterNextPaint(() => { + const visibleExpandedDocs = rows + .slice(visibleRange.startIndex, visibleRange.endIndex + 1) + .filter( + row => + row.kind === 'doc' && row.expanded && row.entityId !== undefined + ) + .map(row => row.entityId as string); + dataProjection.setExpandedVisibleDocIds(visibleExpandedDocs); + }); + }, [dataProjection, rows, visibleRange]); + + useEffect(() => { + if (restoredAnchor.current || !restoredSnapshot?.anchor) return; + const index = rows.findIndex(row => row.id === restoredSnapshot.anchor); + if (index === -1) return; + restoredAnchor.current = true; + listRef.current?.scrollToIndex({ index, align: 'start' }); + }, [restoredSnapshot?.anchor, rows]); + + useEffect(() => { + if (restoredFocus.current || !restoredSnapshot?.focus) return; + const element = document.querySelector( + `[data-navigation-row-id="${CSS.escape(restoredSnapshot.focus)}"]` + ); + if (element) { + restoredFocus.current = true; + element.focus({ preventScroll: true }); + return; + } + const index = rows.findIndex(row => row.id === restoredSnapshot.focus); + if (index !== -1) + listRef.current?.scrollToIndex({ index, align: 'center' }); + }, [restoredSnapshot?.focus, rows, visibleRange]); + + const saveSnapshot = useCallback(() => { + listRef.current?.getState(listState => { + const anchor = rows[visibleRange.startIndex]?.id; + backCoordinator.setSnapshot('home', { + anchor, + expanded: [...expanded], + focus: document.activeElement?.closest( + '[data-navigation-row-id]' + )?.dataset.navigationRowId, + listState, + }); + }); + }, [backCoordinator, expanded, rows, visibleRange.startIndex]); + + useEffect(() => afterNextPaint(saveSnapshot), [saveSnapshot]); + + const toggle = useCallback( + (row: MobileNavigationRow) => { + if (!row.expandable) return; + setExpanded(current => { + const next = new Set(current); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }); + navigationPanelService.setCollapsed(rowPath(row.id), !!row.expanded); + }, + [navigationPanelService] + ); + + return ( +
+ row.id} + rangeChanged={setVisibleRange} + onFocusCapture={() => { + afterNextPaint(saveSnapshot); + }} + components={virtuosoComponents} + itemContent={(_, row) => { + if (row.kind === 'section') { + const section = row.entityId as keyof typeof sectionTitles; + return ( +
+ +
+ ); + } + if (row.kind === 'placeholder') { + return ( +
+ {row.blocked === 'denied' + ? t['com.affine.no-permission']() + : t['Loading']()} +
+ ); + } + if (row.kind === 'action') { + return ; + } + return toggle(row)} />; + }} + /> +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/styles.css.ts b/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/styles.css.ts new file mode 100644 index 0000000000..48c61a11b2 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/navigation/virtual-scroller/styles.css.ts @@ -0,0 +1,93 @@ +import { headlineRegular } from '@toeverything/theme/typography'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +import { globalVars } from '../../../styles/variables.css'; + +export const scroller = style({ + width: '100%', + height: `calc(100dvh - ${globalVars.appTabSafeArea})`, + overflowX: 'hidden', + overscrollBehavior: 'none', + touchAction: 'pan-y', +}); + +export const virtuoso = style({ + overflowX: 'hidden', + touchAction: 'pan-y', +}); + +export const list = style({ + boxSizing: 'border-box', + width: '100%', + padding: '0 8px 32px', +}); + +export const row = style({ + boxSizing: 'border-box', + width: '100%', + minHeight: 44, +}); + +export const section = style({ + display: 'flex', + alignItems: 'center', + height: 33, + padding: '0 16px 8px', + color: cssVarV2.text.primary, + selectors: { + '&:not([data-first-section="true"])': { + height: 65, + paddingTop: 32, + }, + '&[data-collapsed="true"]': { + height: 25, + paddingBottom: 0, + }, + '&[data-collapsed="true"]:not([data-first-section="true"])': { + height: 57, + }, + }, +}); + +export const sectionButton = style([ + headlineRegular, + { + display: 'flex', + alignItems: 'center', + gap: 2, + width: '100%', + border: 0, + padding: 0, + background: 'transparent', + color: 'inherit', + textAlign: 'left', + }, +]); + +export const sectionCollapseIcon = style({ + width: 16, + height: 16, + color: cssVarV2.icon.tertiary, + transform: 'translateY(1px) rotate(90deg)', + transition: 'transform 0.2s', + selectors: { + [`${section}[data-collapsed="true"] &`]: { + transform: 'translateY(1px) rotate(0deg)', + }, + }, +}); + +export const permission = style({ + minHeight: 40, + display: 'flex', + alignItems: 'center', + color: cssVarV2.text.secondary, + fontSize: 13, +}); + +export const tagIcon = style({ + width: 14, + height: 14, + borderRadius: '50%', +}); diff --git a/packages/frontend/core/src/mobile/components/page/index.tsx b/packages/frontend/core/src/mobile/components/page/index.tsx index 445b37e21a..9c225ea93d 100644 --- a/packages/frontend/core/src/mobile/components/page/index.tsx +++ b/packages/frontend/core/src/mobile/components/page/index.tsx @@ -1,6 +1,5 @@ import { type HTMLAttributes, type ReactNode, useEffect } from 'react'; -import { AppTabs } from '../app-tabs'; import * as styles from './styles.css'; interface PageProps extends HTMLAttributes { @@ -25,7 +24,6 @@ export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
{header} {children} - {tab ? : null}
); }; diff --git a/packages/frontend/core/src/mobile/components/page/styles.css.ts b/packages/frontend/core/src/mobile/components/page/styles.css.ts index 70bad2f58d..e8084c3404 100644 --- a/packages/frontend/core/src/mobile/components/page/styles.css.ts +++ b/packages/frontend/core/src/mobile/components/page/styles.css.ts @@ -1,9 +1,17 @@ import { style } from '@vanilla-extract/css'; +import { globalVars } from '../../styles/variables.css'; + export const page = style({ width: '100dvw', height: '100dvh', overflow: 'hidden', display: 'flex', flexDirection: 'column', + boxSizing: 'border-box', + selectors: { + '&[data-tab="true"]': { + paddingBottom: globalVars.appTabSafeArea, + }, + }, }); diff --git a/packages/frontend/core/src/mobile/components/rename/dialog.tsx b/packages/frontend/core/src/mobile/components/rename/dialog.tsx index 8932aae912..001d78fe71 100644 --- a/packages/frontend/core/src/mobile/components/rename/dialog.tsx +++ b/packages/frontend/core/src/mobile/components/rename/dialog.tsx @@ -31,6 +31,7 @@ export const RenameDialog = ({ return ( ( ); useEffect(() => { - setWidth(containerRef.current?.offsetWidth ?? 0); + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver(([entry]) => { + const nextWidth = entry.contentRect.width; + setWidth(current => (current === nextWidth ? current : nextWidth)); + }); + observer.observe(container); + return () => observer.disconnect(); }, []); const emitValue = useMemo(() => { diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx index b5a27f2d11..fc323602ce 100644 --- a/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx +++ b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx @@ -115,12 +115,17 @@ export const SwipeMenu = ({ let shouldExecute = false; - return swipeHelper.init(container, { + const dispose = swipeHelper.init(container, { preventScroll: true, direction: 'horizontal', onSwipeStart() { + shouldExecute = false; activeId$.next(id); }, + onTap() { + shouldExecute = false; + activeId$.next(null); + }, onSwipe({ deltaX: dragX, initialDirection }) { if (initialDirection !== 'horizontal') return; @@ -154,7 +159,27 @@ export const SwipeMenu = ({ isOpenRef.current = false; } }, + onSwipeCancel() { + shouldExecute = false; + activeId$.next(null); + animate( + content, + menu, + { deltaX: isOpenRef.current ? -normalWidth : 0, normalWidth }, + isOpenRef.current ? -normalWidth : 0 + ); + }, }); + + return () => { + dispose(); + shouldExecute = false; + if (activeId$.value === id) { + activeId$.next(null); + } + const deltaX = isOpenRef.current ? -normalWidth : 0; + tick(content, menu, { deltaX, normalWidth }); + }; }, [executeThreshold, haptics, id, normalWidth, onExecute]); return ( diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts index 2b7cf84954..46fe603a70 100644 --- a/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts +++ b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts @@ -4,6 +4,7 @@ import { style } from '@vanilla-extract/css'; export const container = style({ position: 'relative', overflow: 'hidden', + touchAction: 'pan-y', }); export const content = style({ position: 'relative', diff --git a/packages/frontend/core/src/mobile/components/workspace-selector/current-card.tsx b/packages/frontend/core/src/mobile/components/workspace-selector/current-card.tsx index 44412af34b..4f9a5a619e 100644 --- a/packages/frontend/core/src/mobile/components/workspace-selector/current-card.tsx +++ b/packages/frontend/core/src/mobile/components/workspace-selector/current-card.tsx @@ -25,6 +25,7 @@ export const CurrentWorkspaceCard = forwardRef< ref={ref} onClick={onClick} className={clsx(card, className)} + data-testid="workspace-selector-trigger" {...attrs} > {currentWorkspace ? ( diff --git a/packages/frontend/core/src/mobile/components/workspace-selector/index.tsx b/packages/frontend/core/src/mobile/components/workspace-selector/index.tsx index 8a52ae63c1..6786b0669a 100644 --- a/packages/frontend/core/src/mobile/components/workspace-selector/index.tsx +++ b/packages/frontend/core/src/mobile/components/workspace-selector/index.tsx @@ -30,7 +30,12 @@ export const WorkspaceSelector = forwardRef< // revalidate workspace list when open workspace list useEffect(() => { - if (open) workspaceManager?.list.revalidate(); + if (!open) return; + const timer = window.setTimeout( + () => workspaceManager?.list.revalidate(), + 250 + ); + return () => window.clearTimeout(timer); }, [workspaceManager, open]); return ( diff --git a/packages/frontend/core/src/mobile/components/workspace-selector/menu.css.ts b/packages/frontend/core/src/mobile/components/workspace-selector/menu.css.ts index 1ec308b10a..79e32ee413 100644 --- a/packages/frontend/core/src/mobile/components/workspace-selector/menu.css.ts +++ b/packages/frontend/core/src/mobile/components/workspace-selector/menu.css.ts @@ -9,7 +9,7 @@ import { style } from '@vanilla-extract/css'; export const root = style({ maxHeight: - 'calc(100dvh - 100px - env(safe-area-inset-bottom) - env(safe-area-inset-top))', + 'calc(100dvh - 100px - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))', display: 'flex', flexDirection: 'column', }); diff --git a/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx b/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx index a65b961c7c..b00a66c17f 100644 --- a/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx +++ b/packages/frontend/core/src/mobile/components/workspace-selector/menu.tsx @@ -13,6 +13,7 @@ import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { type WorkspaceMetadata, + WorkspaceService, WorkspacesService, } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; @@ -275,6 +276,7 @@ const AddServer = () => { }; export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => { + const currentWorkspace = useService(WorkspaceService).workspace; const workspacesService = useService(WorkspacesService); const workspaces = useLiveData(workspacesService.list.workspaces$); const serversService = useService(ServersService); @@ -314,7 +316,7 @@ export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => { } onClose?.(); }, - [onClose, jumpToPage] + [currentWorkspace.id, jumpToPage, onClose] ); return ( diff --git a/packages/frontend/core/src/mobile/dialogs/index.tsx b/packages/frontend/core/src/mobile/dialogs/index.tsx index e31db41e3a..452ccf0f71 100644 --- a/packages/frontend/core/src/mobile/dialogs/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/index.tsx @@ -15,10 +15,6 @@ import { SettingDialog } from './setting'; import { SignInDialog } from './sign-in'; const GLOBAL_DIALOGS = { - // 'create-workspace': CreateWorkspaceDialog, - // 'import-workspace': ImportWorkspaceDialog, - // 'import-template': ImportTemplateDialog, - // import: ImportDialog, 'sign-in': SignInDialog, } satisfies { [key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC< diff --git a/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx index be2bc77963..267e4f5eb1 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx @@ -5,29 +5,47 @@ import { } from '@affine/core/modules/cloud'; import { useI18n } from '@affine/i18n'; import { useService } from '@toeverything/infra'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { SettingGroup } from '../group'; import { RowLayout } from '../row.layout'; +const loadFailedToastId = 'mobile-settings-devices-load-failed'; + export const DevicesGroup = () => { const t = useI18n(); const auth = useService(AuthService); const [sessions, setSessions] = useState([]); + const dismissTimer = useRef(undefined); const reload = useCallback(() => { void auth .listDeviceSessions() .then(setSessions) .catch(error => { - notify.error({ - title: t['com.affine.settings.devices.load-failed'](), - message: String(error), - }); + const toastId = notify.error( + { + title: t['com.affine.settings.devices.load-failed'](), + message: String(error), + }, + { id: loadFailedToastId, duration: 5000 } + ); + window.clearTimeout(dismissTimer.current); + dismissTimer.current = window.setTimeout( + () => notify.dismiss(toastId), + 5000 + ); }); }, [auth, t]); useEffect(reload, [reload]); + useEffect( + () => () => { + window.clearTimeout(dismissTimer.current); + notify.dismiss(loadFailedToastId); + }, + [] + ); const revoke = useCallback( async (session: DeviceAuthSession) => { diff --git a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx index f6d970ae84..e8868d9256 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx @@ -4,7 +4,7 @@ import type { WORKSPACE_DIALOG_SCHEMA, } from '@affine/core/modules/dialogs'; import { useI18n } from '@affine/i18n'; -import { useService } from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; import { useEffect } from 'react'; import { AboutGroup } from './about'; @@ -20,6 +20,7 @@ import { UserUsage } from './user-usage'; const MobileSetting = () => { const session = useService(AuthService).session; + const status = useLiveData(session.status$); useEffect(() => session.revalidate(), [session]); return ( @@ -27,7 +28,7 @@ const MobileSetting = () => { - + {status === 'authenticated' ? : null} diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts index c6443c808d..a3c7bb33fa 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts @@ -3,10 +3,10 @@ import { keyframes, style } from '@vanilla-extract/css'; const shineAnimation = keyframes({ '0%': { - backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0', + transform: 'translateX(-100%)', }, '100%': { - backgroundPosition: 'calc(100% + var(--shine-size)) 0, 0 0', + transform: 'translateX(100%)', }, }); export const hotTag = style({ @@ -24,7 +24,6 @@ export const hotTag = style({ boxShadow: '0px 2px 3px rgba(0,0,0,0.1), 0px 0px 3px rgba(255,0,0, 0.5)', vars: { - '--shine-size': '100px', '--shine-color': 'rgba(255,255,255,0.5)', }, @@ -37,13 +36,22 @@ export const hotTag = style({ pointerEvents: 'none', width: '100%', height: '100%', - // animate a shine effect - animation: `${shineAnimation} 3.6s infinite`, + transform: 'translateX(-100%)', + animation: `${shineAnimation} 900ms ease-out`, + animationFillMode: 'forwards', backgroundImage: `linear-gradient(90deg, transparent 0%, var(--shine-color) 50%, transparent 100%)`, backgroundRepeat: 'no-repeat, no-repeat', backgroundSize: '100% 100%', - backgroundPosition: 'calc(var(--shine-size) * -1) 0, 0 0', + }, + }, + '@media': { + '(prefers-reduced-motion: reduce)': { + selectors: { + '&::after': { + animation: 'none', + }, + }, }, }, }); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx index 2f5c6d0543..8cc730d72d 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx @@ -1,8 +1,4 @@ -import { - InsideModalContext, - ModalConfigContext, - Scrollable, -} from '@affine/component'; +import { InsideModalContext, Scrollable } from '@affine/component'; import { PageHeader } from '@affine/core/mobile/components'; import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc'; import { assignInlineVars } from '@vanilla-extract/dynamic'; @@ -17,6 +13,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; +import { useMobileVisualLayer } from '../../modules/back-coordinator'; import { SwipeHelper } from '../../utils'; import * as styles from './swipe-dialog.css'; @@ -142,6 +139,7 @@ const close = ( const SwipeDialogContext = createContext<{ stack: Array>; + backLayer?: symbol; }>({ stack: [], }); @@ -154,17 +152,12 @@ export const SwipeDialog = ({ onOpenChange, }: SwipeDialogProps) => { const insideModal = useContext(InsideModalContext); - const { onOpen: globalOnOpen } = useContext(ModalConfigContext); const swiperTriggerRef = useRef(null); const overlayRef = useRef(null); const dialogRef = useRef(null); - const { stack } = useContext(SwipeDialogContext); + const { stack, backLayer } = useContext(SwipeDialogContext); const prev = stack[stack.length - 1]?.current; - const swipeDialogContextValue = useMemo( - () => ({ stack: [...stack, dialogRef] }), - [stack] - ); const handleClose = useCallback(() => { onOpenChange?.(false); @@ -179,6 +172,15 @@ export const SwipeDialog = ({ handleClose(); } }, [handleClose, prev]); + const layerId = useMobileVisualLayer({ + enabled: open, + parent: backLayer, + onBack: animateClose, + }); + const swipeDialogContextValue = useMemo( + () => ({ stack: [...stack, dialogRef], backLayer: layerId }), + [layerId, stack] + ); useEffect(() => { if (!open) return; @@ -205,6 +207,9 @@ export const SwipeDialog = ({ cancel(overlay, dialog, prev, deltaX); } }, + onSwipeCancel: () => { + reset(overlay, dialog, prev); + }, }); }, [handleClose, open, prev]); @@ -217,11 +222,6 @@ export const SwipeDialog = ({ } }, [open, prev]); - useEffect(() => { - if (open) return globalOnOpen?.(); - return; - }, [globalOnOpen, open]); - if (!open) return null; return ( diff --git a/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.spec.ts b/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.spec.ts new file mode 100644 index 0000000000..f922a74cfb --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.spec.ts @@ -0,0 +1,160 @@ +import { Framework } from '@toeverything/infra'; +import { describe, expect, it, vi } from 'vitest'; + +import { MobileBackCoordinator } from './back-coordinator'; + +const createCoordinator = () => { + const framework = new Framework(); + framework.service(MobileBackCoordinator); + return framework.provider().get(MobileBackCoordinator); +}; + +describe('MobileBackCoordinator', () => { + it('handles visual layers in registration order and disposes descendants', () => { + const coordinator = createCoordinator(); + const calls: string[] = []; + const parent = coordinator.registerVisual({ + handle: () => { + calls.push('parent'); + }, + }); + coordinator.registerVisual({ + parent: parent.id, + handle: () => { + calls.push('child'); + }, + }); + + expect(coordinator.request('system-back')).toBe(true); + expect(calls).toEqual(['child']); + parent.dispose(); + expect(coordinator.request('system-back')).toBe(false); + }); + + it.each(['system-back', 'ui-back'] as const)( + 'uses semantic back for %s but never pops a root', + intent => { + const coordinator = createCoordinator(); + const back = vi.fn(); + coordinator.setDestination({ kind: 'doc', back }); + expect(coordinator.request(intent)).toBe(true); + expect(back).toHaveBeenCalledWith(intent); + coordinator.setDestination({ kind: 'all-docs', back }); + expect(coordinator.request(intent)).toBe(false); + } + ); + + it.each([ + 'home', + 'all-docs', + 'all-collections', + 'all-tags', + 'journal', + ] as const)('passes back through at the %s root', kind => { + const coordinator = createCoordinator(); + const back = vi.fn(); + coordinator.setDestination({ kind, back }); + expect(coordinator.request('system-back')).toBe(false); + expect(coordinator.request('ui-back')).toBe(false); + expect(coordinator.beginInteractive()).toBe(false); + expect(back).not.toHaveBeenCalled(); + }); + + it('keeps ui-up separate from source back', () => { + const coordinator = createCoordinator(); + const back = vi.fn(); + const up = vi.fn(); + coordinator.setDestination({ kind: 'doc', back, up }); + coordinator.request('ui-up'); + expect(up).toHaveBeenCalledOnce(); + expect(back).not.toHaveBeenCalled(); + coordinator.setDestination({ kind: 'doc', back }); + expect(coordinator.request('ui-up')).toBe(false); + }); + + it('commits or cancels one stable interactive target', () => { + const coordinator = createCoordinator(); + const back = vi.fn(); + coordinator.setDestination({ kind: 'doc', back }); + expect(coordinator.beginInteractive()).toBe(true); + coordinator.cancelInteractive(); + expect(back).not.toHaveBeenCalled(); + expect(coordinator.beginInteractive()).toBe(true); + expect(coordinator.commitInteractive()).toBe(true); + expect(back).toHaveBeenCalledWith('interactive-gesture'); + }); + + it('clears visual, destination and restoration state on reset', () => { + const coordinator = createCoordinator(); + coordinator.setSnapshot('home', { anchor: 'doc:a', expanded: ['folder'] }); + coordinator.setDestination({ kind: 'doc', back: vi.fn() }); + coordinator.registerVisual({ handle: vi.fn() }); + coordinator.pushSource({ workspaceId: 'a', location: '/home' }); + coordinator.reset(); + expect(coordinator.snapshot('home')).toBeUndefined(); + expect(coordinator.canHandle$.value).toBe(false); + expect(coordinator.request('ui-back')).toBe(false); + expect(coordinator.popSource('a')).toBeUndefined(); + }); + + it('keeps explicit source tokens workspace-scoped and LIFO', () => { + const coordinator = createCoordinator(); + coordinator.pushSource({ workspaceId: 'a', location: '/home' }); + coordinator.pushSource({ workspaceId: 'a', location: '/doc-a' }); + expect(coordinator.popSource('b')).toBeUndefined(); + expect(coordinator.popSource('a')?.location).toBe('/doc-a'); + expect(coordinator.popSource('a')?.location).toBe('/home'); + }); + + it('keeps cold deep links source-free and returns warm or inaccessible targets to their explicit source', () => { + const coordinator = createCoordinator(); + const replace = vi.fn(); + coordinator.setDestination({ kind: 'doc' }); + expect(coordinator.request('ui-back')).toBe(false); + + coordinator.pushSource({ workspaceId: 'a', location: '/home' }); + coordinator.setDestination({ + kind: 'doc', + back: () => { + const source = coordinator.popSource('a'); + if (!source) return false; + replace(source.location); + return true; + }, + }); + expect(coordinator.request('ui-back')).toBe(true); + expect(replace).toHaveBeenCalledWith('/home'); + }); + + it('closes nested menu, confirm and modal layers without reaching the destination', () => { + const coordinator = createCoordinator(); + const calls: string[] = []; + const destination = vi.fn(); + coordinator.setDestination({ kind: 'doc', back: destination }); + const modal = coordinator.registerVisual({ + handle: () => { + calls.push('modal'); + }, + }); + const confirm = coordinator.registerVisual({ + parent: modal.id, + handle: () => { + calls.push('confirm'); + }, + }); + const menu = coordinator.registerVisual({ + parent: confirm.id, + handle: () => { + calls.push('menu'); + }, + }); + + expect(coordinator.request('system-back')).toBe(true); + menu.dispose(); + expect(coordinator.request('system-back')).toBe(true); + confirm.dispose(); + expect(coordinator.request('system-back')).toBe(true); + expect(calls).toEqual(['menu', 'confirm', 'modal']); + expect(destination).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.ts b/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.ts new file mode 100644 index 0000000000..64901ec22b --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/back-coordinator/back-coordinator.ts @@ -0,0 +1,269 @@ +import { LiveData, Service } from '@toeverything/infra'; +import type { StateSnapshot } from 'react-virtuoso'; + +export type MobileBackIntent = + | 'system-back' + | 'ui-back' + | 'ui-up' + | 'interactive-gesture'; + +export type MobileInteractiveBackPhase = + | 'begin' + | 'progress' + | 'cancel' + | 'commit'; + +export type MobileDestinationKind = + | 'home' + | 'all-docs' + | 'all-collections' + | 'all-tags' + | 'journal' + | 'doc' + | 'search' + | 'collection' + | 'tag' + | 'settings-child'; + +export type MobileRestorationSnapshot = { + anchor?: string; + expanded?: readonly string[]; + focus?: string; + listState?: StateSnapshot; +}; + +export type MobileSourceToken = { + workspaceId: string; + location: string; + restoration?: MobileRestorationSnapshot; +}; + +type BackHandler = (intent: MobileBackIntent) => boolean | void; + +type VisualLayer = { + id: symbol; + parent?: symbol; + enabled: () => boolean; + interactive: boolean; + handle: BackHandler; +}; + +type SemanticDestination = { + kind: MobileDestinationKind; + back?: BackHandler; + up?: BackHandler; +}; + +type InteractiveTransaction = { + target: symbol | 'destination'; +}; + +const rootKinds = new Set([ + 'home', + 'all-docs', + 'all-collections', + 'all-tags', + 'journal', +]); + +export const isMobileRootDestination = (kind: MobileDestinationKind) => + rootKinds.has(kind); + +export class MobileBackCoordinator extends Service { + private readonly visualLayers: VisualLayer[] = []; + private destination: SemanticDestination = { kind: 'home' }; + private transaction: InteractiveTransaction | undefined; + private readonly snapshots = new Map(); + private readonly sources: MobileSourceToken[] = []; + + readonly canHandle$ = new LiveData(false); + readonly canInteractivePop$ = new LiveData(false); + + registerVisual({ + id = Symbol('mobile-visual-layer'), + parent, + enabled = () => true, + interactive = true, + handle, + }: { + id?: symbol; + parent?: symbol; + enabled?: () => boolean; + interactive?: boolean; + handle: BackHandler; + }) { + const layer: VisualLayer = { + id, + parent, + enabled, + interactive, + handle, + }; + this.visualLayers.push(layer); + this.publishAvailability(); + return { + id: layer.id, + dispose: () => { + const removed = new Set([layer.id]); + let changed = true; + while (changed) { + changed = false; + for (const candidate of this.visualLayers) { + if (candidate.parent && removed.has(candidate.parent)) { + changed = !removed.has(candidate.id) || changed; + removed.add(candidate.id); + } + } + } + for (let index = this.visualLayers.length - 1; index >= 0; index--) { + if (removed.has(this.visualLayers[index].id)) { + this.visualLayers.splice(index, 1); + } + } + const transactionTarget = this.transaction?.target; + if ( + transactionTarget && + transactionTarget !== 'destination' && + removed.has(transactionTarget) + ) { + this.transaction = undefined; + } + this.publishAvailability(); + }, + }; + } + + setDestination(destination: SemanticDestination) { + this.destination = destination; + this.transaction = undefined; + this.publishAvailability(); + } + + pushSource(source: MobileSourceToken) { + const previous = this.sources.at(-1); + if ( + previous?.workspaceId === source.workspaceId && + previous.location === source.location + ) { + return; + } + this.sources.push(source); + } + + popSource(workspaceId: string) { + const source = this.sources.at(-1); + if (!source || source.workspaceId !== workspaceId) return undefined; + this.sources.pop(); + return source; + } + + discardSources(count = this.sources.length) { + this.sources.splice(Math.max(0, this.sources.length - count), count); + } + + hasSource(workspaceId: string) { + return this.sources.at(-1)?.workspaceId === workspaceId; + } + + request(intent: MobileBackIntent) { + const layer = this.topLayer(); + if (layer) return layer.handle(intent) !== false; + if (intent === 'ui-up') { + return this.destination.up + ? this.destination.up(intent) !== false + : false; + } + if ( + isMobileRootDestination(this.destination.kind) || + !this.destination.back + ) { + return false; + } + return this.destination.back(intent) !== false; + } + + beginInteractive() { + if (this.transaction) return false; + const layer = this.topLayer(); + if (layer) { + if (!layer.interactive) return false; + this.transaction = { target: layer.id }; + return true; + } + if ( + isMobileRootDestination(this.destination.kind) || + !this.destination.back || + !this.canInteractivePop$.value + ) { + return false; + } + this.transaction = { target: 'destination' }; + return true; + } + + cancelInteractive() { + this.transaction = undefined; + } + + commitInteractive() { + const transaction = this.transaction; + this.transaction = undefined; + if (!transaction) return false; + if (transaction.target === 'destination') { + return this.request('interactive-gesture'); + } + const layer = this.visualLayers.find( + candidate => candidate.id === transaction.target && candidate.enabled() + ); + return layer?.handle('interactive-gesture') !== false && !!layer; + } + + handleInteractivePhase(phase: MobileInteractiveBackPhase) { + if (phase === 'begin') return this.beginInteractive(); + if (phase === 'cancel') { + this.cancelInteractive(); + return true; + } + if (phase === 'commit') return this.commitInteractive(); + return !!this.transaction; + } + + setSnapshot(key: string, snapshot: MobileRestorationSnapshot) { + this.snapshots.set(key, snapshot); + } + + snapshot(key: string) { + return this.snapshots.get(key); + } + + reset() { + this.visualLayers.splice(0); + this.snapshots.clear(); + this.sources.splice(0); + this.destination = { kind: 'home' }; + this.transaction = undefined; + this.publishAvailability(); + } + + private topLayer() { + for (let index = this.visualLayers.length - 1; index >= 0; index--) { + const layer = this.visualLayers[index]; + if (layer.enabled()) return layer; + } + return undefined; + } + + private publishAvailability() { + const layer = this.topLayer(); + const destinationCanPop = + !isMobileRootDestination(this.destination.kind) && + !!this.destination.back; + this.canHandle$.next(!!layer || destinationCanPop); + this.canInteractivePop$.next(layer ? layer.interactive : destinationCanPop); + } + + override dispose() { + this.reset(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/mobile/modules/back-coordinator/index.ts b/packages/frontend/core/src/mobile/modules/back-coordinator/index.ts new file mode 100644 index 0000000000..9c49137f8f --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/back-coordinator/index.ts @@ -0,0 +1,20 @@ +import type { Framework } from '@toeverything/infra'; + +import { MobileBackCoordinator } from './back-coordinator'; + +export type { + MobileBackIntent, + MobileDestinationKind, + MobileInteractiveBackPhase, + MobileRestorationSnapshot, + MobileSourceToken, +} from './back-coordinator'; +export { + isMobileRootDestination, + MobileBackCoordinator, +} from './back-coordinator'; +export { useMobileVisualLayer } from './visual-layer'; + +export function configureMobileBackCoordinator(framework: Framework) { + framework.service(MobileBackCoordinator); +} diff --git a/packages/frontend/core/src/mobile/modules/back-coordinator/visual-layer.ts b/packages/frontend/core/src/mobile/modules/back-coordinator/visual-layer.ts new file mode 100644 index 0000000000..d053073ed7 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/back-coordinator/visual-layer.ts @@ -0,0 +1,29 @@ +import { useService } from '@toeverything/infra'; +import { useEffect, useMemo } from 'react'; + +import { MobileBackCoordinator } from './back-coordinator'; + +export const useMobileVisualLayer = ({ + enabled, + parent, + interactive = true, + onBack, +}: { + enabled: boolean | undefined; + parent?: symbol; + interactive?: boolean; + onBack: () => void; +}) => { + const coordinator = useService(MobileBackCoordinator); + const id = useMemo(() => Symbol('mobile-visual-layer'), []); + useEffect(() => { + if (!enabled) return; + return coordinator.registerVisual({ + id, + parent, + interactive, + handle: onBack, + }).dispose; + }, [coordinator, enabled, id, interactive, onBack, parent]); + return id; +}; diff --git a/packages/frontend/core/src/mobile/modules/index.ts b/packages/frontend/core/src/mobile/modules/index.ts index 3c57e3c9eb..52071bf32a 100644 --- a/packages/frontend/core/src/mobile/modules/index.ts +++ b/packages/frontend/core/src/mobile/modules/index.ts @@ -1,13 +1,15 @@ import type { Framework } from '@toeverything/infra'; +import { configureMobileBackCoordinator } from './back-coordinator'; import { configureMobileHapticsModule } from './haptics'; -import { configureMobileNavigationGestureModule } from './navigation-gesture'; +import { configureMobileNavigationProjection } from './navigation-projection'; import { configureMobileSearchModule } from './search'; import { configureMobileVirtualKeyboardModule } from './virtual-keyboard'; export function configureMobileModules(framework: Framework) { + configureMobileBackCoordinator(framework); configureMobileSearchModule(framework); configureMobileVirtualKeyboardModule(framework); - configureMobileNavigationGestureModule(framework); + configureMobileNavigationProjection(framework); configureMobileHapticsModule(framework); } diff --git a/packages/frontend/core/src/mobile/modules/navigation-gesture/index.ts b/packages/frontend/core/src/mobile/modules/navigation-gesture/index.ts deleted file mode 100644 index efe25a8e3e..0000000000 --- a/packages/frontend/core/src/mobile/modules/navigation-gesture/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Framework } from '@toeverything/infra'; - -import { NavigationGestureProvider } from './providers/navigation-gesture'; -import { NavigationGestureService } from './services/navigation-gesture'; - -export { NavigationGestureProvider, NavigationGestureService }; - -export function configureMobileNavigationGestureModule(framework: Framework) { - framework.service( - NavigationGestureService, - f => new NavigationGestureService(f.getOptional(NavigationGestureProvider)) - ); -} diff --git a/packages/frontend/core/src/mobile/modules/navigation-gesture/providers/navigation-gesture.ts b/packages/frontend/core/src/mobile/modules/navigation-gesture/providers/navigation-gesture.ts deleted file mode 100644 index cd34ecd6d3..0000000000 --- a/packages/frontend/core/src/mobile/modules/navigation-gesture/providers/navigation-gesture.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createIdentifier } from '@toeverything/infra'; - -export interface NavigationGestureProvider { - isEnabled: () => boolean; - enable: () => void; - disable: () => void; -} - -export const NavigationGestureProvider = - createIdentifier('NavigationGestureProvider'); diff --git a/packages/frontend/core/src/mobile/modules/navigation-gesture/services/navigation-gesture.ts b/packages/frontend/core/src/mobile/modules/navigation-gesture/services/navigation-gesture.ts deleted file mode 100644 index 0aee79d924..0000000000 --- a/packages/frontend/core/src/mobile/modules/navigation-gesture/services/navigation-gesture.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DebugLogger } from '@affine/debug'; -import { - effect, - exhaustMapWithTrailing, - fromPromise, - LiveData, - Service, -} from '@toeverything/infra'; -import { catchError, distinctUntilChanged, EMPTY } from 'rxjs'; - -import type { NavigationGestureProvider } from '../providers/navigation-gesture'; - -const logger = new DebugLogger('affine:navigation-gesture'); - -export class NavigationGestureService extends Service { - public enabled$ = new LiveData(false); - - constructor( - private readonly navigationGestureProvider?: NavigationGestureProvider - ) { - super(); - } - - setEnabled = effect( - distinctUntilChanged(), - exhaustMapWithTrailing((enable: boolean) => { - return fromPromise(async () => { - if (!this.navigationGestureProvider) { - return; - } - if (enable) { - await this.enable(); - } else { - await this.disable(); - } - return; - }).pipe( - catchError(err => { - logger.error('navigationGestureProvider error', err); - return EMPTY; - }) - ); - }) - ); - - async enable() { - this.enabled$.next(true); - logger.debug(`Enable navigation gesture`); - return this.navigationGestureProvider?.enable(); - } - - async disable() { - this.enabled$.next(false); - logger.debug(`Disable navigation gesture`); - return this.navigationGestureProvider?.disable(); - } -} diff --git a/packages/frontend/core/src/mobile/modules/navigation-projection/index.ts b/packages/frontend/core/src/mobile/modules/navigation-projection/index.ts new file mode 100644 index 0000000000..1c58b1c922 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/navigation-projection/index.ts @@ -0,0 +1,34 @@ +import { CollectionService } from '@affine/core/modules/collection'; +import { DocsService } from '@affine/core/modules/doc'; +import { DocsSearchService } from '@affine/core/modules/docs-search'; +import { FavoriteService } from '@affine/core/modules/favorite'; +import { GlobalContextService } from '@affine/core/modules/global-context'; +import { OrganizeService } from '@affine/core/modules/organize'; +import { GuardService } from '@affine/core/modules/permissions'; +import { TagService } from '@affine/core/modules/tag'; +import { + WorkspaceScope, + WorkspaceService, +} from '@affine/core/modules/workspace'; +import type { Framework } from '@toeverything/infra'; + +import { MobileShellDataProjection } from './shell-data-projection'; + +export * from './navigation-projection'; +export * from './shell-data-projection'; + +export function configureMobileNavigationProjection(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(MobileShellDataProjection, [ + DocsService, + DocsSearchService, + FavoriteService, + GlobalContextService, + GuardService, + WorkspaceService, + CollectionService, + OrganizeService, + TagService, + ]); +} diff --git a/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.spec.ts b/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.spec.ts new file mode 100644 index 0000000000..2528953c40 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.spec.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; + +import { + MobileNavigationProjection, + type MobileNavigationSection, +} from './navigation-projection'; + +const projection = new MobileNavigationProjection(); + +describe('MobileNavigationProjection', () => { + it('flattens expanded paths and preserves duplicate path identity', () => { + const sections: MobileNavigationSection[] = [ + { + id: 'favorites', + children: [ + { kind: 'doc', entityId: 'a' }, + { kind: 'doc', entityId: 'a' }, + { + kind: 'folder', + entityId: 'folder', + children: [{ kind: 'doc', entityId: 'b' }], + }, + ], + }, + ]; + const rows = projection.flatten( + sections, + new Set(['favorites', 'favorites/folder%3Afolder']) + ); + + expect(rows.map(row => [row.kind, row.depth, row.entityId])).toEqual([ + ['section', 0, 'favorites'], + ['doc', 1, 'a'], + ['doc', 1, 'a'], + ['folder', 1, 'folder'], + ['doc', 2, 'b'], + ]); + expect(rows[1].id).not.toBe(rows[2].id); + }); + + it('stops cycles at the current item boundary', () => { + const cyclic = { kind: 'doc', entityId: 'a' } as const; + const sections: MobileNavigationSection[] = [ + { + id: 'favorites', + children: [ + { + ...cyclic, + children: [{ ...cyclic, children: [cyclic] }], + }, + ], + }, + ]; + const rows = projection.flatten( + sections, + new Set(['favorites', 'favorites/doc%3Aa']) + ); + + expect(rows.map(row => row.kind)).toEqual([ + 'section', + 'doc', + 'placeholder', + ]); + }); + + it('omits descendants of collapsed rows', () => { + const sections: MobileNavigationSection[] = [ + { + id: 'folders', + children: [ + { + kind: 'folder', + entityId: 'folder', + children: [{ kind: 'doc', entityId: 'doc' }], + }, + ], + }, + ]; + expect( + projection + .flatten(sections, new Set(['folders'])) + .map(row => row.entityId) + ).toEqual(['folders', 'folder']); + }); + + it('preserves action semantics independently of row depth', () => { + const rows = projection.flatten( + [ + { + id: 'favorites', + children: [ + { + kind: 'doc', + entityId: 'doc', + expandable: true, + children: [ + { + kind: 'action', + entityId: 'doc', + action: 'doc-new-linked', + }, + ], + }, + ], + }, + ], + new Set(['favorites', 'favorites/doc%3Adoc']) + ); + + expect(rows.at(-1)).toMatchObject({ + kind: 'action', + depth: 2, + action: 'doc-new-linked', + }); + }); + + it.each([ + ['collection', 'collection-new-doc'], + ['tag', 'tag-new-doc'], + ] as const)( + 'keeps an empty %s expandable with its create action', + (kind, action) => { + const entityId = `empty-${kind}`; + const rowId = `items/${kind}%3A${entityId}`; + const rows = projection.flatten( + [ + { + id: 'items', + children: [ + { + kind, + entityId, + expandable: true, + children: [{ kind: 'action', entityId, action }], + }, + ], + }, + ], + new Set(['items', rowId]) + ); + + expect(rows[1]).toMatchObject({ + kind, + entityId, + expandable: true, + expanded: true, + }); + expect(rows[2]).toMatchObject({ kind: 'action', action, depth: 2 }); + } + ); + + it.each([ + ['loading', 'loading'], + ['denied', 'denied'], + ] as const)( + 'projects %s permission as a terminal placeholder', + (permission, blocked) => { + const rows = projection.flatten( + [ + { + id: 'favorites', + children: [ + { + kind: 'doc', + entityId: 'private', + permission, + children: [{ kind: 'doc', entityId: 'hidden-child' }], + }, + ], + }, + ], + new Set(['favorites', 'favorites/doc%3Aprivate']) + ); + expect(rows.at(-1)).toMatchObject({ + kind: 'placeholder', + entityId: 'private', + blocked, + }); + expect(rows.some(row => row.entityId === 'hidden-child')).toBe(false); + } + ); + + it.each([60, 600, 6000])( + 'keeps stable mixed section paths for %i items per section', + count => { + const section = ( + id: string, + kind: 'doc' | 'collection' | 'folder' | 'tag' + ): MobileNavigationSection => ({ + id, + children: Array.from({ length: count }, (_, index) => ({ + kind, + entityId: `${kind}-${index}`, + linked: kind === 'doc' && index === 0, + })), + }); + const rows = projection.flatten( + [ + section('favorites', 'doc'), + section('collections', 'collection'), + section('organize', 'folder'), + section('tags', 'tag'), + ], + new Set(['favorites', 'collections', 'organize', 'tags']) + ); + expect(rows).toHaveLength(count * 4 + 4); + expect(rows[1]).toMatchObject({ linked: true, depth: 1 }); + expect(new Set(rows.map(row => row.kind))).toEqual( + new Set(['section', 'doc', 'collection', 'folder', 'tag']) + ); + expect(new Set(rows.map(row => row.id)).size).toBe(rows.length); + } + ); +}); diff --git a/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.ts b/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.ts new file mode 100644 index 0000000000..6ba0c102b1 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/navigation-projection/navigation-projection.ts @@ -0,0 +1,143 @@ +export type MobileNavigationKind = + | 'section' + | 'doc' + | 'collection' + | 'folder' + | 'tag' + | 'action' + | 'placeholder'; + +export type MobileNavigationNode = { + kind: Exclude; + entityId: string; + relationId?: string; + children?: readonly MobileNavigationNode[]; + expandable?: boolean; + linked?: boolean; + permission?: 'allowed' | 'loading' | 'denied'; + action?: + | 'section' + | 'folder-new-doc' + | 'doc-new-linked' + | 'collection-new-doc' + | 'tag-new-doc'; +}; + +export type MobileNavigationSection = { + id: string; + children: readonly MobileNavigationNode[]; +}; + +export type MobileNavigationRow = { + id: string; + kind: MobileNavigationKind; + depth: number; + entityId?: string; + expanded?: boolean; + expandable?: boolean; + linked?: boolean; + relationId?: string; + blocked?: 'loading' | 'denied'; + action?: MobileNavigationNode['action']; +}; + +const pathId = (parts: readonly string[]) => + parts.map(part => encodeURIComponent(part)).join('/'); + +export class MobileNavigationProjection { + flatten( + sections: readonly MobileNavigationSection[], + expanded: ReadonlySet + ): MobileNavigationRow[] { + const rows: MobileNavigationRow[] = []; + for (const section of sections) { + const sectionPath = [section.id]; + const sectionId = pathId(sectionPath); + const sectionExpanded = expanded.has(sectionId); + rows.push({ + id: sectionId, + kind: 'section', + depth: 0, + entityId: section.id, + expanded: sectionExpanded, + expandable: true, + }); + if (sectionExpanded) { + this.append( + rows, + section.children, + sectionPath, + 1, + expanded, + new Set() + ); + } + } + return rows; + } + + private append( + rows: MobileNavigationRow[], + nodes: readonly MobileNavigationNode[], + parentPath: readonly string[], + depth: number, + expanded: ReadonlySet, + ancestors: ReadonlySet + ) { + const occurrences = new Map(); + for (const node of nodes) { + const segment = `${node.kind}:${node.entityId}`; + const occurrence = occurrences.get(segment) ?? 0; + occurrences.set(segment, occurrence + 1); + const path = [ + ...parentPath, + occurrence === 0 ? segment : `${segment}#${occurrence}`, + ]; + const id = pathId(path); + if (node.permission && node.permission !== 'allowed') { + rows.push({ + id: `${id}/permission`, + kind: 'placeholder', + depth, + entityId: node.entityId, + blocked: node.permission, + }); + continue; + } + if (ancestors.has(segment)) { + rows.push({ + id: `${id}/cycle`, + kind: 'placeholder', + depth, + entityId: node.entityId, + }); + continue; + } + const expandable = node.expandable ?? !!node.children?.length; + const isExpanded = expandable && expanded.has(id); + rows.push({ + id, + kind: node.kind, + depth, + entityId: node.entityId, + expanded: expandable ? isExpanded : undefined, + expandable, + linked: node.linked, + relationId: node.relationId, + action: node.action, + }); + if (isExpanded && node.children?.length) { + const nextAncestors = new Set(ancestors); + nextAncestors.add(segment); + this.append( + rows, + node.children, + path, + depth + 1, + expanded, + nextAncestors + ); + } + } + } +} diff --git a/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.spec.ts b/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.spec.ts new file mode 100644 index 0000000000..3606593dfb --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.spec.ts @@ -0,0 +1,293 @@ +/* eslint-disable rxjs/finnish */ + +import { Framework, LiveData } from '@toeverything/infra'; +import { Observable, Subject } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; + +import { MobileShellDataProjection } from './shell-data-projection'; + +describe('MobileShellDataProjection', () => { + it('keeps unchanged entry identity and batches expanded refs', async () => { + const metaA$ = new LiveData({ title: 'A' }); + const metaB$ = new LiveData({ title: 'B' }); + const docsMap$ = new LiveData( + new Map([ + ['a', { meta$: metaA$ }], + ['b', { meta$: metaB$ }], + ]) + ); + const favorites$ = new LiveData<{ type: 'doc' | 'folder'; id: string }[]>( + [] + ); + const activeDoc$ = new LiveData(null); + const nonTrashIds$ = new LiveData(['a', 'b']); + const docById$ = vi.fn((id: string) => + docsMap$.selector(docs => docs.get(id)) + ); + const permissionA$ = new LiveData(true); + const can$ = vi.fn((_action: string, id: string) => + id === 'a' ? permissionA$ : new LiveData(true) + ); + const releaseDoc = vi.fn(); + const releaseIndexer = vi.fn(); + const refsSubscriptions = vi.fn(); + const refs$ = new Subject< + Map + >(); + const missingFolder$ = new LiveData(undefined); + const collectionId = 'empty-collection'; + const tagId = 'empty-tag'; + const collectionDocs$ = new LiveData([]); + const collection$ = new LiveData< + { watch: () => Observable } | undefined + >(undefined); + const tag = { id: tagId, pageIds$: new LiveData([]) }; + const watchRefsFrom = vi.fn( + () => + new Observable(subscriber => { + refsSubscriptions(); + return refs$.subscribe(subscriber); + }) + ); + const args = [ + { + list: { + docsMap$, + nonTrashDocsIds$: nonTrashIds$, + doc$: docById$, + }, + }, + { + watchRefsFrom, + watchRefsBySourceFrom: watchRefsFrom, + indexer: { + addPriority: vi.fn(() => releaseIndexer), + waitForDocCompleted: vi.fn(() => Promise.resolve()), + }, + }, + { favoriteList: { sortedList$: favorites$ } }, + { + globalContext: { + docId: { $: activeDoc$ }, + collectionId: { $: new LiveData(null) }, + tagId: { $: new LiveData(null) }, + }, + }, + { can$ }, + { + workspace: { + engine: { doc: { addPriority: vi.fn(() => releaseDoc) } }, + }, + }, + { + collectionMetas$: new LiveData([ + { id: collectionId, name: 'Empty Collection' }, + ]), + collection$: vi.fn(() => collection$), + }, + { + folderTree: { + rootFolder: { sortedChildren$: new LiveData([]) }, + folderNode$: vi.fn(() => missingFolder$), + }, + }, + { + tagList: { + tags$: new LiveData([tag]), + tagMetas$: new LiveData([ + { id: tagId, name: 'Empty Tag', color: '#000' }, + ]), + tagByTagId$: vi.fn(() => new LiveData(tag)), + }, + }, + ] as unknown as ConstructorParameters; + const framework = new Framework(); + framework.service( + MobileShellDataProjection, + () => new MobileShellDataProjection(...args) + ); + const projection = framework.provider().get(MobileShellDataProjection); + const entryA$ = projection.entry$('a'); + const entryB$ = projection.entry$('b'); + const entryASubscription = entryA$.subscribe(); + const entryBSubscription = entryB$.subscribe(); + const navigationEntryA$ = projection.navigationEntry$('doc', 'a'); + const navigationEntryASubscription = navigationEntryA$.subscribe(); + const navigationEntriesSubscription = + projection.navigationEntries$.subscribe(); + const firstNavigationEntryA = navigationEntryA$.value; + const sectionsChanged = vi.fn(); + const sectionsSubscription = + projection.navigationSections$.subscribe(sectionsChanged); + expect( + projection.navigationSections$.value.map(section => + section.children.at(-1) + ) + ).toEqual([ + { kind: 'action', entityId: 'favorites', action: 'section' }, + { kind: 'action', entityId: 'organize', action: 'section' }, + { kind: 'action', entityId: 'collections', action: 'section' }, + { kind: 'action', entityId: 'tags', action: 'section' }, + ]); + expect(projection.navigationSections$.value[2].children[0]).toMatchObject({ + kind: 'collection', + entityId: collectionId, + expandable: true, + children: [ + { + kind: 'action', + entityId: collectionId, + action: 'collection-new-doc', + }, + ], + }); + expect(projection.navigationSections$.value[3].children[0]).toMatchObject({ + kind: 'tag', + entityId: tagId, + expandable: true, + children: [{ kind: 'action', entityId: tagId, action: 'tag-new-doc' }], + }); + collection$.next({ watch: () => collectionDocs$ }); + collectionDocs$.next(['a']); + expect( + projection.navigationSections$.value[2].children[0].children?.[0] + ).toMatchObject({ kind: 'doc', entityId: 'a' }); + navigationEntryASubscription.unsubscribe(); + const resumedNavigationEntryASubscription = navigationEntryA$.subscribe(); + expect(navigationEntryA$.value).toBe(firstNavigationEntryA); + sectionsChanged.mockClear(); + const firstA = entryA$.value; + const entryAChanged = vi.fn(); + const entryAChangeSubscription = entryA$.subscribe(entryAChanged); + + metaB$.next({ title: 'B2' }); + expect(entryA$.value).toBe(firstA); + expect(entryB$.value?.title).toBe('B2'); + expect(sectionsChanged).not.toHaveBeenCalled(); + favorites$.next([{ type: 'folder', id: 'missing' }]); + expect(projection.navigationSections$.value[0].children).toEqual([ + { kind: 'action', entityId: 'favorites', action: 'section' }, + ]); + favorites$.next([{ type: 'doc', id: 'b' }]); + activeDoc$.next('b'); + expect(entryAChanged).toHaveBeenCalledOnce(); + + const refsSubscription = projection.refsByDoc$.subscribe(); + favorites$.next([{ type: 'doc', id: 'a' }]); + projection.setExpandedVisibleDocIds(['a']); + await Promise.resolve(); + await Promise.resolve(); + refs$.next(new Map([['a', [{ docId: 'b', title: 'B' }]]])); + expect( + projection.navigationSections$.value[0].children[0].children + ).toHaveLength(2); + expect( + projection.navigationSections$.value[0].children[0].children?.at(-1) + ).toMatchObject({ kind: 'action', action: 'doc-new-linked' }); + permissionA$.next(false); + expect( + projection.navigationSections$.value[0].children[0].children ?? [] + ).toHaveLength(1); + releaseDoc.mockClear(); + releaseIndexer.mockClear(); + watchRefsFrom.mockClear(); + refsSubscriptions.mockClear(); + projection.setExpandedVisibleDocIds( + Array.from({ length: 600 }, (_, index) => `doc-${index}`) + ); + await Promise.resolve(); + await Promise.resolve(); + expect(watchRefsFrom).toHaveBeenCalledTimes(1); + const [[requestedIds]] = watchRefsFrom.mock.calls as unknown as [ + [string[]], + ]; + expect(requestedIds).toHaveLength(600); + expect(refsSubscriptions).toHaveBeenCalledTimes(1); + + projection.visible$.next(false); + expect(releaseDoc).toHaveBeenCalledTimes(600); + expect(releaseIndexer).toHaveBeenCalledTimes(600); + docById$.mockClear(); + can$.mockClear(); + const largeIds = Array.from({ length: 600 }, (_, index) => `f-${index}`); + nonTrashIds$.next(largeIds); + favorites$.next(largeIds.map(id => ({ type: 'doc', id }))); + expect(projection.navigationSections$.value[0].children).toHaveLength(601); + expect(docById$).not.toHaveBeenCalled(); + expect(can$).not.toHaveBeenCalled(); + refsSubscription.unsubscribe(); + entryASubscription.unsubscribe(); + entryAChangeSubscription.unsubscribe(); + entryBSubscription.unsubscribe(); + resumedNavigationEntryASubscription.unsubscribe(); + navigationEntriesSubscription.unsubscribe(); + sectionsSubscription.unsubscribe(); + }); + + it('cancels late batch startup and releases priorities when hidden', async () => { + let finishIndexing: (() => void) | undefined; + const waitForDocCompleted = vi.fn( + () => + new Promise(resolve => { + finishIndexing = resolve; + }) + ); + const releaseDoc = vi.fn(); + const releaseIndexer = vi.fn(); + const watchRefsBySourceFrom = vi.fn(() => new Observable()); + const can$ = vi.fn(() => new LiveData(true)); + const args = [ + { + list: { + docsMap$: new LiveData(new Map()), + nonTrashDocsIds$: new LiveData([]), + }, + }, + { + watchRefsBySourceFrom, + indexer: { + addPriority: vi.fn(() => releaseIndexer), + waitForDocCompleted, + }, + }, + { favoriteList: { sortedList$: new LiveData([]) } }, + { globalContext: { docId: { $: new LiveData(null) } } }, + { can$ }, + { + workspace: { + engine: { doc: { addPriority: vi.fn(() => releaseDoc) } }, + }, + }, + { collectionMetas$: new LiveData([]) }, + { + folderTree: { + rootFolder: { sortedChildren$: new LiveData([]) }, + }, + }, + { + tagList: { + tags$: new LiveData([]), + tagMetas$: new LiveData([]), + }, + }, + ] as unknown as ConstructorParameters; + const framework = new Framework(); + framework.service( + MobileShellDataProjection, + () => new MobileShellDataProjection(...args) + ); + const projection = framework.provider().get(MobileShellDataProjection); + const subscription = projection.refsByDoc$.subscribe(); + projection.setExpandedVisibleDocIds(['doc']); + expect(waitForDocCompleted).toHaveBeenCalledOnce(); + + projection.dispose(); + finishIndexing?.(); + await Promise.resolve(); + + expect(watchRefsBySourceFrom).toHaveBeenCalledOnce(); + expect(releaseDoc).toHaveBeenCalledOnce(); + expect(releaseIndexer).toHaveBeenCalledOnce(); + subscription.unsubscribe(); + }); +}); diff --git a/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.ts b/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.ts new file mode 100644 index 0000000000..adcddef4f3 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/navigation-projection/shell-data-projection.ts @@ -0,0 +1,513 @@ +import type { CollectionService } from '@affine/core/modules/collection'; +import type { DocsService } from '@affine/core/modules/doc'; +import type { + DocsSearchService, + IndexedDocReference, +} from '@affine/core/modules/docs-search'; +import type { FavoriteService } from '@affine/core/modules/favorite'; +import type { GlobalContextService } from '@affine/core/modules/global-context'; +import type { + FolderNode, + OrganizeService, +} from '@affine/core/modules/organize'; +import type { GuardService } from '@affine/core/modules/permissions'; +import type { TagService } from '@affine/core/modules/tag'; +import type { WorkspaceService } from '@affine/core/modules/workspace'; +import { LiveData, MANUALLY_STOP, Service } from '@toeverything/infra'; +import { combineLatest, map, Observable, of, switchMap } from 'rxjs'; + +import type { + MobileNavigationNode, + MobileNavigationSection, +} from './navigation-projection'; + +export type MobileShellDocEntry = { + id: string; + title: string; + trash: boolean; + favorite: boolean; + active: boolean; + canRead: boolean | undefined; + canUpdate: boolean | undefined; +}; + +export type MobileShellNavigationEntry = { + key: string; + kind: 'doc' | 'collection' | 'folder' | 'tag'; + id: string; + name: string; + color?: string; + active: boolean; + canRead?: boolean; + canUpdate?: boolean; +}; + +const equalIds = (left: readonly string[], right: readonly string[]) => + left.length === right.length && + left.every((id, index) => id === right[index]); + +export class MobileShellDataProjection extends Service { + private readonly entryCache = new Map(); + private readonly docEntries = new Map< + string, + LiveData + >(); + private readonly readPermissions = new Map< + string, + LiveData + >(); + private readonly updatePermissions = new Map< + string, + LiveData + >(); + private readonly expandedDocIds = new Set(); + private readonly collectionDocs = new Map>(); + private readonly navigationEntryCache = new Map< + string, + MobileShellNavigationEntry + >(); + private readonly docNavigationEntries = new Map< + string, + LiveData + >(); + private readonly referenceCache = new Map(); + private readonly expandedDocIds$ = new LiveData([]); + private readonly permittedExpandedDocIds$ = LiveData.computed(get => + get(this.expandedDocIds$).filter( + id => get(this.readPermission(id)) === true + ) + ); + readonly visible$ = new LiveData(true); + + constructor( + private readonly docs: DocsService, + private readonly docsSearch: DocsSearchService, + private readonly favorites: FavoriteService, + private readonly globalContext: GlobalContextService, + private readonly guard: GuardService, + private readonly workspace: WorkspaceService, + private readonly collections: CollectionService, + private readonly organize: OrganizeService, + private readonly tags: TagService + ) { + super(); + } + + entry$(id: string) { + let entry$ = this.docEntries.get(id); + if (!entry$) { + const record$ = this.docs.list.doc$(id); + entry$ = LiveData.computed(get => { + const record = get(record$); + if (!record) { + this.entryCache.delete(id); + return undefined; + } + const meta = get(record.meta$); + const candidate: MobileShellDocEntry = { + id, + title: meta.title ?? '', + trash: meta.trash ?? false, + favorite: get(this.favorites.favoriteList.sortedList$).some( + item => item.type === 'doc' && item.id === id + ), + active: get(this.globalContext.globalContext.docId.$) === id, + canRead: get(this.readPermission(id)), + canUpdate: get(this.updatePermission(id)), + }; + const cached = this.entryCache.get(id); + if ( + cached && + cached.title === candidate.title && + cached.trash === candidate.trash && + cached.favorite === candidate.favorite && + cached.active === candidate.active && + cached.canRead === candidate.canRead && + cached.canUpdate === candidate.canUpdate + ) { + return cached; + } + this.entryCache.set(id, candidate); + return candidate; + }).distinctUntilChanged((previous, current) => previous === current); + this.docEntries.set(id, entry$); + } + return entry$; + } + + readonly navigationEntries$ = LiveData.computed(get => { + const entries = new Map(); + const activeCollection = get( + this.globalContext.globalContext.collectionId.$ + ); + const activeTag = get(this.globalContext.globalContext.tagId.$); + const add = (candidate: MobileShellNavigationEntry) => { + const cached = this.navigationEntryCache.get(candidate.key); + const entry = + cached && + cached.name === candidate.name && + cached.color === candidate.color && + cached.active === candidate.active + ? cached + : candidate; + this.navigationEntryCache.set(entry.key, entry); + entries.set(entry.key, entry); + }; + for (const collection of get(this.collections.collectionMetas$)) { + add({ + key: `collection:${collection.id}`, + kind: 'collection', + id: collection.id, + name: collection.name, + active: activeCollection === collection.id, + }); + } + for (const tag of get(this.tags.tagList.tagMetas$)) { + add({ + key: `tag:${tag.id}`, + kind: 'tag', + id: tag.id, + name: tag.name, + color: tag.color, + active: activeTag === tag.id, + }); + } + const visitFolder = (node: FolderNode, ancestors: ReadonlySet) => { + const id = node.id; + if (!id || ancestors.has(id)) return; + const nextAncestors = new Set(ancestors); + nextAncestors.add(id); + const type = get(node.type$); + if (type === 'folder') { + add({ + key: `folder:${id}`, + kind: 'folder', + id, + name: get(node.name$), + active: false, + }); + get(node.sortedChildren$).forEach(child => + visitFolder(child, nextAncestors) + ); + } + }; + get(this.organize.folderTree.rootFolder.sortedChildren$).forEach(node => + visitFolder(node, new Set()) + ); + for (const key of this.navigationEntryCache.keys()) { + if (!entries.has(key)) this.navigationEntryCache.delete(key); + } + return entries; + }); + + navigationEntry$(kind: MobileShellNavigationEntry['kind'], id: string) { + if (kind === 'doc') { + let entry$ = this.docNavigationEntries.get(id); + if (!entry$) { + let cached: MobileShellNavigationEntry | undefined; + const created$ = LiveData.computed< + MobileShellNavigationEntry | undefined + >(get => { + const doc = get(this.entry$(id)); + if (!doc) return undefined; + const key = `doc:${id}`; + const candidate: MobileShellNavigationEntry = { + key, + kind: 'doc', + id, + name: doc.title, + active: doc.active, + canRead: doc.canRead, + canUpdate: doc.canUpdate, + }; + if ( + cached && + cached.name === candidate.name && + cached.active === candidate.active && + cached.canRead === candidate.canRead && + cached.canUpdate === candidate.canUpdate + ) { + return cached; + } + cached = candidate; + return candidate; + }).distinctUntilChanged((previous, current) => previous === current); + this.docNavigationEntries.set(id, created$); + entry$ = created$; + } + return entry$; + } + return this.navigationEntries$.selector(entries => + entries.get(`${kind}:${id}`) + ); + } + + readonly navigationSections$ = LiveData.computed(get => { + const refsByDoc = get(this.refsByDoc$); + const nonTrashDocIds = new Set(get(this.docs.list.nonTrashDocsIds$)); + const docNode = ( + id: string, + linked = false, + ancestors: ReadonlySet = new Set() + ): MobileNavigationNode | undefined => { + if (!nonTrashDocIds.has(id)) return undefined; + const nextAncestors = new Set(ancestors); + nextAncestors.add(id); + return { + kind: 'doc', + entityId: id, + linked, + expandable: true, + children: ancestors.has(id) + ? [] + : [ + ...(refsByDoc.get(id)?.flatMap(reference => { + const child = docNode(reference.docId, true, nextAncestors); + return child ? [child] : []; + }) ?? []), + { kind: 'action', entityId: id, action: 'doc-new-linked' }, + ], + }; + }; + const collectionNode = ( + id: string, + relationId?: string + ): MobileNavigationNode => ({ + kind: 'collection', + entityId: id, + linked: !!relationId, + relationId, + expandable: true, + children: [ + ...get(this.collectionDocsFor(id)).flatMap(docId => { + const child = docNode(docId, true); + return child ? [child] : []; + }), + { kind: 'action', entityId: id, action: 'collection-new-doc' }, + ], + }); + const tagNode = (id: string, relationId?: string): MobileNavigationNode => { + const tag = get(this.tags.tagList.tagByTagId$(id)); + return { + kind: 'tag', + entityId: id, + linked: !!relationId, + relationId, + expandable: true, + children: [ + ...(tag + ? get(tag.pageIds$).flatMap(docId => { + const child = docNode(docId, true); + return child ? [child] : []; + }) + : []), + { kind: 'action', entityId: id, action: 'tag-new-doc' }, + ], + }; + }; + const folderNode = ( + node: FolderNode, + ancestors: ReadonlySet + ): MobileNavigationNode | undefined => { + const id = node.id ?? ''; + const type = get(node.type$); + const data = get(node.data$); + if (type === 'doc' && data) { + const doc = docNode(data, true); + return doc ? { ...doc, relationId: id } : undefined; + } + if (type === 'collection' && data) return collectionNode(data, id); + if (type === 'tag' && data) return tagNode(data, id); + if (type !== 'folder') return { kind: 'folder', entityId: id }; + if (ancestors.has(id)) { + return { kind: 'folder', entityId: id, children: [] }; + } + const nextAncestors = new Set(ancestors); + nextAncestors.add(id); + return { + kind: 'folder', + entityId: id, + children: [ + ...get(node.sortedChildren$).flatMap(child => { + const projected = folderNode(child, nextAncestors); + return projected ? [projected] : []; + }), + { kind: 'action', entityId: id, action: 'folder-new-doc' }, + ], + }; + }; + + const favorites = get(this.favorites.favoriteList.sortedList$).flatMap( + item => { + let projected: MobileNavigationNode | undefined; + if (item.type === 'doc') { + projected = docNode(item.id); + } else if (item.type === 'collection') { + projected = collectionNode(item.id); + } else if (item.type === 'tag') { + projected = tagNode(item.id); + } else { + const folder = get(this.organize.folderTree.folderNode$(item.id)); + projected = folder ? folderNode(folder, new Set()) : undefined; + } + return projected ? [projected] : []; + } + ); + const folders = get( + this.organize.folderTree.rootFolder.sortedChildren$ + ).flatMap(node => { + const projected = folderNode(node, new Set()); + return projected ? [projected] : []; + }); + const collections = get(this.collections.collectionMetas$).map(meta => + collectionNode(meta.id) + ); + const tags = get(this.tags.tagList.tags$).map(tag => tagNode(tag.id)); + + return [ + { + id: 'favorites', + children: [ + ...favorites, + { kind: 'action', entityId: 'favorites', action: 'section' }, + ], + }, + { + id: 'organize', + children: [ + ...folders, + { kind: 'action', entityId: 'organize', action: 'section' }, + ], + }, + { + id: 'collections', + children: [ + ...collections, + { kind: 'action', entityId: 'collections', action: 'section' }, + ], + }, + { + id: 'tags', + children: [ + ...tags, + { kind: 'action', entityId: 'tags', action: 'section' }, + ], + }, + ] satisfies MobileNavigationSection[]; + }); + + readonly refsByDoc$: LiveData> = + LiveData.from( + combineLatest([ + this.expandedDocIds$, + this.permittedExpandedDocIds$, + this.visible$, + ]).pipe( + switchMap(([requestedIds, ids, visible]) => { + const permitted = new Set(ids); + requestedIds.forEach(id => { + if (!permitted.has(id)) this.referenceCache.delete(id); + }); + if (!visible || ids.length === 0) { + return of(new Map(this.referenceCache)); + } + return new Observable>( + subscriber => { + const abortController = new AbortController(); + const releasePriorities = ids.flatMap(id => [ + this.workspace.workspace.engine.doc.addPriority(id, 10), + this.docsSearch.indexer.addPriority(id, 10), + ]); + const subscription = this.docsSearch + .watchRefsBySourceFrom([...ids]) + .subscribe(subscriber); + ids.forEach(id => { + this.docsSearch.indexer + .waitForDocCompleted(id, abortController.signal) + .catch(error => { + if (error !== MANUALLY_STOP) subscriber.error(error); + }); + }); + return () => { + abortController.abort(MANUALLY_STOP); + subscription.unsubscribe(); + releasePriorities.forEach(release => release()); + }; + } + ); + }), + map(refsByDoc => { + for (const [id, refs] of refsByDoc) { + this.referenceCache.set(id, refs); + } + return new Map(this.referenceCache); + }) + ), + new Map() + ); + + setExpandedVisibleDocIds(ids: Iterable) { + const next = [...new Set(ids)].sort(); + if (!equalIds(this.expandedDocIds$.value, next)) { + this.expandedDocIds$.next(next); + } + } + + registerExpandedDoc(id: string) { + this.expandedDocIds.add(id); + this.setExpandedVisibleDocIds(this.expandedDocIds); + return () => { + this.expandedDocIds.delete(id); + this.setExpandedVisibleDocIds(this.expandedDocIds); + }; + } + + private readPermission(id: string) { + let permission$ = this.readPermissions.get(id); + if (!permission$) { + permission$ = this.guard.can$('Doc_Read', id); + this.readPermissions.set(id, permission$); + } + return permission$; + } + + private updatePermission(id: string) { + let permission$ = this.updatePermissions.get(id); + if (!permission$) { + permission$ = this.guard.can$('Doc_Update', id); + this.updatePermissions.set(id, permission$); + } + return permission$; + } + + private collectionDocsFor(id: string) { + let docs$ = this.collectionDocs.get(id); + if (!docs$) { + docs$ = LiveData.from( + this.collections + .collection$(id) + .pipe( + switchMap(collection => (collection ? collection.watch() : of([]))) + ), + [] + ); + this.collectionDocs.set(id, docs$); + } + return docs$; + } + + override dispose(): void { + this.visible$.next(false); + this.expandedDocIds.clear(); + this.expandedDocIds$.next([]); + this.entryCache.clear(); + this.docEntries.clear(); + this.readPermissions.clear(); + this.updatePermissions.clear(); + this.collectionDocs.clear(); + this.navigationEntryCache.clear(); + this.docNavigationEntries.clear(); + this.referenceCache.clear(); + super.dispose(); + } +} diff --git a/packages/frontend/core/src/mobile/modules/search/service/search.ts b/packages/frontend/core/src/mobile/modules/search/service/search.ts index 789b828dc6..abe173357c 100644 --- a/packages/frontend/core/src/mobile/modules/search/service/search.ts +++ b/packages/frontend/core/src/mobile/modules/search/service/search.ts @@ -4,9 +4,14 @@ import { RecentDocsQuickSearchSession, TagsQuickSearchSession, } from '@affine/core/modules/quicksearch'; -import { Service } from '@toeverything/infra'; +import { LiveData, Service } from '@toeverything/infra'; export class MobileSearchService extends Service { + readonly query$ = new LiveData(''); + readonly scrollAnchor$ = new LiveData(0); + readonly resultVersion$ = new LiveData(0); + readonly keyboardRestore = false; + readonly recentDocs = this.framework.createEntity( RecentDocsQuickSearchSession ); @@ -15,4 +20,13 @@ export class MobileSearchService extends Service { ); readonly docs = this.framework.createEntity(DocsQuickSearchSession); readonly tags = this.framework.createEntity(TagsQuickSearchSession); + + query(value: string) { + this.query$.next(value); + this.resultVersion$.next(this.resultVersion$.value + 1); + this.recentDocs.query(value); + this.collections.query(value); + this.docs.query(value); + this.tags.query(value); + } } diff --git a/packages/frontend/core/src/mobile/pages/index.tsx b/packages/frontend/core/src/mobile/pages/index.tsx index 8b02724119..31b921fd28 100644 --- a/packages/frontend/core/src/mobile/pages/index.tsx +++ b/packages/frontend/core/src/mobile/pages/index.tsx @@ -7,6 +7,10 @@ import { AppFallback } from '../components/app-fallback'; export const Component = () => { // TODO: replace with a mobile version return ( - } /> + } + createErrorFallback={retry => } + /> ); }; diff --git a/packages/frontend/core/src/mobile/pages/workspace/collection/index.tsx b/packages/frontend/core/src/mobile/pages/workspace/collection/index.tsx index 15d46f345a..3d5cb11ba3 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/collection/index.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/collection/index.tsx @@ -1,6 +1,5 @@ import { useThemeColorV2 } from '@affine/component'; -import { AppTabs } from '../../../components'; import { AllDocsHeader, CollectionList } from '../../../views'; export const Component = () => { @@ -8,7 +7,6 @@ export const Component = () => { return ( <> - ); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index 1f948e38b3..0a7a91a444 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -39,7 +39,7 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { AppTabs } from '../../../components'; +import { useMobileShellTabs } from '../../../components'; import { globalVars } from '../../../styles/variables.css'; import { JournalConflictBlock } from './journal-conflict-block'; import { JournalDatePicker } from './journal-date-picker'; @@ -285,14 +285,12 @@ const getIsLandscape = () => const MobileDetailPageHeader = ({ date, - fromTab, title, allJournalDates, handleDateChange, trackScrollTitle, }: { date?: string; - fromTab: boolean; title?: string; allJournalDates: Set; handleDateChange: (date: string) => void; @@ -336,7 +334,7 @@ const MobileDetailPageHeader = ({ return ( ; handleDateChange: (date: string) => void; @@ -394,6 +390,10 @@ const MobileDetailPageContent = ({ const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape }); const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode); + useMobileShellTabs({ + background: cssVarV2('layer/background/primary'), + hidden: immersive && !chromeVisible, + }); useEffect(() => { const mediaQuery = window.matchMedia('(orientation: landscape)'); @@ -507,7 +507,6 @@ const MobileDetailPageContent = ({ {(!immersive || chromeVisible) && ( -