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

Some files were not shown because too many files have changed in this diff Show More