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

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