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