mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
feat(core): improve mobile perf (#15317)
#### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-tools/cli": "workspace:*",
|
"@affine-tools/cli": "workspace:*",
|
||||||
"@capacitor/cli": "^7.6.5",
|
"@capacitor/cli": "^8.4.2",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@istanbuljs/schema": "^0.1.3",
|
"@istanbuljs/schema": "^0.1.3",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface TestOps extends OpSchema {
|
|||||||
bin: [Uint8Array, Uint8Array];
|
bin: [Uint8Array, Uint8Array];
|
||||||
sub: [Uint8Array, number];
|
sub: [Uint8Array, number];
|
||||||
init: [{ fastText?: boolean } | undefined, { ok: true }];
|
init: [{ fastText?: boolean } | undefined, { ok: true }];
|
||||||
|
any: [undefined, unknown];
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'vitest' {
|
declare module 'vitest' {
|
||||||
@@ -242,6 +243,41 @@ describe('op client', () => {
|
|||||||
sub.unsubscribe();
|
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<unknown>(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 => {
|
it('should unsubscribe subscription op', ctx => {
|
||||||
const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({
|
const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({
|
||||||
next: vi.fn(),
|
next: vi.fn(),
|
||||||
|
|||||||
@@ -101,6 +101,44 @@ describe('op consumer', () => {
|
|||||||
expect(message).toContain('"code":"E_PANIC"');
|
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 => {
|
it('should handle cancel message', async ctx => {
|
||||||
ctx.consumer.register('add', ({ a, b }, { signal }) => {
|
ctx.consumer.register('add', ({ a, b }, { signal }) => {
|
||||||
const { reject, resolve, promise } = Promise.withResolvers<number>();
|
const { reject, resolve, promise } = Promise.withResolvers<number>();
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ export interface OpClientOptions {
|
|||||||
timeout?: number;
|
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<Ops extends OpSchema> extends AutoMessageHandler {
|
export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
||||||
private readonly callIds = new Map<OpNames<Ops>, number>();
|
private readonly callIds = new Map<OpNames<Ops>, number>();
|
||||||
private readonly pendingCalls = new Map<string, PendingCall>();
|
private readonly pendingCalls = new Map<string, PendingCall>();
|
||||||
@@ -61,7 +73,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ('error' in msg) {
|
if ('error' in msg) {
|
||||||
pending.reject(Object.assign(new Error(), msg.error));
|
pending.reject(hydrateRemoteError(msg.error));
|
||||||
} else {
|
} else {
|
||||||
pending.resolve(msg.data);
|
pending.resolve(msg.data);
|
||||||
}
|
}
|
||||||
@@ -86,7 +98,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ob.error(Object.assign(new Error(), msg.error));
|
ob.error(hydrateRemoteError(msg.error));
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =
|
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { createNode } from './node-builder';
|
|||||||
import { queryRaw } from './query';
|
import { queryRaw } from './query';
|
||||||
import { getText, tryParseArrayField } from './utils';
|
import { getText, tryParseArrayField } from './utils';
|
||||||
|
|
||||||
|
const SQLITE_INDEXER_VERSION_OFFSET = 1;
|
||||||
|
|
||||||
export class SqliteIndexerStorage extends IndexerStorageBase {
|
export class SqliteIndexerStorage extends IndexerStorageBase {
|
||||||
static readonly identifier = 'SqliteIndexerStorage';
|
static readonly identifier = 'SqliteIndexerStorage';
|
||||||
override readonly recommendRefreshInterval = 30 * 1000; // 5 seconds
|
override readonly recommendRefreshInterval = 30 * 1000; // 5 seconds
|
||||||
@@ -92,7 +94,7 @@ export class SqliteIndexerStorage extends IndexerStorageBase {
|
|||||||
`${table}:${field as string}`,
|
`${table}:${field as string}`,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
if (text) {
|
if (typeof text === 'string' && text.length > 0) {
|
||||||
let values: string[] = [text];
|
let values: string[] = [text];
|
||||||
const parsed = tryParseArrayField(text);
|
const parsed = tryParseArrayField(text);
|
||||||
if (parsed) {
|
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<number> {
|
async indexVersion(): Promise<number> {
|
||||||
return this.connection.apis.ftsIndexVersion();
|
return (
|
||||||
|
(await this.connection.apis.ftsIndexVersion()) +
|
||||||
|
SQLITE_INDEXER_VERSION_OFFSET
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '<b>', end: '</b>' }] },
|
||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,14 +20,22 @@ export async function createNode(
|
|||||||
`${table}:${field as string}`,
|
`${table}:${field as string}`,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
if (text !== null) {
|
if (typeof text === 'string') {
|
||||||
const parsed = tryParseArrayField(text);
|
const parsed = tryParseArrayField(text);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
fields[field as string] = parsed;
|
fields[field as string] = parsed;
|
||||||
} else {
|
} else {
|
||||||
fields[field as string] = text;
|
fields[field as string] = text;
|
||||||
}
|
}
|
||||||
|
} else if (text == null) {
|
||||||
|
fields[field as string] = '';
|
||||||
} else {
|
} else {
|
||||||
|
console.warn('[nbstore] invalid indexed field type', {
|
||||||
|
table,
|
||||||
|
field: field as string,
|
||||||
|
id,
|
||||||
|
type: typeof text,
|
||||||
|
});
|
||||||
fields[field as string] = '';
|
fields[field as string] = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +51,7 @@ export async function createNode(
|
|||||||
`${table}:${h.field as string}`,
|
`${table}:${h.field as string}`,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
if (text) {
|
if (typeof text === 'string' && text.length > 0) {
|
||||||
const queryString = Array.from(queryStrings).join(' ');
|
const queryString = Array.from(queryStrings).join(' ');
|
||||||
const matches = await connection.apis.ftsGetMatches(
|
const matches = await connection.apis.ftsGetMatches(
|
||||||
`${table}:${h.field as string}`,
|
`${table}:${h.field as string}`,
|
||||||
@@ -67,6 +75,14 @@ export async function createNode(
|
|||||||
highlights[h.field as string] = [];
|
highlights[h.field as string] = [];
|
||||||
}
|
}
|
||||||
} else {
|
} 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] = [];
|
highlights[h.field as string] = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export function getText(
|
|||||||
val: string | string[] | undefined
|
val: string | string[] | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
|
if (val.length === 1) {
|
||||||
|
return val[0];
|
||||||
|
}
|
||||||
return JSON.stringify(val);
|
return JSON.stringify(val);
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
|
|||||||
@@ -568,9 +568,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
|||||||
const title = node.fields.title;
|
const title = node.fields.title;
|
||||||
return [
|
return [
|
||||||
node.id,
|
node.id,
|
||||||
{
|
{ title: typeof title === 'string' ? title : undefined },
|
||||||
title: typeof title === 'string' ? title : title.at(0),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,35 +20,42 @@ def nativeAbi = nativeTarget == 'x86_64' ? 'x86_64' : 'arm64-v8a'
|
|||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "app.affine.pro"
|
namespace = "app.affine.pro"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
ndkVersion = new File(sdkDirectory, "ndk").listFiles().sort().last().name
|
ndkVersion = new File(sdkDirectory, "ndk").listFiles().sort().last().name
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "app.affine.pro"
|
applicationId = "app.affine.pro"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion = rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion = rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode = 1
|
||||||
versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
|
versionName = System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [usesCleartextTraffic: "false"]
|
manifestPlaceholders = [usesCleartextTraffic: "false"]
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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
|
// 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 {
|
ndk {
|
||||||
abiFilters nativeAbi
|
abiFilters nativeAbi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose = true
|
||||||
buildConfig true
|
buildConfig = true
|
||||||
viewBinding true
|
viewBinding = true
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
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 {
|
debug {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
@@ -112,6 +119,7 @@ dependencies {
|
|||||||
implementation libs.androidx.datastore.preferences
|
implementation libs.androidx.datastore.preferences
|
||||||
implementation libs.androidx.navigation.fragment
|
implementation libs.androidx.navigation.fragment
|
||||||
implementation libs.androidx.navigation.ui.ktx
|
implementation libs.androidx.navigation.ui.ktx
|
||||||
|
implementation libs.androidx.webkit
|
||||||
|
|
||||||
implementation libs.apollo.runtime
|
implementation libs.apollo.runtime
|
||||||
implementation (libs.jna) {
|
implementation (libs.jna) {
|
||||||
@@ -159,8 +167,8 @@ cargo {
|
|||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
apiVersion = KotlinVersion.KOTLIN_2_1
|
apiVersion = KotlinVersion.KOTLIN_2_2
|
||||||
languageVersion = KotlinVersion.KOTLIN_2_1
|
languageVersion = KotlinVersion.KOTLIN_2_2
|
||||||
jvmTarget = JvmTarget.JVM_21
|
jvmTarget = JvmTarget.JVM_21
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
@@ -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<String>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package app.affine.pro
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewOutcomeReceiver
|
||||||
|
import androidx.webkit.WebViewStartUpConfig
|
||||||
|
import androidx.webkit.WebViewStartUpResult
|
||||||
|
import androidx.webkit.WebViewStartupException
|
||||||
import app.affine.pro.utils.logger.AffineDebugTree
|
import app.affine.pro.utils.logger.AffineDebugTree
|
||||||
import app.affine.pro.utils.logger.CrashlyticsTree
|
import app.affine.pro.utils.logger.CrashlyticsTree
|
||||||
import app.affine.pro.utils.logger.FileTree
|
import app.affine.pro.utils.logger.FileTree
|
||||||
@@ -11,12 +17,14 @@ import com.google.firebase.crashlytics.setCustomKeys
|
|||||||
import com.google.firebase.ktx.Firebase
|
import com.google.firebase.ktx.Firebase
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class AFFiNEApp : Application() {
|
class AFFiNEApp : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
startWebView()
|
||||||
_context = applicationContext
|
_context = applicationContext
|
||||||
// init logger
|
// init logger
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
@@ -33,6 +41,30 @@ class AFFiNEApp : Application() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startWebView() {
|
||||||
|
val startedAt = SystemClock.elapsedRealtime()
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
val config = WebViewStartUpConfig.Builder(executor).build()
|
||||||
|
WebViewCompat.startUpWebView(
|
||||||
|
this,
|
||||||
|
config,
|
||||||
|
object : WebViewOutcomeReceiver<WebViewStartUpResult, WebViewStartupException> {
|
||||||
|
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() {
|
override fun onTerminate() {
|
||||||
_context = null
|
_context = null
|
||||||
super.onTerminate()
|
super.onTerminate()
|
||||||
@@ -44,4 +76,4 @@ class AFFiNEApp : Application() {
|
|||||||
|
|
||||||
fun context() = requireNotNull(_context)
|
fun context() = requireNotNull(_context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ object AuthInitializer {
|
|||||||
fun initialize(bridge: Bridge) {
|
fun initialize(bridge: Bridge) {
|
||||||
bridge.addWebViewListener(object : WebViewListener() {
|
bridge.addWebViewListener(object : WebViewListener() {
|
||||||
override fun onPageLoaded(webView: WebView?) {
|
override fun onPageLoaded(webView: WebView?) {
|
||||||
bridge.removeWebViewListener(this)
|
webView?.post { bridge.removeWebViewListener(this) }
|
||||||
MainScope().launch(Dispatchers.IO) {
|
MainScope().launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
FileTree.get()?.checkAndUploadOldLogs(
|
FileTree.get()?.checkAndUploadOldLogs(
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package app.affine.pro
|
package app.affine.pro
|
||||||
|
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
|
import android.content.ComponentCallbacks2
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updateMargins
|
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.AuthPlugin
|
||||||
import app.affine.pro.plugin.HashCashPlugin
|
import app.affine.pro.plugin.HashCashPlugin
|
||||||
import app.affine.pro.plugin.NbStorePlugin
|
import app.affine.pro.plugin.NbStorePlugin
|
||||||
|
import app.affine.pro.plugin.MobileBackPlugin
|
||||||
import app.affine.pro.plugin.PreviewPlugin
|
import app.affine.pro.plugin.PreviewPlugin
|
||||||
import app.affine.pro.service.GraphQLService
|
import app.affine.pro.service.GraphQLService
|
||||||
import app.affine.pro.service.SSEService
|
import app.affine.pro.service.SSEService
|
||||||
@@ -53,6 +57,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
|
|||||||
AuthPlugin::class.java,
|
AuthPlugin::class.java,
|
||||||
HashCashPlugin::class.java,
|
HashCashPlugin::class.java,
|
||||||
NbStorePlugin::class.java,
|
NbStorePlugin::class.java,
|
||||||
|
MobileBackPlugin::class.java,
|
||||||
PreviewPlugin::class.java,
|
PreviewPlugin::class.java,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -83,6 +88,8 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
|
|||||||
private var navHeight = 0
|
private var navHeight = 0
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
installSplashScreen()
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
|
||||||
navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
|
navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
|
||||||
@@ -96,6 +103,16 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
|
|||||||
configureEditorWebView()
|
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() {
|
private fun configureEditorWebView() {
|
||||||
bridge.webView.apply {
|
bridge.webView.apply {
|
||||||
overScrollMode = View.OVER_SCROLL_NEVER
|
overScrollMode = View.OVER_SCROLL_NEVER
|
||||||
|
|||||||
+52
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-6
@@ -7,11 +7,14 @@ import com.getcapacitor.PluginCall
|
|||||||
import com.getcapacitor.PluginMethod
|
import com.getcapacitor.PluginMethod
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uniffi.affine_mobile_native.DocRecord
|
import uniffi.affine_mobile_native.DocRecord
|
||||||
import uniffi.affine_mobile_native.SetBlob
|
import uniffi.affine_mobile_native.SetBlob
|
||||||
import uniffi.affine_mobile_native.newDocStoragePool
|
import uniffi.affine_mobile_native.newDocStoragePool
|
||||||
|
|
||||||
|
private const val ANDROID_INDEXER_VERSION_OFFSET = 1u
|
||||||
|
|
||||||
@CapacitorPlugin(name = "NbStoreDocStorage")
|
@CapacitorPlugin(name = "NbStoreDocStorage")
|
||||||
class NbStorePlugin : Plugin() {
|
class NbStorePlugin : Plugin() {
|
||||||
|
|
||||||
@@ -598,10 +601,10 @@ class NbStorePlugin : Plugin() {
|
|||||||
JSObject()
|
JSObject()
|
||||||
.put("blockId", block.blockId)
|
.put("blockId", block.blockId)
|
||||||
.put("flavour", block.flavour)
|
.put("flavour", block.flavour)
|
||||||
.put("content", block.content)
|
.put("content", block.content?.let(::JSArray))
|
||||||
.put("blob", block.blob)
|
.put("blob", block.blob?.let(::JSArray))
|
||||||
.put("refDocId", block.refDocId)
|
.put("refDocId", block.refDocId?.let(::JSArray))
|
||||||
.put("refInfo", block.refInfo)
|
.put("refInfo", block.refInfo?.let(::JSArray))
|
||||||
.put("parentFlavour", block.parentFlavour)
|
.put("parentFlavour", block.parentFlavour)
|
||||||
.put("parentBlockId", block.parentBlockId)
|
.put("parentBlockId", block.parentBlockId)
|
||||||
.put("additional", block.additional)
|
.put("additional", block.additional)
|
||||||
@@ -683,7 +686,7 @@ class NbStorePlugin : Plugin() {
|
|||||||
val indexName = call.getStringEnsure("indexName")
|
val indexName = call.getStringEnsure("indexName")
|
||||||
val docId = call.getStringEnsure("docId")
|
val docId = call.getStringEnsure("docId")
|
||||||
val text = docStoragePool.ftsGetDocument(id, indexName, 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) {
|
} catch (e: Exception) {
|
||||||
call.reject("Failed to get fts document: ${e.message}", null, e)
|
call.reject("Failed to get fts document: ${e.message}", null, e)
|
||||||
}
|
}
|
||||||
@@ -728,7 +731,7 @@ class NbStorePlugin : Plugin() {
|
|||||||
fun ftsIndexVersion(call: PluginCall) {
|
fun ftsIndexVersion(call: PluginCall) {
|
||||||
launch(Dispatchers.IO) {
|
launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val version = docStoragePool.ftsIndexVersion()
|
val version = docStoragePool.ftsIndexVersion() + ANDROID_INDEXER_VERSION_OFFSET
|
||||||
call.resolve(JSObject().put("indexVersion", version))
|
call.resolve(JSObject().put("indexVersion", version))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
call.reject("Failed to get fts index version: ${e.message}", null, e)
|
call.reject("Failed to get fts index version: ${e.message}", null, e)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ext {
|
ext {
|
||||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
|
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
|
||||||
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
|
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
@@ -9,21 +9,23 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply plugin: 'com.android.library'
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "capacitor.cordova.android.plugins"
|
namespace = "capacitor.cordova.android.plugins"
|
||||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
||||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
}
|
}
|
||||||
lintOptions {
|
lintOptions {
|
||||||
abortOnError false
|
abortOnError = false
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
@@ -54,4 +56,4 @@ apply from: "cordova.variables.gradle"
|
|||||||
|
|
||||||
for (def func : cdvPluginPostBuildExtras) {
|
for (def func : cdvPluginPostBuildExtras) {
|
||||||
func()
|
func()
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
ext {
|
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.
|
// Plugin gradle extensions can append to this to have code run at the end.
|
||||||
cdvPluginPostBuildExtras = []
|
cdvPluginPostBuildExtras = []
|
||||||
cordovaConfig = [:]
|
cordovaConfig = [:]
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
|
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
|
||||||
<application android:usesCleartextTraffic="true">
|
<application >
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ org.gradle.jvmargs=-Xmx1536m
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
android.r8.optimizedResourceShrinking=true
|
||||||
ksp.incremental.apt=true
|
ksp.incremental.apt=true
|
||||||
ksp.useKSP2=true
|
ksp.useKSP2=true
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
[versions]
|
[versions]
|
||||||
android-gradle-plugin = "8.13.2"
|
android-gradle-plugin = "8.13.2"
|
||||||
androidx-activity-compose = "1.10.1"
|
androidx-activity-compose = "1.13.0"
|
||||||
androidx-appcompat = "1.7.0"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-browser = "1.8.0"
|
androidx-browser = "1.9.0"
|
||||||
androidx-compose-bom = "2025.05.00"
|
androidx-compose-bom = "2025.05.00"
|
||||||
androidx-coordinatorlayout = "1.3.0"
|
androidx-coordinatorlayout = "1.3.0"
|
||||||
androidx-core-ktx = "1.16.0"
|
androidx-core-ktx = "1.17.0"
|
||||||
androidx-core-splashscreen = "1.0.1"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-datastore-preferences = "1.2.0-alpha02"
|
androidx-datastore-preferences = "1.2.0-alpha02"
|
||||||
androidx-espresso-core = "3.6.1"
|
androidx-espresso-core = "3.7.0"
|
||||||
androidx-junit = "1.2.1"
|
androidx-junit = "1.3.0"
|
||||||
androidx-lifecycle-compose = "2.9.0"
|
androidx-lifecycle-compose = "2.9.0"
|
||||||
androidx-material3 = "1.3.1"
|
androidx-material3 = "1.3.1"
|
||||||
androidx-navigation = "2.9.0"
|
androidx-navigation = "2.9.0"
|
||||||
@@ -19,25 +19,26 @@ apollo-kotlin-adapters = "0.0.6"
|
|||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
firebase-bom = "33.13.0"
|
firebase-bom = "33.13.0"
|
||||||
firebase-crashlytics = "3.0.3"
|
firebase-crashlytics = "3.0.3"
|
||||||
google-services = "4.4.2"
|
google-services = "4.4.4"
|
||||||
gradle-versions = "0.52.0"
|
gradle-versions = "0.52.0"
|
||||||
hilt = "2.56.2"
|
hilt = "2.56.2"
|
||||||
hilt-ext = "1.2.0"
|
hilt-ext = "1.2.0"
|
||||||
jna = "5.17.0"
|
jna = "5.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.1.20"
|
kotlin = "2.2.20"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
kotlinx-datetime = "0.6.2"
|
kotlinx-datetime = "0.6.2"
|
||||||
kotlinx-serialization-json = "1.8.1"
|
kotlinx-serialization-json = "1.8.1"
|
||||||
ksp = "2.1.20-2.0.1"
|
ksp = "2.2.20-2.0.4"
|
||||||
# @keep
|
# @keep
|
||||||
minSdk = "23"
|
minSdk = "24"
|
||||||
mozilla-rust-android = "0.9.6"
|
mozilla-rust-android = "0.9.6"
|
||||||
okhttp-bom = "5.0.0-alpha.14"
|
okhttp-bom = "5.0.0-alpha.14"
|
||||||
richtext = "1.0.0-alpha02"
|
richtext = "1.0.0-alpha02"
|
||||||
# @keep
|
# @keep
|
||||||
targetSdk = "35"
|
targetSdk = "36"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
|
webkit = "1.16.0"
|
||||||
version-catalog-update = "1.0.0"
|
version-catalog-update = "1.0.0"
|
||||||
|
|
||||||
[libraries]
|
[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-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-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-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-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-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" }
|
apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ const config: CapacitorConfig & AppConfig = {
|
|||||||
keystoreAliasPassword: process.env.AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD,
|
keystoreAliasPassword: process.env.AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD,
|
||||||
releaseType: 'AAB',
|
releaseType: 'AAB',
|
||||||
},
|
},
|
||||||
adjustMarginsForEdgeToEdge: 'force',
|
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
SystemBars: {
|
||||||
|
insetsHandling: 'css',
|
||||||
|
},
|
||||||
CapacitorHttp: {
|
CapacitorHttp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
"@affine/nbstore": "workspace:*",
|
"@affine/nbstore": "workspace:*",
|
||||||
"@affine/track": "workspace:*",
|
"@affine/track": "workspace:*",
|
||||||
"@blocksuite/affine": "workspace:*",
|
"@blocksuite/affine": "workspace:*",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^8.4.2",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^7.0.0",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@capacitor/status-bar": "^7.0.0",
|
"@capacitor/status-bar": "^8.0.0",
|
||||||
"@capgo/inappbrowser": "^8.0.0",
|
"@capgo/inappbrowser": "^8.6.14",
|
||||||
"@toeverything/infra": "workspace:*",
|
"@toeverything/infra": "workspace:*",
|
||||||
"async-call-rpc": "^6.4.2",
|
"async-call-rpc": "^6.4.2",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^7.6.5",
|
"@capacitor/cli": "^8.4.2",
|
||||||
"@types/react": "^19.0.1",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { notify } from '@affine/component';
|
|||||||
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||||
import { AffineContext } from '@affine/core/components/context';
|
import { AffineContext } from '@affine/core/components/context';
|
||||||
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
|
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 { 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 { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
|
||||||
import { router } from '@affine/core/mobile/router';
|
import { router } from '@affine/core/mobile/router';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
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 { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
||||||
import { getWorkerUrl } from '@affine/env/worker';
|
import { getWorkerUrl } from '@affine/env/worker';
|
||||||
import { I18n } from '@affine/i18n';
|
import { I18n } from '@affine/i18n';
|
||||||
|
import { serveAuthRequests } from '@affine/mobile-shared/auth/channel';
|
||||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||||
import { setTelemetryTransport } from '@affine/track';
|
import { setTelemetryTransport } from '@affine/track';
|
||||||
import { Container } from '@blocksuite/affine/global/di';
|
import { Container } from '@blocksuite/affine/global/di';
|
||||||
@@ -44,7 +47,13 @@ import { App as CapacitorApp } from '@capacitor/app';
|
|||||||
import { Keyboard } from '@capacitor/keyboard';
|
import { Keyboard } from '@capacitor/keyboard';
|
||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
import { InAppBrowser } from '@capgo/inappbrowser';
|
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 { OpClient } from '@toeverything/infra/op';
|
||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
@@ -55,9 +64,14 @@ import { AffineTheme } from './plugins/affine-theme';
|
|||||||
import { AIButton } from './plugins/ai-button';
|
import { AIButton } from './plugins/ai-button';
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
import { HashCash } from './plugins/hashcash';
|
import { HashCash } from './plugins/hashcash';
|
||||||
|
import { MobileBack } from './plugins/mobile-back';
|
||||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||||
import { Preview } from './plugins/preview';
|
import { Preview } from './plugins/preview';
|
||||||
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
import {
|
||||||
|
authRequestProvider,
|
||||||
|
clearEndpointSession,
|
||||||
|
getValidAccessToken,
|
||||||
|
} from './proxy';
|
||||||
|
|
||||||
const storeManagerClient = createStoreManagerClient();
|
const storeManagerClient = createStoreManagerClient();
|
||||||
setTelemetryTransport(storeManagerClient.telemetry);
|
setTelemetryTransport(storeManagerClient.telemetry);
|
||||||
@@ -409,19 +423,67 @@ const ThemeProvider = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AndroidCapacitorApp = CapacitorApp as typeof CapacitorApp & {
|
||||||
|
toggleBackButtonHandler(options: { enabled: boolean }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<FrameworkRoot framework={frameworkProvider}>
|
<FrameworkRoot framework={frameworkProvider}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AffineContext store={getCurrentStore()}>
|
<MobileModalConfigProvider>
|
||||||
<ThemeProvider />
|
<AffineContext store={getCurrentStore()}>
|
||||||
<RouterProvider
|
<ThemeProvider />
|
||||||
fallbackElement={<AppFallback />}
|
<AndroidBackAdapter />
|
||||||
router={router}
|
<RouterProvider
|
||||||
future={future}
|
fallbackElement={<AppFallback />}
|
||||||
/>
|
router={router}
|
||||||
</AffineContext>
|
future={future}
|
||||||
|
/>
|
||||||
|
</AffineContext>
|
||||||
|
</MobileModalConfigProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</FrameworkRoot>
|
</FrameworkRoot>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -460,22 +522,7 @@ function createStoreManagerClient() {
|
|||||||
|
|
||||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||||
new MessageChannel();
|
new MessageChannel();
|
||||||
authTokenChannelServer.addEventListener('message', event => {
|
serveAuthRequests(authTokenChannelServer, authRequestProvider);
|
||||||
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();
|
|
||||||
worker.postMessage(
|
worker.postMessage(
|
||||||
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||||
[authTokenChannelClient]
|
[authTokenChannelClient]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import './setup-worker';
|
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 { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
|
||||||
import {
|
import {
|
||||||
cloudStorages,
|
cloudStorages,
|
||||||
@@ -18,54 +20,19 @@ import {
|
|||||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
|
|
||||||
let authTokenPort: MessagePort | undefined;
|
const authProvider = new MessagePortAuthProvider();
|
||||||
const pendingTokenRequests = new Map<
|
installAuthRequestProxy(authProvider);
|
||||||
string,
|
|
||||||
{
|
|
||||||
resolve: (token: string | null) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
configureSocketAuthMethod((endpoint, cb) => {
|
configureSocketAuthMethod((endpoint, cb) => {
|
||||||
getValidAccessToken(endpoint)
|
authProvider
|
||||||
|
.getValidAccessToken(endpoint)
|
||||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||||
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('message', e => {
|
globalThis.addEventListener('message', e => {
|
||||||
if (e.data.type === 'auth-access-token-channel') {
|
if (e.data.type === 'auth-access-token-channel') {
|
||||||
authTokenPort = e.ports[0] as MessagePort;
|
authProvider.setPort(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();
|
|
||||||
return;
|
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<string | null>((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<WorkerManagerOps>(
|
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||||
globalThis as MessageCommunicapable
|
globalThis as MessageCommunicapable
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
addListener(
|
||||||
|
event: 'back',
|
||||||
|
listener: (event: MobileBackEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileBack = registerPlugin<MobileBackPlugin>('MobileBack');
|
||||||
@@ -180,7 +180,7 @@ export interface NbStorePlugin {
|
|||||||
id: string;
|
id: string;
|
||||||
indexName: string;
|
indexName: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
}) => Promise<{ text: string | null }>;
|
}) => Promise<{ text?: string | null }>;
|
||||||
ftsGetMatches: (options: {
|
ftsGetMatches: (options: {
|
||||||
id: string;
|
id: string;
|
||||||
indexName: string;
|
indexName: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
base64ToUint8Array,
|
base64ToUint8Array,
|
||||||
uint8ArrayToBase64,
|
uint8ArrayToBase64,
|
||||||
} from '@affine/core/modules/workspace-engine';
|
} from '@affine/core/modules/workspace-engine';
|
||||||
|
import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional';
|
||||||
import {
|
import {
|
||||||
decodePayload,
|
decodePayload,
|
||||||
MOBILE_BLOB_FILE_PREFIX,
|
MOBILE_BLOB_FILE_PREFIX,
|
||||||
@@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
|
|||||||
indexName,
|
indexName,
|
||||||
docId,
|
docId,
|
||||||
});
|
});
|
||||||
return result.text;
|
return normalizeNativeOptional(result.text);
|
||||||
},
|
},
|
||||||
ftsGetMatches: async function (
|
ftsGetMatches: async function (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -1,196 +1,30 @@
|
|||||||
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||||
|
import {
|
||||||
|
type AuthRequestProvider,
|
||||||
|
installAuthRequestProxy,
|
||||||
|
} from '@affine/mobile-shared/auth/request';
|
||||||
|
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
|
|
||||||
function authEndpointForUrl(url: string | URL) {
|
export const authRequestProvider: AuthRequestProvider = {
|
||||||
try {
|
async getValidAccessToken(endpoint) {
|
||||||
const parsed = new URL(url, globalThis.location.origin);
|
const { token } = await Auth.getValidAccessToken({
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
? parsed.origin
|
});
|
||||||
: null;
|
return token ?? null;
|
||||||
} catch {
|
},
|
||||||
return null;
|
async refreshAccessToken(endpoint) {
|
||||||
}
|
const { token } = await Auth.refreshAccessToken({
|
||||||
}
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
|
});
|
||||||
/**
|
return token;
|
||||||
* 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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
installAuthRequestProxy(authRequestProvider);
|
||||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
|
||||||
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<string, string>();
|
|
||||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
|
||||||
private replaying = false;
|
|
||||||
private hasReplayed = false;
|
|
||||||
|
|
||||||
constructor() {
|
export function getValidAccessToken(endpoint: string) {
|
||||||
super();
|
return authRequestProvider.getValidAccessToken(endpoint);
|
||||||
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<string | null> {
|
|
||||||
const { token } = await Auth.getValidAccessToken({
|
|
||||||
endpoint: canonicalAuthEndpoint(endpoint),
|
|
||||||
});
|
|
||||||
return token ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearEndpointSession(endpoint: string) {
|
export async function clearEndpointSession(endpoint: string) {
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
import '@affine/core/bootstrap/browser';
|
import '@affine/core/bootstrap/browser';
|
||||||
import './proxy';
|
|
||||||
|
|||||||
@@ -30,11 +30,11 @@
|
|||||||
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; };
|
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; };
|
||||||
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -77,6 +77,9 @@
|
|||||||
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = "<group>"; };
|
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = "<group>"; };
|
||||||
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
|
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
|
||||||
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
|
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
|
||||||
|
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
|
||||||
|
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
@@ -85,27 +88,19 @@
|
|||||||
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
|
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
|
|
||||||
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
|
|
||||||
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = Plugins;
|
path = Plugins;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
AA0000070000000000000000 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
504EC3011FED79650016851F /* Frameworks */ = {
|
504EC3011FED79650016851F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -120,6 +115,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000070000000000000000 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -229,23 +231,6 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget 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 */ = {
|
504EC3031FED79650016851F /* AFFiNE */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
|
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
|
||||||
@@ -270,6 +255,23 @@
|
|||||||
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
|
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -280,13 +282,13 @@
|
|||||||
LastSwiftUpdateCheck = 0920;
|
LastSwiftUpdateCheck = 0920;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
AA0000090000000000000000 = {
|
|
||||||
CreatedOnToolsVersion = 26.0;
|
|
||||||
};
|
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
LastSwiftMigration = 1600;
|
LastSwiftMigration = 1600;
|
||||||
};
|
};
|
||||||
|
AA0000090000000000000000 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||||
@@ -309,13 +311,6 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
AA0000080000000000000000 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
504EC3021FED79650016851F /* Resources */ = {
|
504EC3021FED79650016851F /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -330,6 +325,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000080000000000000000 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
@@ -358,13 +360,9 @@
|
|||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||||
@@ -391,15 +389,6 @@
|
|||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase 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 */ = {
|
504EC3001FED79650016851F /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -419,6 +408,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000060000000000000000 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
|
||||||
|
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -433,38 +431,6 @@
|
|||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
504EC3141FED79650016851F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -585,10 +551,10 @@
|
|||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
STRIP_INSTALLED_PRODUCT = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
STRIP_INSTALLED_PRODUCT = YES;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -665,18 +631,41 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList 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" */ = {
|
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@@ -695,6 +684,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
AA00000A0000000000000000 /* Debug */,
|
||||||
|
AA00000B0000000000000000 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
|
|||||||
dismissIntelligentsButton()
|
dismissIntelligentsButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func didReceiveMemoryWarning() {
|
||||||
|
super.didReceiveMemoryWarning()
|
||||||
|
webView?.evaluateJavaScript("window.dispatchEvent(new Event('affine:memory-pressure'))")
|
||||||
|
}
|
||||||
|
|
||||||
override func capacitorDidLoad() {
|
override func capacitorDidLoad() {
|
||||||
let plugins: [CAPPlugin] = [
|
let plugins: [CAPPlugin] = [
|
||||||
AffineThemePlugin(associatedController: self),
|
AffineThemePlugin(associatedController: self),
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
indexName: indexName,
|
indexName: indexName,
|
||||||
docId: docId
|
docId: docId
|
||||||
)
|
)
|
||||||
call.resolve(["text": text as Any])
|
call.resolve(["text": text ?? NSNull()])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to get fts document, \(error)", nil, error)
|
call.reject("Failed to get fts document, \(error)", nil, error)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-6
@@ -1,5 +1,6 @@
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@objc(NavigationGesturePlugin)
|
@objc(NavigationGesturePlugin)
|
||||||
public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
@@ -10,23 +11,54 @@ public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "disable", 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) {
|
@objc func isEnabled(_ call: CAPPluginCall) {
|
||||||
let enabled = bridge?.webView?.allowsBackForwardNavigationGestures ?? true
|
DispatchQueue.main.async {
|
||||||
call.resolve(["value": enabled])
|
let enabled = self.edgePan?.isEnabled ?? false
|
||||||
|
call.resolve(["value": enabled])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func enable(_ call: CAPPluginCall) {
|
@objc func enable(_ call: CAPPluginCall) {
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.async {
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true
|
self.edgePan?.isEnabled = true
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func disable(_ call: CAPPluginCall) {
|
@objc func disable(_ call: CAPPluginCall) {
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.async {
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = false
|
self.edgePan?.isEnabled = false
|
||||||
call.resolve([:])
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require_relative '../../../../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
require_relative '../../../../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||||
|
|
||||||
platform :ios, '13.0'
|
platform :ios, '15.0'
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
# workaround to avoid Xcode caching of Pods that requires
|
# workaround to avoid Xcode caching of Pods that requires
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Capacitor (7.4.5):
|
- Capacitor (8.4.2):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (7.1.1):
|
- CapacitorApp (8.1.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorBrowser (7.0.3):
|
- CapacitorBrowser (8.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCordova (7.4.5)
|
- CapacitorCordova (8.4.2)
|
||||||
- CapacitorHaptics (7.0.3):
|
- CapacitorHaptics (8.0.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorKeyboard (7.0.4):
|
- CapacitorKeyboard (8.0.5):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPluginAppTrackingTransparency (3.0.0):
|
- CapacitorPluginAppTrackingTransparency (3.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
@@ -45,15 +45,15 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
|
Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3
|
||||||
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
|
CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06
|
||||||
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
|
CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656
|
||||||
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
|
CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4
|
||||||
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
|
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
|
||||||
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
|
CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b
|
||||||
CapacitorPluginAppTrackingTransparency: f0df8fe65d07d3854de3dad832caf192262bbaa5
|
CapacitorPluginAppTrackingTransparency: f0df8fe65d07d3854de3dad832caf192262bbaa5
|
||||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||||
|
|
||||||
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
PODFILE CHECKSUM: ea5ce301a5d89089eaf7d25b0c1fd49293dcfc20
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
"@affine/nbstore": "workspace:*",
|
"@affine/nbstore": "workspace:*",
|
||||||
"@affine/track": "workspace:*",
|
"@affine/track": "workspace:*",
|
||||||
"@blocksuite/affine": "workspace:*",
|
"@blocksuite/affine": "workspace:*",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/browser": "^7.0.0",
|
"@capacitor/browser": "^8.0.0",
|
||||||
"@capacitor/core": "^7.0.0",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/haptics": "^7.0.0",
|
"@capacitor/haptics": "^8.0.0",
|
||||||
"@capacitor/ios": "^7.0.0",
|
"@capacitor/ios": "^8.4.2",
|
||||||
"@capacitor/keyboard": "^7.0.0",
|
"@capacitor/keyboard": "^8.0.0",
|
||||||
"@toeverything/infra": "workspace:^",
|
"@toeverything/infra": "workspace:^",
|
||||||
"async-call-rpc": "^6.4.2",
|
"async-call-rpc": "^6.4.2",
|
||||||
"capacitor-plugin-app-tracking-transparency": "^3.0.0",
|
"capacitor-plugin-app-tracking-transparency": "^3.0.0",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-tools/cli": "workspace:*",
|
"@affine-tools/cli": "workspace:*",
|
||||||
"@affine-tools/utils": "workspace:*",
|
"@affine-tools/utils": "workspace:*",
|
||||||
"@capacitor/cli": "^7.6.5",
|
"@capacitor/cli": "^8.4.2",
|
||||||
"@types/react": "^19.0.1",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { notify } from '@affine/component';
|
|||||||
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||||
import { AffineContext } from '@affine/core/components/context';
|
import { AffineContext } from '@affine/core/components/context';
|
||||||
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
|
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 { configureMobileModules } from '@affine/core/mobile/modules';
|
||||||
|
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
|
||||||
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
|
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 { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
|
||||||
import { router } from '@affine/core/mobile/router';
|
import { router } from '@affine/core/mobile/router';
|
||||||
import { configureCommonModules } from '@affine/core/modules';
|
import { configureCommonModules } from '@affine/core/modules';
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
requestApplySubscriptionMutation,
|
requestApplySubscriptionMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { I18n } from '@affine/i18n';
|
import { I18n } from '@affine/i18n';
|
||||||
|
import { serveAuthRequests } from '@affine/mobile-shared/auth/channel';
|
||||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||||
import { setTelemetryTransport } from '@affine/track';
|
import { setTelemetryTransport } from '@affine/track';
|
||||||
import { Container } from '@blocksuite/affine/global/di';
|
import { Container } from '@blocksuite/affine/global/di';
|
||||||
@@ -61,7 +63,13 @@ import { Browser } from '@capacitor/browser';
|
|||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import { Haptics } from '@capacitor/haptics';
|
import { Haptics } from '@capacitor/haptics';
|
||||||
import { Keyboard, KeyboardStyle } from '@capacitor/keyboard';
|
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 { OpClient } from '@toeverything/infra/op';
|
||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
|
import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
|
||||||
@@ -70,16 +78,19 @@ import { Suspense, useEffect } from 'react';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
|
import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
|
||||||
import { ModalConfigProvider } from './modal-config';
|
|
||||||
import { AffineTheme } from './plugins/affine-theme';
|
import { AffineTheme } from './plugins/affine-theme';
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
import { Hashcash } from './plugins/hashcash';
|
import { Hashcash } from './plugins/hashcash';
|
||||||
import { ImagePicker } from './plugins/image-picker';
|
import { ImagePicker } from './plugins/image-picker';
|
||||||
|
import { NavigationGesture } from './plugins/navigation-gesture';
|
||||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||||
import { PayWall } from './plugins/paywall';
|
import { PayWall } from './plugins/paywall';
|
||||||
import { Preview } from './plugins/preview';
|
import { Preview } from './plugins/preview';
|
||||||
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
import {
|
||||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
authRequestProvider,
|
||||||
|
clearEndpointSession,
|
||||||
|
getValidAccessToken,
|
||||||
|
} from './proxy';
|
||||||
|
|
||||||
const storeManagerClient = createStoreManagerClient();
|
const storeManagerClient = createStoreManagerClient();
|
||||||
setTelemetryTransport(storeManagerClient.telemetry);
|
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, {
|
framework.impl(HapticProvider, {
|
||||||
impact: options => Haptics.impact(options as any),
|
impact: options => Haptics.impact(options as any),
|
||||||
vibrate: options => Haptics.vibrate(options as any),
|
vibrate: options => Haptics.vibrate(options as any),
|
||||||
@@ -578,14 +584,49 @@ const KeyboardThemeProvider = () => {
|
|||||||
return null;
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<FrameworkRoot framework={frameworkProvider}>
|
<FrameworkRoot framework={frameworkProvider}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AffineContext store={getCurrentStore()}>
|
<MobileModalConfigProvider>
|
||||||
<KeyboardThemeProvider />
|
<AffineContext store={getCurrentStore()}>
|
||||||
<ModalConfigProvider>
|
<KeyboardThemeProvider />
|
||||||
|
<IOSBackAdapter />
|
||||||
<BlocksuiteMenuConfigProvider>
|
<BlocksuiteMenuConfigProvider>
|
||||||
<RouterProvider
|
<RouterProvider
|
||||||
fallbackElement={<AppFallback />}
|
fallbackElement={<AppFallback />}
|
||||||
@@ -593,8 +634,8 @@ export function App() {
|
|||||||
future={future}
|
future={future}
|
||||||
/>
|
/>
|
||||||
</BlocksuiteMenuConfigProvider>
|
</BlocksuiteMenuConfigProvider>
|
||||||
</ModalConfigProvider>
|
</AffineContext>
|
||||||
</AffineContext>
|
</MobileModalConfigProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</FrameworkRoot>
|
</FrameworkRoot>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -626,22 +667,7 @@ function createStoreManagerClient() {
|
|||||||
|
|
||||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||||
new MessageChannel();
|
new MessageChannel();
|
||||||
authTokenChannelServer.addEventListener('message', event => {
|
serveAuthRequests(authTokenChannelServer, authRequestProvider);
|
||||||
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();
|
|
||||||
worker.postMessage(
|
worker.postMessage(
|
||||||
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||||
[authTokenChannelClient]
|
[authTokenChannelClient]
|
||||||
|
|||||||
@@ -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 { onMenuOpen } from '@blocksuite/affine/components/context-menu';
|
||||||
import { useService } from '@toeverything/infra';
|
import { useService } from '@toeverything/infra';
|
||||||
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
import { type PropsWithChildren, useEffect } from 'react';
|
||||||
|
|
||||||
export const BlocksuiteMenuConfigProvider = ({
|
export const BlocksuiteMenuConfigProvider = ({
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren) => {
|
}: PropsWithChildren) => {
|
||||||
const navigationGesture = useService(NavigationGestureService);
|
const coordinator = useService(MobileBackCoordinator);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onMenuOpen(() => {
|
return onMenuOpen(() => {
|
||||||
return handleMenuState();
|
return coordinator.registerVisual({
|
||||||
|
interactive: false,
|
||||||
|
handle: () => false,
|
||||||
|
}).dispose;
|
||||||
});
|
});
|
||||||
}, [handleMenuState]);
|
}, [coordinator]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<ModalConfigContext.Provider value={modalConfigValue}>
|
|
||||||
{children}
|
|
||||||
</ModalConfigContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import './setup-worker';
|
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 { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
|
||||||
import {
|
import {
|
||||||
cloudStorages,
|
cloudStorages,
|
||||||
@@ -18,53 +20,19 @@ import {
|
|||||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
|
|
||||||
let authTokenPort: MessagePort | undefined;
|
const authProvider = new MessagePortAuthProvider();
|
||||||
const terminalAuthErrors = new Set([
|
installAuthRequestProxy(authProvider);
|
||||||
'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;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
configureSocketAuthMethod((endpoint, cb) => {
|
configureSocketAuthMethod((endpoint, cb) => {
|
||||||
getValidAccessToken(endpoint)
|
authProvider
|
||||||
|
.getValidAccessToken(endpoint)
|
||||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||||
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('message', e => {
|
globalThis.addEventListener('message', e => {
|
||||||
if (e.data.type === 'auth-access-token-channel') {
|
if (e.data.type === 'auth-access-token-channel') {
|
||||||
authTokenPort = e.ports[0] as MessagePort;
|
authProvider.setPort(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();
|
|
||||||
return;
|
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<string | null>((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<WorkerManagerOps>(
|
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||||
globalThis as MessageCommunicapable
|
globalThis as MessageCommunicapable
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
export interface NavigationGesturePlugin {
|
export interface NavigationGesturePlugin {
|
||||||
isEnabled: () => Promise<boolean>;
|
isEnabled: () => Promise<boolean>;
|
||||||
enable: () => Promise<void>;
|
enable: () => Promise<void>;
|
||||||
disable: () => Promise<void>;
|
disable: () => Promise<void>;
|
||||||
|
addListener(
|
||||||
|
event: 'gesture',
|
||||||
|
listener: (event: {
|
||||||
|
phase: 'begin' | 'progress' | 'commit' | 'cancel';
|
||||||
|
progress: number;
|
||||||
|
}) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export interface NbStorePlugin {
|
|||||||
id: string;
|
id: string;
|
||||||
indexName: string;
|
indexName: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
}) => Promise<{ text: string | null }>;
|
}) => Promise<{ text?: string | null }>;
|
||||||
ftsGetMatches: (options: {
|
ftsGetMatches: (options: {
|
||||||
id: string;
|
id: string;
|
||||||
indexName: string;
|
indexName: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
base64ToUint8Array,
|
base64ToUint8Array,
|
||||||
uint8ArrayToBase64,
|
uint8ArrayToBase64,
|
||||||
} from '@affine/core/modules/workspace-engine';
|
} from '@affine/core/modules/workspace-engine';
|
||||||
|
import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional';
|
||||||
import {
|
import {
|
||||||
decodePayload,
|
decodePayload,
|
||||||
MOBILE_BLOB_FILE_PREFIX,
|
MOBILE_BLOB_FILE_PREFIX,
|
||||||
@@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
|
|||||||
indexName,
|
indexName,
|
||||||
docId,
|
docId,
|
||||||
});
|
});
|
||||||
return result.text;
|
return normalizeNativeOptional(result.text);
|
||||||
},
|
},
|
||||||
ftsGetMatches: async function (
|
ftsGetMatches: async function (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -1,196 +1,30 @@
|
|||||||
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||||
|
import {
|
||||||
|
type AuthRequestProvider,
|
||||||
|
installAuthRequestProxy,
|
||||||
|
} from '@affine/mobile-shared/auth/request';
|
||||||
|
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
|
|
||||||
function authEndpointForUrl(url: string | URL) {
|
export const authRequestProvider: AuthRequestProvider = {
|
||||||
try {
|
async getValidAccessToken(endpoint) {
|
||||||
const parsed = new URL(url, globalThis.location.origin);
|
const { token } = await Auth.getValidAccessToken({
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
? parsed.origin
|
});
|
||||||
: null;
|
return token ?? null;
|
||||||
} catch {
|
},
|
||||||
return null;
|
async refreshAccessToken(endpoint) {
|
||||||
}
|
const { token } = await Auth.refreshAccessToken({
|
||||||
}
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
|
});
|
||||||
/**
|
return token;
|
||||||
* 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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
installAuthRequestProxy(authRequestProvider);
|
||||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
|
||||||
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<string, string>();
|
|
||||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
|
||||||
private replaying = false;
|
|
||||||
private hasReplayed = false;
|
|
||||||
|
|
||||||
constructor() {
|
export function getValidAccessToken(endpoint: string) {
|
||||||
super();
|
return authRequestProvider.getValidAccessToken(endpoint);
|
||||||
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<string | null> {
|
|
||||||
const { token } = await Auth.getValidAccessToken({
|
|
||||||
endpoint: canonicalAuthEndpoint(endpoint),
|
|
||||||
});
|
|
||||||
return token ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearEndpointSession(endpoint: string) {
|
export async function clearEndpointSession(endpoint: string) {
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
import '@affine/core/bootstrap/browser';
|
import '@affine/core/bootstrap/browser';
|
||||||
import './proxy';
|
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
@@ -6,12 +6,15 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./auth/channel": "./src/auth/channel.ts",
|
||||||
"./auth/endpoint": "./src/auth/endpoint.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"
|
"./nbstore/payload": "./src/nbstore/payload.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@affine/core": "workspace:*",
|
"@affine/core": "workspace:*",
|
||||||
"@capacitor/core": "^7.0.0"
|
"@capacitor/core": "^8.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -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<FakeXMLHttpRequest>();
|
||||||
|
|
||||||
|
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<typeof fetch>(
|
||||||
|
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<typeof fetch>()
|
||||||
|
.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<typeof fetch>(
|
||||||
|
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<string | null>(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<string | null>(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<string>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AuthRequest>;
|
||||||
|
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<AuthResponse>;
|
||||||
|
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<string | null>((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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,3 +8,7 @@ export function canonicalAuthEndpoint(endpoint: string) {
|
|||||||
return endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshAccessToken(code: unknown) {
|
||||||
|
return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { shouldRefreshAccessToken } from './endpoint';
|
||||||
|
|
||||||
|
export interface AuthRequestProvider {
|
||||||
|
getValidAccessToken(endpoint: string): Promise<string | null>;
|
||||||
|
refreshAccessToken(endpoint: string): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<XMLHttpRequest, string>();
|
||||||
|
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<string, string>();
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
|
export * from './auth/channel';
|
||||||
export * from './auth/endpoint';
|
export * from './auth/endpoint';
|
||||||
|
export * from './auth/request';
|
||||||
|
export * from './nbstore/optional';
|
||||||
export * from './nbstore/payload';
|
export * from './nbstore/payload';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function normalizeNativeOptional<T>(value: T | null | undefined) {
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
DropdownMenuContentProps,
|
DropdownMenuContentProps,
|
||||||
DropdownMenuItemProps as MenuItemPropsPrimitive,
|
|
||||||
DropdownMenuPortalProps,
|
DropdownMenuPortalProps,
|
||||||
DropdownMenuProps,
|
DropdownMenuProps,
|
||||||
DropdownMenuSubContentProps,
|
DropdownMenuSubContentProps,
|
||||||
DropdownMenuSubProps,
|
DropdownMenuSubProps,
|
||||||
} from '@radix-ui/react-dropdown-menu';
|
} from '@radix-ui/react-dropdown-menu';
|
||||||
import type { CSSProperties, ReactNode } from 'react';
|
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
export interface MenuRef {
|
export interface MenuRef {
|
||||||
changeOpen: (open: boolean) => void;
|
changeOpen: (open: boolean) => void;
|
||||||
@@ -25,9 +24,12 @@ export interface MenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuItemProps extends Omit<
|
export interface MenuItemProps extends Omit<
|
||||||
MenuItemPropsPrimitive,
|
HTMLAttributes<HTMLElement>,
|
||||||
'asChild' | 'textValue' | 'prefix'
|
'onSelect' | 'prefix'
|
||||||
> {
|
> {
|
||||||
|
disabled?: boolean;
|
||||||
|
textValue?: string;
|
||||||
|
onSelect?: (event: Event) => void;
|
||||||
type?: 'default' | 'warning' | 'danger';
|
type?: 'default' | 'warning' | 'danger';
|
||||||
prefix?: ReactNode;
|
prefix?: ReactNode;
|
||||||
suffix?: ReactNode;
|
suffix?: ReactNode;
|
||||||
|
|||||||
@@ -1,47 +1,48 @@
|
|||||||
import { useCallback, useContext } from 'react';
|
import { type MouseEvent, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import type { MenuItemProps } from '../menu.types';
|
import type { MenuItemProps } from '../menu.types';
|
||||||
import { useMenuItem } from '../use-menu-item';
|
import { useMenuItem } from '../use-menu-item';
|
||||||
import { MobileMenuContext } from './context';
|
import { MobileMenuContext } from './context';
|
||||||
|
|
||||||
let preventDefaultFlag = false;
|
|
||||||
const preventDefault = () => {
|
|
||||||
preventDefaultFlag = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MobileMenuItem = (props: MenuItemProps) => {
|
export const MobileMenuItem = (props: MenuItemProps) => {
|
||||||
const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext);
|
const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext);
|
||||||
const { className, children, otherProps } = useMenuItem(props);
|
const { className, children, otherProps } = useMenuItem(props);
|
||||||
const { onSelect, onClick, divide, ...restProps } = otherProps;
|
const {
|
||||||
|
onSelect,
|
||||||
|
onClick,
|
||||||
|
divide,
|
||||||
|
textValue: _textValue,
|
||||||
|
...restProps
|
||||||
|
} = otherProps;
|
||||||
|
|
||||||
const onItemClick = useCallback(
|
const onItemClick = useCallback(
|
||||||
(e: any) => {
|
(event: MouseEvent<HTMLButtonElement>) => {
|
||||||
onSelect?.(e);
|
onSelect?.(event.nativeEvent);
|
||||||
onClick?.({ ...e, preventDefault });
|
onClick?.(event);
|
||||||
if (preventDefaultFlag) {
|
if (event.defaultPrevented || event.nativeEvent.defaultPrevented) {
|
||||||
preventDefaultFlag = false;
|
return;
|
||||||
|
}
|
||||||
|
if (subMenus.length > 1) {
|
||||||
|
// assume the user can only click the last menu
|
||||||
|
// (mimic the back button)
|
||||||
|
setSubMenus(subMenus.slice(0, -1));
|
||||||
} else {
|
} else {
|
||||||
if (subMenus.length > 1) {
|
setOpen?.(false);
|
||||||
// assume the user can only click the last menu
|
|
||||||
// (mimic the back button)
|
|
||||||
setSubMenus(subMenus.slice(0, -1));
|
|
||||||
} else {
|
|
||||||
setOpen?.(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClick, onSelect, setOpen, setSubMenus, subMenus]
|
[onClick, onSelect, setOpen, setSubMenus, subMenus]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
role="menuitem"
|
type="button"
|
||||||
onClick={onItemClick}
|
onClick={onItemClick}
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={props.disabled}
|
||||||
data-divider={divide}
|
data-divider={divide}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import clsx from 'clsx';
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -71,13 +71,15 @@ export const MobileMenu = ({
|
|||||||
const activeIndex = subMenus.length;
|
const activeIndex = subMenus.length;
|
||||||
|
|
||||||
// dynamic height for slider
|
// dynamic height for slider
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (sliderElement && finalOpen) {
|
if (sliderElement && finalOpen) {
|
||||||
const active = sliderElement.querySelector(
|
const active = sliderElement.querySelector(
|
||||||
`.${styles.menuContent}[data-index="${activeIndex}"]`
|
`.${styles.menuContent}[data-index="${activeIndex}"]`
|
||||||
);
|
);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
|
setSliderHeight(active.getBoundingClientRect().height);
|
||||||
|
|
||||||
// for the situation that content is loaded asynchronously
|
// for the situation that content is loaded asynchronously
|
||||||
return observeResize(active, entry => {
|
return observeResize(active, entry => {
|
||||||
setSliderHeight(entry.borderBoxSize[0].blockSize);
|
setSliderHeight(entry.borderBoxSize[0].blockSize);
|
||||||
@@ -156,6 +158,7 @@ export const MobileMenu = ({
|
|||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
width="100%"
|
width="100%"
|
||||||
animation="slideBottom"
|
animation="slideBottom"
|
||||||
|
contentAnimation="none"
|
||||||
withoutCloseButton={true}
|
withoutCloseButton={true}
|
||||||
contentOptions={{
|
contentOptions={{
|
||||||
className: clsx(className, styles.mobileMenuModal),
|
className: clsx(className, styles.mobileMenuModal),
|
||||||
@@ -163,6 +166,7 @@ export const MobileMenu = ({
|
|||||||
}}
|
}}
|
||||||
contentWrapperStyle={contentWrapperStyle}
|
contentWrapperStyle={contentWrapperStyle}
|
||||||
disableAutoFocus={true}
|
disableAutoFocus={true}
|
||||||
|
preserveEditingFocusOnAction
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={setSliderElement}
|
ref={setSliderElement}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const mobileMenuModal = style({
|
|||||||
export const slider = style({
|
export const slider = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
transition: 'all 0.23s',
|
transition: 'transform 0.23s',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const menuContent = style({
|
export const menuContent = style({
|
||||||
@@ -42,8 +42,13 @@ export const menuContent = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const mobileMenuItem = style({
|
export const mobileMenuItem = style({
|
||||||
|
width: '100%',
|
||||||
padding: '10px 20px',
|
padding: '10px 20px',
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
|
appearance: 'none',
|
||||||
|
font: 'inherit',
|
||||||
|
lineHeight: '22px',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
|
||||||
':hover': {
|
':hover': {
|
||||||
vars: {
|
vars: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const MobileMenuSub = ({
|
|||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
otherProps: { onClick, ...otherTriggerOptions },
|
otherProps: { onClick, textValue: _textValue, ...otherTriggerOptions },
|
||||||
} = useMenuItem({
|
} = useMenuItem({
|
||||||
children: propsChildren,
|
children: propsChildren,
|
||||||
suffixIcon: <ArrowRightSmallPlusIcon />,
|
suffixIcon: <ArrowRightSmallPlusIcon />,
|
||||||
@@ -35,9 +35,14 @@ export const MobileMenuSub = ({
|
|||||||
subContentOptions={contentOptions}
|
subContentOptions={contentOptions}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
<div role="menuitem" className={className} {...otherTriggerOptions}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
disabled={triggerOptions?.disabled}
|
||||||
|
{...otherTriggerOptions}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</button>
|
||||||
</MobileMenuSubRaw>
|
</MobileMenuSubRaw>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -50,7 +55,7 @@ export const MobileMenuSubRaw = ({
|
|||||||
subOptions,
|
subOptions,
|
||||||
subContentOptions,
|
subContentOptions,
|
||||||
}: MenuSubProps & {
|
}: MenuSubProps & {
|
||||||
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
|
onClick?: (e: MouseEvent<HTMLElement>) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const contentOptions = subContentOptions ?? EMPTY_SUB_CONTENT_OPTIONS;
|
const contentOptions = subContentOptions ?? EMPTY_SUB_CONTENT_OPTIONS;
|
||||||
@@ -67,7 +72,7 @@ export const MobileMenuSubRaw = ({
|
|||||||
}, [addSubMenu, subMenuContent]);
|
}, [addSubMenu, subMenuContent]);
|
||||||
|
|
||||||
const onItemClick = useCallback(
|
const onItemClick = useCallback(
|
||||||
(e: MouseEvent<HTMLDivElement>) => {
|
(e: MouseEvent<HTMLElement>) => {
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
doAddSubMenu();
|
doAddSubMenu();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ModalConfig {
|
|||||||
* add global callback for modal open,
|
* add global callback for modal open,
|
||||||
* return a function to handle close/unmount callback
|
* return a function to handle close/unmount callback
|
||||||
*/
|
*/
|
||||||
onOpen?: () => OnClose;
|
onOpen?: (close: () => void) => OnClose;
|
||||||
/**
|
/**
|
||||||
* For mobile
|
* For mobile
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { SafeArea } from '../safe-area';
|
|||||||
import { InsideModalContext, ModalConfigContext } from './context';
|
import { InsideModalContext, ModalConfigContext } from './context';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
|
type ModalAnimation = 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight';
|
||||||
|
|
||||||
export interface ModalProps extends DialogProps {
|
export interface ModalProps extends DialogProps {
|
||||||
width?: CSSProperties['width'];
|
width?: CSSProperties['width'];
|
||||||
height?: CSSProperties['height'];
|
height?: CSSProperties['height'];
|
||||||
@@ -48,17 +50,29 @@ export interface ModalProps extends DialogProps {
|
|||||||
/**
|
/**
|
||||||
* @default 'fadeScaleTop'
|
* @default 'fadeScaleTop'
|
||||||
*/
|
*/
|
||||||
animation?: 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight';
|
animation?: ModalAnimation;
|
||||||
|
contentAnimation?: ModalAnimation;
|
||||||
/**
|
/**
|
||||||
* Whether to show the modal in full screen mode
|
* Whether to show the modal in full screen mode
|
||||||
*/
|
*/
|
||||||
fullScreen?: boolean;
|
fullScreen?: boolean;
|
||||||
disableAutoFocus?: boolean;
|
disableAutoFocus?: boolean;
|
||||||
|
preserveEditingFocusOnAction?: boolean;
|
||||||
}
|
}
|
||||||
type PointerDownOutsideEvent = Parameters<
|
type PointerDownOutsideEvent = Parameters<
|
||||||
Exclude<DialogContentProps['onPointerDownOutside'], undefined>
|
Exclude<DialogContentProps['onPointerDownOutside'], undefined>
|
||||||
>[0];
|
>[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 = '') => {
|
const getVar = (style: number | string = '', defaultValue = '') => {
|
||||||
return style
|
return style
|
||||||
? typeof style === 'number'
|
? typeof style === 'number'
|
||||||
@@ -151,6 +165,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
style: contentStyle,
|
style: contentStyle,
|
||||||
className: contentClassName,
|
className: contentClassName,
|
||||||
onPointerDownOutside,
|
onPointerDownOutside,
|
||||||
|
onPointerDown,
|
||||||
onEscapeKeyDown,
|
onEscapeKeyDown,
|
||||||
...otherContentOptions
|
...otherContentOptions
|
||||||
} = {},
|
} = {},
|
||||||
@@ -164,8 +179,10 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
contentWrapperClassName,
|
contentWrapperClassName,
|
||||||
contentWrapperStyle,
|
contentWrapperStyle,
|
||||||
animation = BUILD_CONFIG.isMobileEdition ? 'slideBottom' : 'fadeScaleTop',
|
animation = BUILD_CONFIG.isMobileEdition ? 'slideBottom' : 'fadeScaleTop',
|
||||||
|
contentAnimation = animation,
|
||||||
fullScreen,
|
fullScreen,
|
||||||
disableAutoFocus,
|
disableAutoFocus,
|
||||||
|
preserveEditingFocusOnAction = false,
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
const { className: closeButtonClassName, ...otherCloseButtonProps } =
|
const { className: closeButtonClassName, ...otherCloseButtonProps } =
|
||||||
@@ -176,9 +193,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) return modalConfigOnOpen?.();
|
if (open) return modalConfigOnOpen?.(() => onOpenChange?.(false));
|
||||||
return;
|
return;
|
||||||
}, [modalConfigOnOpen, open]);
|
}, [modalConfigOnOpen, onOpenChange, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -211,6 +228,29 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
[onEscapeKeyDown, persistent]
|
[onEscapeKeyDown, persistent]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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(
|
const handleAutoFocus = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
disableAutoFocus && e.preventDefault();
|
disableAutoFocus && e.preventDefault();
|
||||||
@@ -244,19 +284,34 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
>
|
>
|
||||||
<SafeArea
|
<SafeArea
|
||||||
bottom={BUILD_CONFIG.isMobileEdition}
|
bottom={BUILD_CONFIG.isMobileEdition}
|
||||||
bottomOffset={dynamicKeyboardHeight ?? 12}
|
bottomOffset={
|
||||||
|
fullScreen
|
||||||
|
? 0
|
||||||
|
: dynamicKeyboardHeight === undefined
|
||||||
|
? 12
|
||||||
|
: `calc(${getVar(dynamicKeyboardHeight)} + 12px)`
|
||||||
|
}
|
||||||
data-full-screen={fullScreen}
|
data-full-screen={fullScreen}
|
||||||
data-modal={modal}
|
data-modal={modal}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
`anim-${animation}`,
|
`anim-${contentAnimation}`,
|
||||||
styles.modalContentWrapper,
|
styles.modalContentWrapper,
|
||||||
contentWrapperClassName
|
contentWrapperClassName
|
||||||
)}
|
)}
|
||||||
data-mobile={BUILD_CONFIG.isMobileEdition ? '' : undefined}
|
data-mobile={BUILD_CONFIG.isMobileEdition ? '' : undefined}
|
||||||
style={contentWrapperStyle}
|
style={{
|
||||||
|
...assignInlineVars({
|
||||||
|
[styles.keyboardInsetVar]: getVar(
|
||||||
|
dynamicKeyboardHeight,
|
||||||
|
'0px'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
...contentWrapperStyle,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
onPointerDownOutside={handlePointerDownOutSide}
|
onPointerDownOutside={handlePointerDownOutSide}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
onEscapeKeyDown={handleEscapeKeyDown}
|
onEscapeKeyDown={handleEscapeKeyDown}
|
||||||
className={clsx(styles.modalContent, contentClassName)}
|
className={clsx(styles.modalContent, contentClassName)}
|
||||||
onOpenAutoFocus={handleAutoFocus}
|
onOpenAutoFocus={handleAutoFocus}
|
||||||
@@ -268,9 +323,13 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
),
|
),
|
||||||
[styles.heightVar]: getVar(
|
[styles.heightVar]: getVar(
|
||||||
height,
|
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,
|
...contentStyle,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const PromptModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
preserveEditingFocusOnAction={BUILD_CONFIG.isMobileEdition}
|
||||||
contentOptions={{
|
contentOptions={{
|
||||||
className: styles.container,
|
className: styles.container,
|
||||||
onPointerDownOutside: e => {
|
onPointerDownOutside: e => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { vtScopeSelector } from '../../utils/view-transition';
|
|||||||
export const widthVar = createVar('widthVar');
|
export const widthVar = createVar('widthVar');
|
||||||
export const heightVar = createVar('heightVar');
|
export const heightVar = createVar('heightVar');
|
||||||
export const minHeightVar = createVar('minHeightVar');
|
export const minHeightVar = createVar('minHeightVar');
|
||||||
|
export const keyboardInsetVar = createVar('keyboardInsetVar');
|
||||||
|
|
||||||
export const modalVTScope = generateIdentifier('modal');
|
export const modalVTScope = generateIdentifier('modal');
|
||||||
|
|
||||||
@@ -89,14 +90,24 @@ export const modalContentWrapper = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
zIndex: cssVar('zIndexModal'),
|
zIndex: cssVar('zIndexModal'),
|
||||||
|
transition: 'padding-bottom 230ms ease-out',
|
||||||
|
|
||||||
|
'@media': {
|
||||||
|
'(prefers-reduced-motion: reduce)': {
|
||||||
|
transition: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-mobile]': {
|
'&[data-mobile]': {
|
||||||
alignItems: 'flex-end',
|
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"]': {
|
'&[data-full-screen="true"]': {
|
||||||
|
alignItems: 'flex-start',
|
||||||
padding: '0 !important',
|
padding: '0 !important',
|
||||||
},
|
},
|
||||||
'&.anim-none': {
|
'&.anim-none': {
|
||||||
@@ -174,8 +185,8 @@ export const modalContent = style({
|
|||||||
'[data-full-screen="true"] &': {
|
'[data-full-screen="true"] &': {
|
||||||
vars: {
|
vars: {
|
||||||
[widthVar]: '100dvw',
|
[widthVar]: '100dvw',
|
||||||
[heightVar]: '100dvh',
|
[heightVar]: `calc(100dvh - ${keyboardInsetVar})`,
|
||||||
[minHeightVar]: '100dvh',
|
[minHeightVar]: `calc(100dvh - ${keyboardInsetVar})`,
|
||||||
},
|
},
|
||||||
maxWidth: '100dvw',
|
maxWidth: '100dvw',
|
||||||
maxHeight: '100dvh',
|
maxHeight: '100dvh',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function MobileNotificationCenter() {
|
|||||||
position="top-center"
|
position="top-center"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
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',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
theme={resolvedTheme}
|
theme={resolvedTheme}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export const safeArea = style({
|
|||||||
paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`,
|
paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`,
|
||||||
},
|
},
|
||||||
'&[data-standalone][data-top]': {
|
'&[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]': {
|
'&[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')})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,19 +53,21 @@ export function registerAffineCreationCommands({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
unsubs.push(
|
if (!BUILD_CONFIG.isMobileEdition) {
|
||||||
registerAffineCommand({
|
unsubs.push(
|
||||||
id: 'affine:new-workspace',
|
registerAffineCommand({
|
||||||
category: 'affine:creation',
|
id: 'affine:new-workspace',
|
||||||
icon: <PlusIcon />,
|
category: 'affine:creation',
|
||||||
label: t['com.affine.cmdk.affine.new-workspace'](),
|
icon: <PlusIcon />,
|
||||||
run() {
|
label: t['com.affine.cmdk.affine.new-workspace'](),
|
||||||
track.$.cmdk.workspace.createWorkspace();
|
run() {
|
||||||
|
track.$.cmdk.workspace.createWorkspace();
|
||||||
|
|
||||||
globalDialogService.open('create-workspace', {});
|
globalDialogService.open('create-workspace', {});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
registerAffineCommand({
|
registerAffineCommand({
|
||||||
id: 'affine:import-workspace',
|
id: 'affine:import-workspace',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const ConfigModal = ({
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
preserveEditingFocusOnAction
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
open={open}
|
open={open}
|
||||||
fullScreen={variant === 'page'}
|
fullScreen={variant === 'page'}
|
||||||
|
|||||||
@@ -79,10 +79,21 @@ export const listEmptyDescription = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const error = style({
|
export const error = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
color: cssVarV2('status/error'),
|
color: cssVarV2('status/error'),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
padding: '4px 2px',
|
padding: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const errorEmptyTitle = style({
|
||||||
|
color: cssVarV2('status/error'),
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '22px',
|
||||||
|
textAlign: 'center',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const itemContainer = style({
|
export const itemContainer = style({
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Scrollable,
|
Scrollable,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { InvitationService } from '@affine/core/modules/cloud';
|
import { AuthService, InvitationService } from '@affine/core/modules/cloud';
|
||||||
|
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||||
import {
|
import {
|
||||||
type Notification,
|
type Notification,
|
||||||
NotificationListService,
|
NotificationListService,
|
||||||
@@ -54,10 +55,18 @@ import * as styles from './list.style.css';
|
|||||||
export const NotificationList = () => {
|
export const NotificationList = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const notificationListService = useService(NotificationListService);
|
const notificationListService = useService(NotificationListService);
|
||||||
|
const authService = useService(AuthService);
|
||||||
|
const globalDialogService = useService(GlobalDialogService);
|
||||||
|
const authStatus = useLiveData(authService.session.status$);
|
||||||
const notifications = useLiveData(notificationListService.notifications$);
|
const notifications = useLiveData(notificationListService.notifications$);
|
||||||
const isLoading = useLiveData(notificationListService.isLoading$);
|
const isLoading = useLiveData(notificationListService.isLoading$);
|
||||||
const error = useLiveData(notificationListService.error$);
|
const error = useLiveData(notificationListService.error$);
|
||||||
const hasMore = useLiveData(notificationListService.hasMore$);
|
const hasMore = useLiveData(notificationListService.hasMore$);
|
||||||
|
const showLoadingMore =
|
||||||
|
authStatus === 'authenticated' &&
|
||||||
|
notifications.length > 0 &&
|
||||||
|
hasMore &&
|
||||||
|
isLoading;
|
||||||
const loadMoreIndicatorRef = useRef<HTMLDivElement>(null);
|
const loadMoreIndicatorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const userFriendlyError = useMemo(() => {
|
const userFriendlyError = useMemo(() => {
|
||||||
@@ -65,23 +74,31 @@ export const NotificationList = () => {
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// reset the notification list when the component is mounted
|
|
||||||
notificationListService.reset();
|
notificationListService.reset();
|
||||||
notificationListService.loadMore();
|
if (authStatus === 'authenticated') notificationListService.loadMore();
|
||||||
}, [notificationListService]);
|
}, [authStatus, notificationListService]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadMoreIndicatorRef.current) {
|
if (loadMoreIndicatorRef.current) {
|
||||||
let previousIsIntersecting = false;
|
let previousIsIntersecting = false;
|
||||||
return observeIntersection(loadMoreIndicatorRef.current, entity => {
|
return observeIntersection(loadMoreIndicatorRef.current, entity => {
|
||||||
if (entity.isIntersecting && !previousIsIntersecting && hasMore) {
|
if (
|
||||||
|
authStatus === 'authenticated' &&
|
||||||
|
entity.isIntersecting &&
|
||||||
|
!previousIsIntersecting &&
|
||||||
|
hasMore
|
||||||
|
) {
|
||||||
notificationListService.loadMore();
|
notificationListService.loadMore();
|
||||||
}
|
}
|
||||||
previousIsIntersecting = entity.isIntersecting;
|
previousIsIntersecting = entity.isIntersecting;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [hasMore, notificationListService]);
|
}, [authStatus, hasMore, notificationListService]);
|
||||||
|
|
||||||
|
const handleSignIn = useCallback(() => {
|
||||||
|
globalDialogService.open('sign-in', {});
|
||||||
|
}, [globalDialogService]);
|
||||||
|
|
||||||
const handleDeleteAll = useCallback(() => {
|
const handleDeleteAll = useCallback(() => {
|
||||||
notificationListService.readAllNotifications().catch(err => {
|
notificationListService.readAllNotifications().catch(err => {
|
||||||
@@ -110,7 +127,9 @@ export const NotificationList = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Scrollable.Root className={styles.scrollRoot}>
|
<Scrollable.Root className={styles.scrollRoot}>
|
||||||
<Scrollable.Viewport className={styles.scrollViewport}>
|
<Scrollable.Viewport className={styles.scrollViewport}>
|
||||||
{notifications.length > 0 ? (
|
{authStatus === 'unauthenticated' ? (
|
||||||
|
<NotificationSignIn onSignIn={handleSignIn} />
|
||||||
|
) : notifications.length > 0 ? (
|
||||||
<ul className={styles.itemList}>
|
<ul className={styles.itemList}>
|
||||||
{notifications.map(notification => (
|
{notifications.map(notification => (
|
||||||
<li key={notification.id}>
|
<li key={notification.id}>
|
||||||
@@ -118,22 +137,32 @@ export const NotificationList = () => {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{userFriendlyError && (
|
{userFriendlyError && (
|
||||||
<div className={styles.error}>{userFriendlyError.message}</div>
|
<li>
|
||||||
|
<NotificationError
|
||||||
|
message={userFriendlyError.message}
|
||||||
|
onRetry={() => notificationListService.retry()}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<NotificationItemSkeleton />
|
<NotificationItemSkeleton />
|
||||||
) : userFriendlyError ? (
|
) : userFriendlyError ? (
|
||||||
<div className={styles.error}>{userFriendlyError.message}</div>
|
<NotificationErrorEmpty
|
||||||
|
message={userFriendlyError.message}
|
||||||
|
onRetry={() => notificationListService.retry()}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NotificationListEmpty />
|
<NotificationListEmpty />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={loadMoreIndicatorRef}
|
ref={loadMoreIndicatorRef}
|
||||||
className={hasMore ? styles.loadMoreIndicator : ''}
|
className={showLoadingMore ? styles.loadMoreIndicator : ''}
|
||||||
>
|
>
|
||||||
{hasMore ? t['com.affine.notification.loading-more']() : null}
|
{showLoadingMore
|
||||||
|
? t['com.affine.notification.loading-more']()
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</Scrollable.Viewport>
|
</Scrollable.Viewport>
|
||||||
<Scrollable.Scrollbar />
|
<Scrollable.Scrollbar />
|
||||||
@@ -142,6 +171,61 @@ export const NotificationList = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NotificationSignIn = ({ onSignIn }: { onSignIn: () => void }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.listEmpty}>
|
||||||
|
<div className={styles.listEmptyIconContainer}>
|
||||||
|
<NotificationIcon width={24} height={24} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.listEmptyTitle}>
|
||||||
|
{t['com.affine.ai.login-required.dialog-title']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.listEmptyDescription}>
|
||||||
|
{t['com.affine.notification.empty.description']()}
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={onSignIn}>
|
||||||
|
{t['com.affine.ai.login-required.dialog-confirm']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationErrorEmpty = ({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.listEmpty}>
|
||||||
|
<div className={styles.listEmptyIconContainer}>
|
||||||
|
<NotificationIcon width={24} height={24} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorEmptyTitle}>{message}</div>
|
||||||
|
<Button onClick={onRetry}>{t['Retry']()}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationError = ({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<span>{message}</span>
|
||||||
|
<Button onClick={onRetry}>{t['Retry']()}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const NotificationListEmpty = () => {
|
const NotificationListEmpty = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export const TagsEditor = ({
|
|||||||
onClick: () => onSelectTagOption(tag),
|
onClick: () => onSelectTagOption(tag),
|
||||||
onMouseEnter: () => setFocusedIndex(idx),
|
onMouseEnter: () => setFocusedIndex(idx),
|
||||||
['data-testid']: 'tag-selector-item',
|
['data-testid']: 'tag-selector-item',
|
||||||
|
['data-modal-action']: '',
|
||||||
['data-focused']: safeFocusedIndex === idx,
|
['data-focused']: safeFocusedIndex === idx,
|
||||||
className: styles.tagSelectorItem,
|
className: styles.tagSelectorItem,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,14 +39,18 @@ export const Component = ({
|
|||||||
defaultIndexRoute = 'all',
|
defaultIndexRoute = 'all',
|
||||||
children,
|
children,
|
||||||
fallback,
|
fallback,
|
||||||
|
createErrorFallback,
|
||||||
}: {
|
}: {
|
||||||
defaultIndexRoute?: string;
|
defaultIndexRoute?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
fallback?: ReactNode;
|
fallback?: ReactNode;
|
||||||
|
createErrorFallback?: (retry: () => void) => ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
|
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
|
||||||
const [navigating, setNavigating] = useState(true);
|
const [navigating, setNavigating] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState(false);
|
||||||
|
const [createAttempt, setCreateAttempt] = useState(0);
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
const defaultServerService = useService(DefaultServerService);
|
const defaultServerService = useService(DefaultServerService);
|
||||||
|
|
||||||
@@ -118,6 +122,9 @@ export const Component = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
|
if (BUILD_CONFIG.isMobileEdition && enableLocalWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNavigating(false);
|
setNavigating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -151,7 +158,12 @@ export const Component = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createFirstAppData(workspacesService)
|
const creation = createFirstAppData(workspacesService);
|
||||||
|
if (!creation) return;
|
||||||
|
|
||||||
|
setCreateError(false);
|
||||||
|
setCreating(true);
|
||||||
|
creation
|
||||||
.then(createdWorkspace => {
|
.then(createdWorkspace => {
|
||||||
if (createdWorkspace) {
|
if (createdWorkspace) {
|
||||||
if (createdWorkspace.defaultPageId) {
|
if (createdWorkspace.defaultPageId) {
|
||||||
@@ -166,21 +178,29 @@ export const Component = ({
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to create first app data', err);
|
console.error('Failed to create first app data', err);
|
||||||
|
setCreateError(true);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
jumpToPage,
|
jumpToPage,
|
||||||
jumpToSignIn,
|
|
||||||
openPage,
|
openPage,
|
||||||
workspacesService,
|
workspacesService,
|
||||||
loggedIn,
|
|
||||||
listIsLoading,
|
listIsLoading,
|
||||||
list,
|
list,
|
||||||
enableLocalWorkspace,
|
enableLocalWorkspace,
|
||||||
|
createAttempt,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const retryCreate = useCallback(() => {
|
||||||
|
setCreateAttempt(attempt => attempt + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (createError && createErrorFallback) {
|
||||||
|
return createErrorFallback(retryCreate);
|
||||||
|
}
|
||||||
|
|
||||||
if (navigating || creating) {
|
if (navigating || creating) {
|
||||||
return fallback ?? <AppContainer fallback />;
|
return fallback ?? <AppContainer fallback />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SafeArea, Skeleton } from '@affine/component';
|
import { Button, SafeArea, Skeleton } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
import { WorkspaceSelector } from '../workspace-selector';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
|
||||||
const SectionTitleFallback = () => {
|
const SectionTitleFallback = () => {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +49,9 @@ const Section = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppFallback = () => {
|
export const AppFallback = ({ onRetry }: { onRetry?: () => void }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeArea top bottom style={{ height: '100dvh', overflow: 'hidden' }}>
|
<SafeArea top bottom style={{ height: '100dvh', overflow: 'hidden' }}>
|
||||||
{/* notification and setting */}
|
{/* notification and setting */}
|
||||||
@@ -73,7 +75,10 @@ export const AppFallback = () => {
|
|||||||
</div>
|
</div>
|
||||||
{/* workspace card */}
|
{/* workspace card */}
|
||||||
<div style={{ padding: '4px 16px' }}>
|
<div style={{ padding: '4px 16px' }}>
|
||||||
<WorkspaceSelector />
|
<Skeleton
|
||||||
|
animation="wave"
|
||||||
|
style={{ width: 220, height: 40, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div style={{ padding: '10px 16px 15px' }}>
|
<div style={{ padding: '10px 16px 15px' }}>
|
||||||
@@ -92,6 +97,33 @@ export const AppFallback = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Section />
|
<Section />
|
||||||
<Section />
|
<Section />
|
||||||
|
{onRetry ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: 24,
|
||||||
|
background: cssVarV2('layer/background/mobile/primary'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 320, textAlign: 'center' }}>
|
||||||
|
{t['error.INTERNAL_SERVER_ERROR']()}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
style={{ minWidth: 104, height: 44 }}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{t['Retry']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</SafeArea>
|
</SafeArea>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export const AppTabCreate = ({ tab }: AppTabCustomFCProps) => {
|
|||||||
if (enablePageTemplate && pageTemplateDocId) {
|
if (enablePageTemplate && pageTemplateDocId) {
|
||||||
const docId =
|
const docId =
|
||||||
await docsService.duplicateFromTemplate(pageTemplateDocId);
|
await docsService.duplicateFromTemplate(pageTemplateDocId);
|
||||||
workbench.openDoc({ docId, fromTab: 'true' });
|
workbench.openDoc(docId);
|
||||||
} else {
|
} else {
|
||||||
const doc = pageHelper.createPage(undefined, { show: false });
|
const doc = pageHelper.createPage(undefined, { show: false });
|
||||||
workbench.openDoc({ docId: doc.id, fromTab: 'true' });
|
workbench.openDoc(doc.id);
|
||||||
}
|
}
|
||||||
track.$.navigationPanel.$.createDoc();
|
track.$.navigationPanel.$.createDoc();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const AppTabJournal = ({ tab }: AppTabCustomFCProps) => {
|
|||||||
const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId));
|
const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId));
|
||||||
|
|
||||||
const handleOpenToday = useCallback(() => {
|
const handleOpenToday = useCallback(() => {
|
||||||
workbench.open('/journals', { at: 'active' });
|
workbench.open('/journals', { at: 'active', replaceHistory: true });
|
||||||
}, [workbench]);
|
}, [workbench]);
|
||||||
|
|
||||||
const Icon = journalDate ? JournalIcon : TodayIcon;
|
const Icon = journalDate ? JournalIcon : TodayIcon;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export * from './app-tabs';
|
export * from './app-tabs';
|
||||||
export * from './doc-card';
|
export * from './doc-card';
|
||||||
export * from './doc-info';
|
export * from './doc-info';
|
||||||
|
export * from './mobile-shell-host';
|
||||||
|
export * from './navigation/menu-host';
|
||||||
export * from './navigation-back';
|
export * from './navigation-back';
|
||||||
export * from './page-header';
|
export * from './page-header';
|
||||||
export * from './rename';
|
export * from './rename';
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ModalConfigContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ModalConfigContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<TabsMetadata | null>(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 (
|
||||||
|
<TabsMetadataContext.Provider value={value}>
|
||||||
|
<MobileNavigationMenuHost>{children}</MobileNavigationMenuHost>
|
||||||
|
<AppTabs
|
||||||
|
background={
|
||||||
|
metadata?.background ?? cssVarV2('layer/background/mobile/primary')
|
||||||
|
}
|
||||||
|
hidden={metadata?.hidden}
|
||||||
|
/>
|
||||||
|
</TabsMetadataContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
|
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
|
||||||
import { useService } from '@toeverything/infra';
|
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 {
|
export interface NavigationBackButtonProps extends IconButtonProps {
|
||||||
backAction?: () => void;
|
backAction?: () => void;
|
||||||
@@ -23,23 +23,15 @@ export const NavigationBackButton = ({
|
|||||||
style: propsStyle,
|
style: propsStyle,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: NavigationBackButtonProps) => {
|
}: NavigationBackButtonProps) => {
|
||||||
const navigationGesture = useService(NavigationGestureService);
|
const backCoordinator = useService(MobileBackCoordinator);
|
||||||
const isInsideModal = useIsInsideModal();
|
const isInsideModal = useIsInsideModal();
|
||||||
|
|
||||||
const handleRouteBack = useCallback(() => {
|
const handleRouteBack = useCallback(() => {
|
||||||
backAction ? backAction() : history.back();
|
if (backAction) return backAction();
|
||||||
}, [backAction]);
|
if (!backCoordinator.request('ui-back')) {
|
||||||
|
backCoordinator.request('ui-up');
|
||||||
useEffect(() => {
|
}
|
||||||
if (isInsideModal) return;
|
}, [backAction, backCoordinator]);
|
||||||
|
|
||||||
const prev = navigationGesture.enabled$.value;
|
|
||||||
navigationGesture.setEnabled(true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
navigationGesture.setEnabled(prev);
|
|
||||||
};
|
|
||||||
}, [isInsideModal, navigationGesture]);
|
|
||||||
|
|
||||||
const style = useMemo(() => ({ padding: 10, ...propsStyle }), [propsStyle]);
|
const style = useMemo(() => ({ padding: 10, ...propsStyle }), [propsStyle]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { NavigationPanelCollections } from './sections/collections';
|
|||||||
export { NavigationPanelFavorites } from './sections/favorites';
|
export { NavigationPanelFavorites } from './sections/favorites';
|
||||||
export { NavigationPanelOrganize } from './sections/organize';
|
export { NavigationPanelOrganize } from './sections/organize';
|
||||||
export { NavigationPanelTags } from './sections/tags';
|
export { NavigationPanelTags } from './sections/tags';
|
||||||
|
export { MobileNavigationVirtualScroller } from './virtual-scroller';
|
||||||
|
|||||||
+12
-10
@@ -1,6 +1,5 @@
|
|||||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||||
import { ToggleRightIcon } from '@blocksuite/icons/rc';
|
import { ToggleRightIcon } from '@blocksuite/icons/rc';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
@@ -91,9 +90,9 @@ export const CollapsibleSection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Root
|
<div
|
||||||
data-collapsed={collapsed}
|
data-collapsed={collapsed}
|
||||||
open={!collapsed}
|
data-state={collapsed ? 'closed' : 'open'}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
@@ -105,12 +104,15 @@ export const CollapsibleSection = ({
|
|||||||
data-testid={headerTestId}
|
data-testid={headerTestId}
|
||||||
className={headerClassName}
|
className={headerClassName}
|
||||||
/>
|
/>
|
||||||
<Collapsible.Content
|
{collapsed ? null : (
|
||||||
data-testid="collapsible-section-content"
|
<div
|
||||||
className={clsx(content, contentClassName)}
|
data-state="open"
|
||||||
>
|
data-testid="collapsible-section-content"
|
||||||
{children}
|
className={clsx(content, contentClassName)}
|
||||||
</Collapsible.Content>
|
>
|
||||||
</Collapsible.Root>
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<MenuHostContextValue | null>(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 }) => (
|
||||||
|
<Fragment key={index}>{view}</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MobileNavigationMenuHost = ({ children }: PropsWithChildren) => {
|
||||||
|
const location = useLiveData(
|
||||||
|
useService(WorkbenchService).workbench.location$
|
||||||
|
);
|
||||||
|
const [items, setItems] = useState<ReactNode>(null);
|
||||||
|
const close = useCallback(() => setItems(null), []);
|
||||||
|
const value = useMemo<MenuHostContextValue>(
|
||||||
|
() => ({ open: setItems, close }),
|
||||||
|
[close]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => close(), [close, location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuHostContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<MobileMenu
|
||||||
|
rootOptions={{
|
||||||
|
open: items !== null,
|
||||||
|
onOpenChange: open => !open && close(),
|
||||||
|
}}
|
||||||
|
items={items}
|
||||||
|
>
|
||||||
|
<span hidden />
|
||||||
|
</MobileMenu>
|
||||||
|
</MenuHostContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
+20
-18
@@ -18,8 +18,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
|||||||
import { NavigationPanelTreeNode } from '../../tree/node';
|
import { NavigationPanelTreeNode } from '../../tree/node';
|
||||||
import { NavigationPanelDocNode } from '../doc';
|
import { NavigationPanelDocNode } from '../doc';
|
||||||
import {
|
import {
|
||||||
useNavigationPanelCollectionNodeOperations,
|
NavigationPanelCollectionNodeMenu,
|
||||||
useNavigationPanelCollectionNodeOperationsMenu,
|
useNavigationPanelCollectionAddDoc,
|
||||||
} from './operations';
|
} from './operations';
|
||||||
|
|
||||||
const CollectionIcon = () => <ViewLayersIcon />;
|
const CollectionIcon = () => <ViewLayersIcon />;
|
||||||
@@ -72,24 +72,26 @@ export const NavigationPanelCollectionNode = ({
|
|||||||
});
|
});
|
||||||
}, [collection, workspaceDialogService]);
|
}, [collection, workspaceDialogService]);
|
||||||
|
|
||||||
const collectionOperations = useNavigationPanelCollectionNodeOperationsMenu(
|
const handleAddDocToCollection = useNavigationPanelCollectionAddDoc(
|
||||||
collectionId,
|
collectionId,
|
||||||
handleOpenCollapsed,
|
handleOpenCollapsed
|
||||||
handleEditCollection
|
|
||||||
);
|
);
|
||||||
const { handleAddDocToCollection } =
|
const menuTarget = useMemo(
|
||||||
useNavigationPanelCollectionNodeOperations(
|
() => (
|
||||||
|
<NavigationPanelCollectionNodeMenu
|
||||||
|
collectionId={collectionId}
|
||||||
|
handleAddDocToCollection={handleAddDocToCollection}
|
||||||
|
onOpenEdit={handleEditCollection}
|
||||||
|
additionalOperations={additionalOperations}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
additionalOperations,
|
||||||
collectionId,
|
collectionId,
|
||||||
handleOpenCollapsed,
|
handleAddDocToCollection,
|
||||||
handleEditCollection
|
handleEditCollection,
|
||||||
);
|
]
|
||||||
|
);
|
||||||
const finalOperations = useMemo(() => {
|
|
||||||
if (additionalOperations) {
|
|
||||||
return [...additionalOperations, ...collectionOperations];
|
|
||||||
}
|
|
||||||
return collectionOperations;
|
|
||||||
}, [additionalOperations, collectionOperations]);
|
|
||||||
|
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -103,7 +105,7 @@ export const NavigationPanelCollectionNode = ({
|
|||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
to={`/collection/${collection.id}`}
|
to={`/collection/${collection.id}`}
|
||||||
active={active}
|
active={active}
|
||||||
operations={finalOperations}
|
menuTarget={menuTarget}
|
||||||
data-testid={`navigation-panel-collection-${collectionId}`}
|
data-testid={`navigation-panel-collection-${collectionId}`}
|
||||||
>
|
>
|
||||||
<NavigationPanelCollectionNodeChildren
|
<NavigationPanelCollectionNodeChildren
|
||||||
|
|||||||
+67
-43
@@ -24,39 +24,22 @@ import {
|
|||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { MobileNavigationMenuItems } from '../../menu-host';
|
||||||
import { CollectionRenameSubMenu } from './dialog';
|
import { CollectionRenameSubMenu } from './dialog';
|
||||||
|
|
||||||
export const useNavigationPanelCollectionNodeOperations = (
|
export const useNavigationPanelCollectionAddDoc = (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
onOpenCollapsed: () => void,
|
onOpenCollapsed: () => void
|
||||||
onOpenEdit: () => void
|
|
||||||
) => {
|
) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const { workspaceService, collectionService } = useServices({
|
||||||
workbenchService,
|
|
||||||
workspaceService,
|
|
||||||
collectionService,
|
|
||||||
compatibleFavoriteItemsAdapter,
|
|
||||||
} = useServices({
|
|
||||||
WorkbenchService,
|
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
CollectionService,
|
CollectionService,
|
||||||
CompatibleFavoriteItemsAdapter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createPage } = usePageHelper(
|
const { createPage } = usePageHelper(
|
||||||
workspaceService.workspace.docCollection
|
workspaceService.workspace.docCollection
|
||||||
);
|
);
|
||||||
|
|
||||||
const favorite = useLiveData(
|
|
||||||
useMemo(
|
|
||||||
() =>
|
|
||||||
compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
|
|
||||||
[collectionId, compatibleFavoriteItemsAdapter]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const createAndAddDocument = useCallback(() => {
|
const createAndAddDocument = useCallback(() => {
|
||||||
const newDoc = createPage();
|
const newDoc = createPage();
|
||||||
collectionService.addDocToCollection(collectionId, newDoc.id);
|
collectionService.addDocToCollection(collectionId, newDoc.id);
|
||||||
@@ -66,7 +49,41 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
});
|
});
|
||||||
onOpenCollapsed();
|
onOpenCollapsed();
|
||||||
}, [collectionId, collectionService, createPage, 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(() => {
|
const handleToggleFavoriteCollection = useCallback(() => {
|
||||||
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||||
track.$.navigationPanel.organize.toggleFavorite({
|
track.$.navigationPanel.organize.toggleFavorite({
|
||||||
@@ -74,19 +91,6 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
});
|
});
|
||||||
}, [compatibleFavoriteItemsAdapter, collectionId]);
|
}, [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(() => {
|
const handleOpenInSplitView = useCallback(() => {
|
||||||
workbenchService.workbench.openCollection(collectionId, { at: 'beside' });
|
workbenchService.workbench.openCollection(collectionId, { at: 'beside' });
|
||||||
track.$.navigationPanel.organize.openInSplitView({
|
track.$.navigationPanel.organize.openInSplitView({
|
||||||
@@ -153,14 +157,14 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
|
|
||||||
export const useNavigationPanelCollectionNodeOperationsMenu = (
|
export const useNavigationPanelCollectionNodeOperationsMenu = (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
onOpenCollapsed: () => void,
|
handleAddDocToCollection: () => void,
|
||||||
onOpenEdit: () => void
|
onOpenEdit: () => void
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
favorite,
|
favorite,
|
||||||
handleAddDocToCollection,
|
handleAddDocToCollection: addDocToCollection,
|
||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
@@ -169,7 +173,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
|
|||||||
handleRename,
|
handleRename,
|
||||||
} = useNavigationPanelCollectionNodeOperations(
|
} = useNavigationPanelCollectionNodeOperations(
|
||||||
collectionId,
|
collectionId,
|
||||||
onOpenCollapsed,
|
handleAddDocToCollection,
|
||||||
onOpenEdit
|
onOpenEdit
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,7 +185,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
|
|||||||
view: (
|
view: (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="16"
|
size="16"
|
||||||
onClick={handleAddDocToCollection}
|
onClick={addDocToCollection}
|
||||||
tooltip={t[
|
tooltip={t[
|
||||||
'com.affine.rootAppSidebar.explorer.collection-add-tooltip'
|
'com.affine.rootAppSidebar.explorer.collection-add-tooltip'
|
||||||
]()}
|
]()}
|
||||||
@@ -209,10 +213,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
|
|||||||
{
|
{
|
||||||
index: 99,
|
index: 99,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem
|
<MenuItem prefixIcon={<PlusIcon />} onClick={addDocToCollection}>
|
||||||
prefixIcon={<PlusIcon />}
|
|
||||||
onClick={handleAddDocToCollection}
|
|
||||||
>
|
|
||||||
{t['New Page']()}
|
{t['New Page']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
@@ -272,7 +273,7 @@ export const useNavigationPanelCollectionNodeOperationsMenu = (
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
favorite,
|
favorite,
|
||||||
handleAddDocToCollection,
|
addDocToCollection,
|
||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
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 <MobileNavigationMenuItems operations={allOperations} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
import { Loading } from '@affine/component';
|
import { Loading } from '@affine/component';
|
||||||
import { Guard } from '@affine/core/components/guard';
|
|
||||||
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
|
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 { 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 { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
|
||||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import {
|
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
||||||
LiveData,
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
MANUALLY_STOP,
|
|
||||||
useLiveData,
|
|
||||||
useService,
|
|
||||||
useServices,
|
|
||||||
} from '@toeverything/infra';
|
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { MobileShellDataProjection } from '../../../../modules/navigation-projection';
|
||||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||||
import { NavigationPanelTreeNode } from '../../tree/node';
|
import { NavigationPanelTreeNode } from '../../tree/node';
|
||||||
import {
|
import {
|
||||||
useNavigationPanelDocNodeOperations,
|
NavigationPanelDocNodeMenu,
|
||||||
useNavigationPanelDocNodeOperationsMenu,
|
useNavigationPanelDocNodeAddLinkedPage,
|
||||||
} from './operations';
|
} from './operations';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
@@ -39,24 +28,13 @@ export const NavigationPanelDocNode = ({
|
|||||||
parentPath: string[];
|
parentPath: string[];
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const { docDisplayMetaService, featureFlagService } = useServices({
|
||||||
docsSearchService,
|
|
||||||
docsService,
|
|
||||||
globalContextService,
|
|
||||||
docDisplayMetaService,
|
|
||||||
featureFlagService,
|
|
||||||
workspaceService,
|
|
||||||
} = useServices({
|
|
||||||
DocsSearchService,
|
|
||||||
WorkspaceService,
|
|
||||||
DocsService,
|
|
||||||
GlobalContextService,
|
|
||||||
DocDisplayMetaService,
|
DocDisplayMetaService,
|
||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
});
|
});
|
||||||
const navigationPanelService = useService(NavigationPanelService);
|
const navigationPanelService = useService(NavigationPanelService);
|
||||||
const active =
|
const dataProjection = useService(MobileShellDataProjection);
|
||||||
useLiveData(globalContextService.globalContext.docId.$) === docId;
|
const entry = useLiveData(dataProjection.entry$(docId));
|
||||||
const path = useMemo(
|
const path = useMemo(
|
||||||
() => [...parentPath, `doc-${docId}`],
|
() => [...parentPath, `doc-${docId}`],
|
||||||
[parentPath, docId]
|
[parentPath, docId]
|
||||||
@@ -69,15 +47,12 @@ export const NavigationPanelDocNode = ({
|
|||||||
[navigationPanelService, path]
|
[navigationPanelService, path]
|
||||||
);
|
);
|
||||||
|
|
||||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
|
||||||
const DocIcon = useLiveData(
|
const DocIcon = useLiveData(
|
||||||
docDisplayMetaService.icon$(docId, {
|
docDisplayMetaService.icon$(docId, {
|
||||||
reference: isLinked,
|
reference: isLinked,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const docTitle = useLiveData(docDisplayMetaService.title$(docId));
|
|
||||||
const isInTrash = useLiveData(docRecord?.trash$);
|
|
||||||
const enableEmojiIcon = useLiveData(
|
const enableEmojiIcon = useLiveData(
|
||||||
featureFlagService.flags.enable_emoji_doc_icon.$
|
featureFlagService.flags.enable_emoji_doc_icon.$
|
||||||
);
|
);
|
||||||
@@ -90,109 +65,72 @@ export const NavigationPanelDocNode = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const children = useLiveData(
|
const children = useLiveData(
|
||||||
useMemo(
|
dataProjection.refsByDoc$.selector(refs => refs.get(docId))
|
||||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
);
|
||||||
[docsSearchService, docId]
|
useEffect(
|
||||||
)
|
() => (collapsed ? undefined : dataProjection.registerExpandedDoc(docId)),
|
||||||
|
[collapsed, dataProjection, docId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
const openNodeCollapsed = useCallback(
|
||||||
useLayoutEffect(() => {
|
() => setCollapsed(false),
|
||||||
if (collapsed) {
|
[setCollapsed]
|
||||||
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 operations = useNavigationPanelDocNodeOperationsMenu(docId, option);
|
const handleAddLinkedPage = useNavigationPanelDocNodeAddLinkedPage(
|
||||||
const { handleAddLinkedPage } = useNavigationPanelDocNodeOperations(
|
|
||||||
docId,
|
docId,
|
||||||
option
|
openNodeCollapsed
|
||||||
|
);
|
||||||
|
const menuTarget = useMemo(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelDocNodeMenu
|
||||||
|
docId={docId}
|
||||||
|
handleAddLinkedPage={handleAddLinkedPage}
|
||||||
|
additionalOperations={additionalOperations}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[additionalOperations, docId, handleAddLinkedPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalOperations = useMemo(() => {
|
if (entry?.trash !== false) {
|
||||||
if (additionalOperations) {
|
|
||||||
return [...operations, ...additionalOperations];
|
|
||||||
}
|
|
||||||
return operations;
|
|
||||||
}, [additionalOperations, operations]);
|
|
||||||
|
|
||||||
if (isInTrash || !docRecord) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationPanelTreeNode
|
<NavigationPanelTreeNode
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
name={docTitle}
|
name={entry.title}
|
||||||
extractEmojiAsIcon={enableEmojiIcon}
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
to={`/${docId}`}
|
to={`/${docId}`}
|
||||||
active={active}
|
active={entry.active}
|
||||||
postfix={
|
postfix={
|
||||||
referencesLoading &&
|
children === undefined &&
|
||||||
!collapsed && (
|
!collapsed && (
|
||||||
<div className={styles.loadingIcon}>
|
<div className={styles.loadingIcon}>
|
||||||
<Loading />
|
<Loading />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
operations={finalOperations}
|
menuTarget={menuTarget}
|
||||||
data-testid={`navigation-panel-doc-${docId}`}
|
data-testid={`navigation-panel-doc-${docId}`}
|
||||||
>
|
>
|
||||||
<Guard docId={docId} permission="Doc_Read">
|
{entry.canRead
|
||||||
{canRead =>
|
? children?.map((child, index) => (
|
||||||
canRead
|
<NavigationPanelDocNode
|
||||||
? children?.map((child, index) => (
|
key={`${child.docId}-${index}`}
|
||||||
<NavigationPanelDocNode
|
docId={child.docId}
|
||||||
key={`${child.docId}-${index}`}
|
isLinked
|
||||||
docId={child.docId}
|
parentPath={path}
|
||||||
isLinked
|
|
||||||
parentPath={path}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</Guard>
|
|
||||||
<Guard docId={docId} permission="Doc_Update">
|
|
||||||
{canEdit =>
|
|
||||||
canEdit ? (
|
|
||||||
<AddItemPlaceholder
|
|
||||||
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
|
||||||
onClick={handleAddLinkedPage}
|
|
||||||
/>
|
/>
|
||||||
) : null
|
))
|
||||||
}
|
: null}
|
||||||
</Guard>
|
{entry.canUpdate ? (
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||||
|
onClick={handleAddLinkedPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</NavigationPanelTreeNode>
|
</NavigationPanelTreeNode>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,35 +29,42 @@ import { useLiveData, useService, useServices } from '@toeverything/infra';
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { DocFrameScope, DocInfoSheet } from '../../../doc-info';
|
import { DocFrameScope, DocInfoSheet } from '../../../doc-info';
|
||||||
|
import { MobileNavigationMenuItems } from '../../menu-host';
|
||||||
import { DocRenameSubMenu } from './dialog';
|
import { DocRenameSubMenu } from './dialog';
|
||||||
|
|
||||||
export const useNavigationPanelDocNodeOperations = (
|
export const useNavigationPanelDocNodeAddLinkedPage = (
|
||||||
docId: string,
|
docId: string,
|
||||||
options: {
|
openNodeCollapsed: () => void
|
||||||
openNodeCollapsed: () => void;
|
|
||||||
}
|
|
||||||
) => {
|
) => {
|
||||||
const t = useI18n();
|
const { docsService, workspaceService } = useServices({
|
||||||
const {
|
|
||||||
workbenchService,
|
|
||||||
workspaceService,
|
|
||||||
docsService,
|
|
||||||
compatibleFavoriteItemsAdapter,
|
|
||||||
} = useServices({
|
|
||||||
DocsService,
|
DocsService,
|
||||||
WorkbenchService,
|
|
||||||
WorkspaceService,
|
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 { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||||
|
|
||||||
const { createPage } = usePageHelper(
|
|
||||||
workspaceService.workspace.docCollection
|
|
||||||
);
|
|
||||||
|
|
||||||
const favorite = useLiveData(
|
const favorite = useLiveData(
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc');
|
return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc');
|
||||||
@@ -112,15 +119,6 @@ export const useNavigationPanelDocNodeOperations = (
|
|||||||
});
|
});
|
||||||
}, [docId, workbenchService.workbench]);
|
}, [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(() => {
|
const handleToggleFavoriteDoc = useCallback(() => {
|
||||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||||
track.$.navigationPanel.organize.toggleFavorite({
|
track.$.navigationPanel.organize.toggleFavorite({
|
||||||
@@ -139,7 +137,6 @@ export const useNavigationPanelDocNodeOperations = (
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
|
||||||
handleDuplicate,
|
handleDuplicate,
|
||||||
handleToggleFavoriteDoc,
|
handleToggleFavoriteDoc,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
@@ -149,7 +146,6 @@ export const useNavigationPanelDocNodeOperations = (
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
|
||||||
handleDuplicate,
|
handleDuplicate,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
@@ -163,26 +159,28 @@ export const useNavigationPanelDocNodeOperations = (
|
|||||||
export const useNavigationPanelDocNodeOperationsMenu = (
|
export const useNavigationPanelDocNodeOperationsMenu = (
|
||||||
docId: string,
|
docId: string,
|
||||||
options: {
|
options: {
|
||||||
openInfoModal: () => void;
|
handleAddLinkedPage: () => void;
|
||||||
openNodeCollapsed: () => void;
|
|
||||||
}
|
}
|
||||||
): NodeOperation[] => {
|
): {
|
||||||
|
operations: NodeOperation[];
|
||||||
|
handleAddLinkedPage: () => void;
|
||||||
|
} => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
const { handleAddLinkedPage } = options;
|
||||||
const {
|
const {
|
||||||
favorite,
|
favorite,
|
||||||
handleAddLinkedPage,
|
|
||||||
handleDuplicate,
|
handleDuplicate,
|
||||||
handleToggleFavoriteDoc,
|
handleToggleFavoriteDoc,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleRename,
|
handleRename,
|
||||||
} = useNavigationPanelDocNodeOperations(docId, options);
|
} = useNavigationPanelDocNodeOperations(docId);
|
||||||
|
|
||||||
const docService = useService(DocsService);
|
const docService = useService(DocsService);
|
||||||
const docRecord = useLiveData(docService.list.doc$(docId));
|
const docRecord = useLiveData(docService.list.doc$(docId));
|
||||||
const title = useLiveData(docRecord?.title$);
|
const title = useLiveData(docRecord?.title$);
|
||||||
|
|
||||||
return useMemo(
|
const operations = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
index: 10,
|
index: 10,
|
||||||
@@ -301,4 +299,28 @@ export const useNavigationPanelDocNodeOperationsMenu = (
|
|||||||
title,
|
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 <MobileNavigationMenuItems operations={allOperations} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
AnimatedFolderIcon,
|
AnimatedFolderIcon,
|
||||||
IconButton,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuSeparator,
|
MenuSeparator,
|
||||||
MenuSub,
|
MenuSub,
|
||||||
@@ -12,7 +11,6 @@ import type {
|
|||||||
NodeOperation,
|
NodeOperation,
|
||||||
} from '@affine/core/desktop/components/navigation-panel';
|
} from '@affine/core/desktop/components/navigation-panel';
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||||
import {
|
import {
|
||||||
@@ -27,16 +25,16 @@ import {
|
|||||||
FolderIcon,
|
FolderIcon,
|
||||||
LayerIcon,
|
LayerIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
PlusIcon,
|
|
||||||
PlusThickIcon,
|
PlusThickIcon,
|
||||||
RemoveFolderIcon,
|
RemoveFolderIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
import { useLiveData, useService, useServices } from '@toeverything/infra';
|
||||||
import { difference } from 'lodash-es';
|
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 { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||||
|
import { MobileNavigationMenuItems } from '../../menu-host';
|
||||||
import { NavigationPanelTreeNode } from '../../tree/node';
|
import { NavigationPanelTreeNode } from '../../tree/node';
|
||||||
import { NavigationPanelCollectionNode } from '../collection';
|
import { NavigationPanelCollectionNode } from '../collection';
|
||||||
import { NavigationPanelDocNode } from '../doc';
|
import { NavigationPanelDocNode } from '../doc';
|
||||||
@@ -129,169 +127,39 @@ const NavigationPanelFolderIcon: NavigationPanelTreeNodeIcon = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const NavigationPanelFolderNodeFolder = ({
|
const NavigationPanelFolderMenu = ({
|
||||||
node,
|
nodeId,
|
||||||
operations: additionalOperations,
|
name,
|
||||||
parentPath,
|
handleDelete,
|
||||||
|
handleRename,
|
||||||
|
handleCreateSubfolder,
|
||||||
|
handleAddToFolder,
|
||||||
|
createSubTipRenderer,
|
||||||
|
additionalOperations,
|
||||||
}: {
|
}: {
|
||||||
node: FolderNode;
|
nodeId: string;
|
||||||
operations?: NodeOperation[];
|
name: string;
|
||||||
parentPath: 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 t = useI18n();
|
||||||
const { workspaceService, featureFlagService, workspaceDialogService } =
|
const operations = useMemo(
|
||||||
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 <FolderCreateTip input={input} parentName={name} />;
|
|
||||||
},
|
|
||||||
[name]
|
|
||||||
);
|
|
||||||
|
|
||||||
const folderOperations = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
inline: true,
|
|
||||||
view: (
|
|
||||||
<IconButton
|
|
||||||
size="16"
|
|
||||||
onClick={handleNewDoc}
|
|
||||||
tooltip={t[
|
|
||||||
'com.affine.rootAppSidebar.explorer.organize-add-tooltip'
|
|
||||||
]()}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</IconButton>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
index: 98,
|
index: 98,
|
||||||
view: (
|
view: (
|
||||||
<FolderRenameSubMenu
|
<FolderRenameSubMenu
|
||||||
initialName={name}
|
initialName={name}
|
||||||
onConfirm={handleRename}
|
onConfirm={handleRename}
|
||||||
menuProps={{
|
menuProps={{ triggerOptions: { 'data-testid': 'rename-folder' } }}
|
||||||
triggerOptions: { 'data-testid': 'rename-folder' },
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ index: 99, view: <MenuSeparator /> },
|
||||||
index: 99,
|
|
||||||
view: <MenuSeparator />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
view: (
|
view: (
|
||||||
@@ -315,9 +183,7 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
index: 102,
|
index: 102,
|
||||||
view: (
|
view: (
|
||||||
<MenuSub
|
<MenuSub
|
||||||
triggerOptions={{
|
triggerOptions={{ prefixIcon: <PlusThickIcon /> }}
|
||||||
prefixIcon: <PlusThickIcon />,
|
|
||||||
}}
|
|
||||||
items={
|
items={
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -327,14 +193,14 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => handleAddToFolder('tag')}
|
|
||||||
prefixIcon={<TagsIcon />}
|
prefixIcon={<TagsIcon />}
|
||||||
|
onClick={() => handleAddToFolder('tag')}
|
||||||
>
|
>
|
||||||
{t['com.affine.rootAppSidebar.organize.folder.add-tags']()}
|
{t['com.affine.rootAppSidebar.organize.folder.add-tags']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => handleAddToFolder('collection')}
|
|
||||||
prefixIcon={<LayerIcon />}
|
prefixIcon={<LayerIcon />}
|
||||||
|
onClick={() => handleAddToFolder('collection')}
|
||||||
>
|
>
|
||||||
{t[
|
{t[
|
||||||
'com.affine.rootAppSidebar.organize.folder.add-collections'
|
'com.affine.rootAppSidebar.organize.folder.add-collections'
|
||||||
@@ -347,21 +213,16 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
</MenuSub>
|
</MenuSub>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
index: 200,
|
index: 200,
|
||||||
view: node.id ? <FavoriteFolderOperation id={node.id} /> : null,
|
view: nodeId ? <FavoriteFolderOperation id={nodeId} /> : null,
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
index: 9999,
|
|
||||||
view: <MenuSeparator key="menu-separator" />,
|
|
||||||
},
|
},
|
||||||
|
{ index: 9999, view: <MenuSeparator /> },
|
||||||
{
|
{
|
||||||
index: 10000,
|
index: 10000,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
type={'danger'}
|
type="danger"
|
||||||
prefixIcon={<DeleteIcon />}
|
prefixIcon={<DeleteIcon />}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
@@ -369,25 +230,182 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
}, [
|
[
|
||||||
createSubTipRenderer,
|
createSubTipRenderer,
|
||||||
handleAddToFolder,
|
handleAddToFolder,
|
||||||
handleCreateSubfolder,
|
handleCreateSubfolder,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleNewDoc,
|
handleRename,
|
||||||
handleRename,
|
name,
|
||||||
name,
|
nodeId,
|
||||||
node.id,
|
t,
|
||||||
t,
|
]
|
||||||
]);
|
);
|
||||||
|
return (
|
||||||
|
<MobileNavigationMenuItems
|
||||||
|
operations={[...(additionalOperations ?? []), ...operations]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const finalOperations = useMemo(() => {
|
export const NavigationPanelFolderNodeMenu = ({
|
||||||
if (additionalOperations) {
|
nodeId,
|
||||||
return [...additionalOperations, ...folderOperations];
|
additionalOperations,
|
||||||
}
|
}: {
|
||||||
return folderOperations;
|
nodeId: string;
|
||||||
}, [additionalOperations, folderOperations]);
|
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 }) => (
|
||||||
|
<FolderCreateTip input={input} parentName={name} />
|
||||||
|
),
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
return (
|
||||||
|
<NavigationPanelFolderMenu
|
||||||
|
nodeId={nodeId}
|
||||||
|
name={name}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
handleRename={handleRename}
|
||||||
|
handleCreateSubfolder={handleCreateSubfolder}
|
||||||
|
handleAddToFolder={handleAddToFolder}
|
||||||
|
createSubTipRenderer={createSubTipRenderer}
|
||||||
|
additionalOperations={additionalOperations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelFolderNodeMenu
|
||||||
|
nodeId={node.id ?? ''}
|
||||||
|
additionalOperations={additionalOperations}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[additionalOperations, node.id]
|
||||||
|
);
|
||||||
|
|
||||||
const childrenOperations = useCallback(
|
const childrenOperations = useCallback(
|
||||||
(type: string, node: FolderNode) => {
|
(type: string, node: FolderNode) => {
|
||||||
@@ -432,7 +450,7 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
extractEmojiAsIcon={enableEmojiIcon}
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={handleCollapsedChange}
|
setCollapsed={handleCollapsedChange}
|
||||||
operations={finalOperations}
|
menuTarget={menuTarget}
|
||||||
data-testid={`navigation-panel-folder-${node.id}`}
|
data-testid={`navigation-panel-folder-${node.id}`}
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
data-role="navigation-panel-folder"
|
data-role="navigation-panel-folder"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
|||||||
import { NavigationPanelTreeNode } from '../../tree/node';
|
import { NavigationPanelTreeNode } from '../../tree/node';
|
||||||
import { NavigationPanelDocNode } from '../doc';
|
import { NavigationPanelDocNode } from '../doc';
|
||||||
import {
|
import {
|
||||||
useNavigationPanelTagNodeOperations,
|
NavigationPanelTagNodeMenu,
|
||||||
useNavigationPanelTagNodeOperationsMenu,
|
useNavigationPanelTagNodeNewDoc,
|
||||||
} from './operations';
|
} from './operations';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
@@ -67,21 +67,24 @@ export const NavigationPanelTagNode = ({
|
|||||||
[tagColor]
|
[tagColor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const option = useMemo(
|
const openNodeCollapsed = useCallback(
|
||||||
() => ({
|
() => setCollapsed(false),
|
||||||
openNodeCollapsed: () => setCollapsed(false),
|
|
||||||
}),
|
|
||||||
[setCollapsed]
|
[setCollapsed]
|
||||||
);
|
);
|
||||||
const operations = useNavigationPanelTagNodeOperationsMenu(tagId, option);
|
const handleNewDoc = useNavigationPanelTagNodeNewDoc(
|
||||||
const { handleNewDoc } = useNavigationPanelTagNodeOperations(tagId, option);
|
tagId,
|
||||||
|
openNodeCollapsed
|
||||||
const finalOperations = useMemo(() => {
|
);
|
||||||
if (additionalOperations) {
|
const menuTarget = useMemo(
|
||||||
return [...operations, ...additionalOperations];
|
() => (
|
||||||
}
|
<NavigationPanelTagNodeMenu
|
||||||
return operations;
|
tagId={tagId}
|
||||||
}, [additionalOperations, operations]);
|
handleNewDoc={handleNewDoc}
|
||||||
|
additionalOperations={additionalOperations}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[additionalOperations, handleNewDoc, tagId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!tagRecord) {
|
if (!tagRecord) {
|
||||||
return null;
|
return null;
|
||||||
@@ -95,7 +98,7 @@ export const NavigationPanelTagNode = ({
|
|||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
to={`/tag/${tagId}`}
|
to={`/tag/${tagId}`}
|
||||||
active={active}
|
active={active}
|
||||||
operations={finalOperations}
|
menuTarget={menuTarget}
|
||||||
data-testid={`navigation-panel-tag-${tagId}`}
|
data-testid={`navigation-panel-tag-${tagId}`}
|
||||||
aria-label={tagName}
|
aria-label={tagName}
|
||||||
data-role="navigation-panel-tag"
|
data-role="navigation-panel-tag"
|
||||||
|
|||||||
@@ -26,27 +26,47 @@ import {
|
|||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { MobileNavigationMenuItems } from '../../menu-host';
|
||||||
import { TagRenameSubMenu } from './dialog';
|
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 = (
|
export const useNavigationPanelTagNodeOperations = (
|
||||||
tagId: string,
|
tagId: string,
|
||||||
{
|
{
|
||||||
openNodeCollapsed,
|
handleNewDoc,
|
||||||
}: {
|
}: {
|
||||||
openNodeCollapsed: () => void;
|
handleNewDoc: () => void;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const {
|
||||||
workbenchService,
|
workbenchService,
|
||||||
workspaceService,
|
|
||||||
tagService,
|
tagService,
|
||||||
favoriteService,
|
favoriteService,
|
||||||
workspaceDialogService,
|
workspaceDialogService,
|
||||||
globalCacheService,
|
globalCacheService,
|
||||||
} = useServices({
|
} = useServices({
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
WorkspaceService,
|
|
||||||
TagService,
|
TagService,
|
||||||
DocsService,
|
DocsService,
|
||||||
FavoriteService,
|
FavoriteService,
|
||||||
@@ -60,19 +80,6 @@ export const useNavigationPanelTagNodeOperations = (
|
|||||||
);
|
);
|
||||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
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(() => {
|
const handleMoveToTrash = useCallback(() => {
|
||||||
tagService.tagList.deleteTag(tagId);
|
tagService.tagList.deleteTag(tagId);
|
||||||
track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' });
|
track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' });
|
||||||
@@ -214,7 +221,7 @@ export const useNavigationPanelTagNodeOperations = (
|
|||||||
export const useNavigationPanelTagNodeOperationsMenu = (
|
export const useNavigationPanelTagNodeOperationsMenu = (
|
||||||
tagId: string,
|
tagId: string,
|
||||||
option: {
|
option: {
|
||||||
openNodeCollapsed: () => void;
|
handleNewDoc: () => void;
|
||||||
}
|
}
|
||||||
): NodeOperation[] => {
|
): NodeOperation[] => {
|
||||||
const t = useI18n();
|
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 <MobileNavigationMenuItems operations={allOperations} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MobileMenu } from '@affine/component';
|
|
||||||
import {
|
import {
|
||||||
type BaseNavigationPanelTreeNodeProps,
|
type BaseNavigationPanelTreeNodeProps,
|
||||||
NavigationPanelTreeContext,
|
NavigationPanelTreeContext,
|
||||||
@@ -6,21 +5,32 @@ import {
|
|||||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||||
import { extractEmojiIcon } from '@affine/core/utils';
|
import { extractEmojiIcon } from '@affine/core/utils';
|
||||||
import { ArrowDownSmallIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
import { ArrowDownSmallIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import {
|
import {
|
||||||
Fragment,
|
type AriaAttributes,
|
||||||
|
type AriaRole,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { SwipeMenu } from '../../swipe-menu';
|
import { SwipeMenu } from '../../swipe-menu';
|
||||||
|
import {
|
||||||
|
MobileNavigationMenuItems,
|
||||||
|
useMobileNavigationMenuHost,
|
||||||
|
} from '../menu-host';
|
||||||
import * as styles from './node.css';
|
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'] = [];
|
const EMPTY_OPERATIONS: BaseNavigationPanelTreeNodeProps['operations'] = [];
|
||||||
|
|
||||||
@@ -39,6 +49,7 @@ export const NavigationPanelTreeNode = ({
|
|||||||
postfix,
|
postfix,
|
||||||
childrenOperations = EMPTY_OPERATIONS,
|
childrenOperations = EMPTY_OPERATIONS,
|
||||||
childrenPlaceholder,
|
childrenPlaceholder,
|
||||||
|
menuTarget,
|
||||||
linkComponent: LinkComponent = WorkbenchLink,
|
linkComponent: LinkComponent = WorkbenchLink,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: NavigationPanelTreeNodeProps) => {
|
}: NavigationPanelTreeNodeProps) => {
|
||||||
@@ -47,8 +58,7 @@ export const NavigationPanelTreeNode = ({
|
|||||||
// If no onClick or to is provided, clicking on the node will toggle the collapse state
|
// If no onClick or to is provided, clicking on the node will toggle the collapse state
|
||||||
const clickForCollapse = !onClick && !to && !disabled;
|
const clickForCollapse = !onClick && !to && !disabled;
|
||||||
const [childCount, setChildCount] = useState(0);
|
const [childCount, setChildCount] = useState(0);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const menuHost = useMobileNavigationMenuHost();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const { emoji, name } = useMemo(() => {
|
const { emoji, name } = useMemo(() => {
|
||||||
if (!extractEmojiAsIcon || !rawName) {
|
if (!extractEmojiAsIcon || !rawName) {
|
||||||
@@ -71,6 +81,13 @@ export const NavigationPanelTreeNode = ({
|
|||||||
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
||||||
};
|
};
|
||||||
}, [operations]);
|
}, [operations]);
|
||||||
|
const openMenu = useCallback(() => {
|
||||||
|
if (menuTarget || menuOperations.length > 0) {
|
||||||
|
menuHost.open(
|
||||||
|
menuTarget ?? <MobileNavigationMenuItems operations={menuOperations} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [menuHost, menuOperations, menuTarget]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => {
|
const contextValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -114,22 +131,17 @@ export const NavigationPanelTreeNode = ({
|
|||||||
data-disabled={disabled}
|
data-disabled={disabled}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
{menuOperations.length > 0 ? (
|
{menuTarget || menuOperations.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
// prevent jump to page
|
// prevent jump to page
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
openMenu();
|
||||||
}}
|
}}
|
||||||
|
className={styles.iconContainer}
|
||||||
|
data-testid="menu-trigger"
|
||||||
>
|
>
|
||||||
<MobileMenu
|
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
||||||
items={menuOperations.map(({ view, index }) => (
|
|
||||||
<Fragment key={index}>{view}</Fragment>
|
|
||||||
))}
|
|
||||||
>
|
|
||||||
<div className={styles.iconContainer} data-testid="menu-trigger">
|
|
||||||
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
|
||||||
</div>
|
|
||||||
</MobileMenu>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.iconContainer}>
|
<div className={styles.iconContainer}>
|
||||||
@@ -157,29 +169,23 @@ export const NavigationPanelTreeNode = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Root
|
<div
|
||||||
open={!collapsed}
|
data-state={collapsed ? 'closed' : 'open'}
|
||||||
onOpenChange={setCollapsed}
|
|
||||||
style={assignInlineVars({
|
style={assignInlineVars({
|
||||||
[styles.levelIndent]: `${level * 20}px`,
|
[styles.levelIndent]: `${level * 20}px`,
|
||||||
})}
|
})}
|
||||||
ref={rootRef}
|
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
>
|
>
|
||||||
<SwipeMenu
|
<SwipeMenu
|
||||||
onExecute={useCallback(() => setMenuOpen(true), [])}
|
onExecute={openMenu}
|
||||||
menu={
|
menu={
|
||||||
<MobileMenu
|
<button
|
||||||
rootOptions={useMemo(
|
type="button"
|
||||||
() => ({ open: menuOpen, onOpenChange: setMenuOpen }),
|
onClick={openMenu}
|
||||||
[menuOpen]
|
data-testid="swipe-menu-trigger"
|
||||||
)}
|
|
||||||
items={menuOperations.map(({ view, index }) => (
|
|
||||||
<Fragment key={index}>{view}</Fragment>
|
|
||||||
))}
|
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon fontSize={24} />
|
<MoreHorizontalIcon fontSize={24} />
|
||||||
</MobileMenu>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.contentContainer} data-open={!collapsed}>
|
<div className={styles.contentContainer} data-open={!collapsed}>
|
||||||
@@ -196,15 +202,17 @@ export const NavigationPanelTreeNode = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SwipeMenu>
|
</SwipeMenu>
|
||||||
<Collapsible.Content>
|
{collapsed ? null : (
|
||||||
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
|
<div data-state="open">
|
||||||
<div className={styles.collapseContentPlaceholder}>
|
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
|
||||||
{childCount === 0 && !collapsed && childrenPlaceholder}
|
<div className={styles.collapseContentPlaceholder}>
|
||||||
|
{childCount === 0 && childrenPlaceholder}
|
||||||
|
</div>
|
||||||
|
<NavigationPanelTreeContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</NavigationPanelTreeContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
<NavigationPanelTreeContext.Provider value={contextValue}>
|
)}
|
||||||
{collapsed ? null : children}
|
</div>
|
||||||
</NavigationPanelTreeContext.Provider>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = () => (
|
||||||
|
<>
|
||||||
|
<HomeHeader />
|
||||||
|
<RecentDocs />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Footer = () => <SafeArea bottom />;
|
||||||
|
|
||||||
|
const List = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
function NavigationList(props, ref) {
|
||||||
|
return <div {...props} ref={ref} role="tree" className={styles.list} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtuosoComponents: Components<MobileNavigationRow> = {
|
||||||
|
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 <ViewLayersIcon />;
|
||||||
|
if (entry.kind === 'folder') return <FolderIcon />;
|
||||||
|
if (entry.kind === 'tag') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={styles.tagIcon}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
data-testid="navigation-panel-tag-icon-dot"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <PageIcon />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => <RowIcon entry={entry} />, [entry]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<NavigationPanelTreeRoot>
|
||||||
|
<NavigationPanelTreeNode
|
||||||
|
icon={Icon}
|
||||||
|
name={entry.name}
|
||||||
|
to={to}
|
||||||
|
active={entry.active}
|
||||||
|
collapsed={!row.expanded}
|
||||||
|
setCollapsed={toggle}
|
||||||
|
menuTarget={menuTarget}
|
||||||
|
data-testid={`navigation-panel-${entry.kind}-${entry.id}`}
|
||||||
|
data-role={`navigation-panel-${entry.kind}`}
|
||||||
|
role="treeitem"
|
||||||
|
aria-label={entry.name}
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
aria-expanded={row.expandable ? row.expanded : undefined}
|
||||||
|
/>
|
||||||
|
</NavigationPanelTreeRoot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelDocNodeMenu
|
||||||
|
docId={entry.id}
|
||||||
|
handleAddLinkedPage={handleAddLinkedPage}
|
||||||
|
additionalOperations={
|
||||||
|
row.relationId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 999,
|
||||||
|
view: (
|
||||||
|
<RemoveFromFolderMenuItem relationId={row.relationId} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[entry.id, handleAddLinkedPage, row.relationId]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<EntityRowShell
|
||||||
|
row={row}
|
||||||
|
entry={entry}
|
||||||
|
toggle={toggle}
|
||||||
|
menuTarget={menuTarget}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelCollectionNodeMenu
|
||||||
|
collectionId={entry.id}
|
||||||
|
handleAddDocToCollection={handleAdd}
|
||||||
|
onOpenEdit={handleEdit}
|
||||||
|
additionalOperations={
|
||||||
|
row.relationId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 999,
|
||||||
|
view: (
|
||||||
|
<RemoveFromFolderMenuItem relationId={row.relationId} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[entry.id, handleAdd, handleEdit, row.relationId]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<EntityRowShell
|
||||||
|
row={row}
|
||||||
|
entry={entry}
|
||||||
|
toggle={toggle}
|
||||||
|
menuTarget={menuTarget}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelTagNodeMenu
|
||||||
|
tagId={entry.id}
|
||||||
|
handleNewDoc={handleNewDoc}
|
||||||
|
additionalOperations={
|
||||||
|
row.relationId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 999,
|
||||||
|
view: (
|
||||||
|
<RemoveFromFolderMenuItem relationId={row.relationId} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[entry.id, handleNewDoc, row.relationId]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<EntityRowShell
|
||||||
|
row={row}
|
||||||
|
entry={entry}
|
||||||
|
toggle={toggle}
|
||||||
|
menuTarget={menuTarget}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RemoveFromFolderMenuItem = ({ relationId }: { relationId: string }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const organizeService = useService(OrganizeService);
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
type="danger"
|
||||||
|
prefixIcon={<RemoveFolderIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
organizeService.folderTree.folderNode$(relationId).value?.delete()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<NavigationPanelFolderNodeMenu
|
||||||
|
nodeId={entry.id}
|
||||||
|
additionalOperations={[
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<PageIcon />} onClick={handleNewDoc}>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.new-doc']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[entry.id, handleNewDoc, t]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<EntityRowShell
|
||||||
|
row={row}
|
||||||
|
entry={entry}
|
||||||
|
toggle={toggle}
|
||||||
|
menuTarget={menuTarget}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntityRow = ({
|
||||||
|
row,
|
||||||
|
entry,
|
||||||
|
toggle,
|
||||||
|
}: {
|
||||||
|
row: MobileNavigationRow;
|
||||||
|
entry: MobileShellNavigationEntry;
|
||||||
|
toggle: () => void;
|
||||||
|
}) => {
|
||||||
|
if (entry.kind === 'doc') {
|
||||||
|
return <DocRow row={row} entry={entry} toggle={toggle} />;
|
||||||
|
}
|
||||||
|
if (entry.kind === 'collection') {
|
||||||
|
return <CollectionRow row={row} entry={entry} toggle={toggle} />;
|
||||||
|
}
|
||||||
|
if (entry.kind === 'tag') {
|
||||||
|
return <TagRow row={row} entry={entry} toggle={toggle} />;
|
||||||
|
}
|
||||||
|
return <FolderRow row={row} entry={entry} toggle={toggle} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FolderNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const handleCreate = useFolderNewDoc(row.entityId ?? '');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['com.affine.rootAppSidebar.organize.folder.new-doc']()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
data-testid="new-folder-in-folder-button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocNewLinkedActionEnabled = ({
|
||||||
|
row,
|
||||||
|
docId,
|
||||||
|
}: {
|
||||||
|
row: MobileNavigationRow;
|
||||||
|
docId: string;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const keepExpanded = useCallback(() => {}, []);
|
||||||
|
const handleCreate = useNavigationPanelDocNodeAddLinkedPage(
|
||||||
|
docId,
|
||||||
|
keepExpanded
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
data-testid="navigation-panel-doc-add-linked-page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocNewLinkedAction = ({ row }: { row: MobileNavigationRow }) => {
|
||||||
|
const dataProjection = useService(MobileShellDataProjection);
|
||||||
|
const docId = row.entityId ?? '';
|
||||||
|
const entry = useLiveData(dataProjection.navigationEntry$('doc', docId));
|
||||||
|
return entry?.canUpdate === true ? (
|
||||||
|
<DocNewLinkedActionEnabled row={row} docId={docId} />
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollectionNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const keepExpanded = useCallback(() => {}, []);
|
||||||
|
const handleCreate = useNavigationPanelCollectionAddDoc(
|
||||||
|
row.entityId ?? '',
|
||||||
|
keepExpanded
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['New Page']()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
data-testid="navigation-panel-collection-add-page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagNewDocAction = ({ row }: { row: MobileNavigationRow }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const keepExpanded = useCallback(() => {}, []);
|
||||||
|
const handleCreate = useNavigationPanelTagNodeNewDoc(
|
||||||
|
row.entityId ?? '',
|
||||||
|
keepExpanded
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['New Page']()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
data-testid="navigation-panel-tag-add-page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={styles.permission}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{entry.canRead === false
|
||||||
|
? t['com.affine.no-permission']()
|
||||||
|
: t['Loading']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EntityRow row={row} entry={entry} toggle={toggle} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AddItemPlaceholder
|
||||||
|
data-testid="navigation-panel-bar-add-favorite-button"
|
||||||
|
label={t['New Page']()}
|
||||||
|
onClick={handleCreate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
icon={<AddOrganizeIcon />}
|
||||||
|
data-testid="navigation-panel-bar-add-organize-button"
|
||||||
|
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
/>
|
||||||
|
<FolderRenameDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onConfirm={handleCreate}
|
||||||
|
descRenderer={FolderCreateTip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AddItemPlaceholder
|
||||||
|
icon={<AddCollectionIcon />}
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AddItemPlaceholder
|
||||||
|
icon={<AddTagIcon />}
|
||||||
|
data-testid="navigation-panel-add-tag-button"
|
||||||
|
label={t[
|
||||||
|
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
||||||
|
]()}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
/>
|
||||||
|
<TagRenameDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onConfirm={handleCreate}
|
||||||
|
enableAnimation
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionAction = ({ section }: { section: string }) => {
|
||||||
|
if (section === 'favorites') return <FavoritesAction />;
|
||||||
|
if (section === 'organize') return <OrganizeAction />;
|
||||||
|
if (section === 'collections') return <CollectionsAction />;
|
||||||
|
if (section === 'tags') return <TagsAction />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavigationAction = ({ row }: { row: MobileNavigationRow }) => {
|
||||||
|
if (row.action === 'section') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.row}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={2}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<SectionAction section={row.entityId ?? ''} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.action === 'folder-new-doc') return <FolderNewDocAction row={row} />;
|
||||||
|
if (row.action === 'doc-new-linked') return <DocNewLinkedAction row={row} />;
|
||||||
|
if (row.action === 'collection-new-doc') {
|
||||||
|
return <CollectionNewDocAction row={row} />;
|
||||||
|
}
|
||||||
|
if (row.action === 'tag-new-doc') return <TagNewDocAction row={row} />;
|
||||||
|
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<ReadonlySet<string>>(() => {
|
||||||
|
if (restoredSnapshot) return new Set(restoredSnapshot.expanded);
|
||||||
|
const initial = new Set<string>();
|
||||||
|
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<VirtuosoHandle>(null);
|
||||||
|
const restoredAnchor = useRef(false);
|
||||||
|
const restoredFocus = useRef(false);
|
||||||
|
const [visibleRange, setVisibleRange] = useState<ListRange>({
|
||||||
|
startIndex: 0,
|
||||||
|
endIndex: 30,
|
||||||
|
});
|
||||||
|
const rows = useMemo(
|
||||||
|
() => projection.flatten(sections, expanded),
|
||||||
|
[expanded, projection, sections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hydratedRows = useRef(new Set<string>());
|
||||||
|
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<HTMLElement>(
|
||||||
|
`[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<HTMLElement>(
|
||||||
|
'[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 (
|
||||||
|
<div className={styles.scroller}>
|
||||||
|
<Virtuoso
|
||||||
|
ref={listRef}
|
||||||
|
className={styles.virtuoso}
|
||||||
|
data={rows}
|
||||||
|
restoreStateFrom={restoredSnapshot?.listState}
|
||||||
|
overscan={{ main: 600, reverse: 300 }}
|
||||||
|
computeItemKey={(_, row) => row.id}
|
||||||
|
rangeChanged={setVisibleRange}
|
||||||
|
onFocusCapture={() => {
|
||||||
|
afterNextPaint(saveSnapshot);
|
||||||
|
}}
|
||||||
|
components={virtuosoComponents}
|
||||||
|
itemContent={(_, row) => {
|
||||||
|
if (row.kind === 'section') {
|
||||||
|
const section = row.entityId as keyof typeof sectionTitles;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.section}
|
||||||
|
data-first-section={section === 'favorites'}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
data-collapsible
|
||||||
|
data-collapsed={!row.expanded}
|
||||||
|
data-testid={`navigation-panel-${section}`}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={1}
|
||||||
|
aria-expanded={row.expanded}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sectionButton}
|
||||||
|
onClick={() => toggle(row)}
|
||||||
|
>
|
||||||
|
{t[sectionTitles[section]]()}
|
||||||
|
<ToggleRightIcon className={styles.sectionCollapseIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.kind === 'placeholder') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.permission}
|
||||||
|
data-navigation-row-id={row.id}
|
||||||
|
style={{ paddingLeft: rowIndent(row.depth) }}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={row.depth + 1}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{row.blocked === 'denied'
|
||||||
|
? t['com.affine.no-permission']()
|
||||||
|
: t['Loading']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.kind === 'action') {
|
||||||
|
return <NavigationAction row={row} />;
|
||||||
|
}
|
||||||
|
return <ProjectedEntityRow row={row} toggle={() => toggle(row)} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+93
@@ -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%',
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
|
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
|
||||||
|
|
||||||
import { AppTabs } from '../app-tabs';
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -25,7 +24,6 @@ export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
|
|||||||
<main className={styles.page} {...attrs} data-tab={tab}>
|
<main className={styles.page} {...attrs} data-tab={tab}>
|
||||||
{header}
|
{header}
|
||||||
{children}
|
{children}
|
||||||
{tab ? <AppTabs fixed={false} /> : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
import { globalVars } from '../../styles/variables.css';
|
||||||
|
|
||||||
export const page = style({
|
export const page = style({
|
||||||
width: '100dvw',
|
width: '100dvw',
|
||||||
height: '100dvh',
|
height: '100dvh',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
selectors: {
|
||||||
|
'&[data-tab="true"]': {
|
||||||
|
paddingBottom: globalVars.appTabSafeArea,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const RenameDialog = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
preserveEditingFocusOnAction
|
||||||
width="100%"
|
width="100%"
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const emitValue = useMemo(() => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user