mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat(infra): framework
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
OauthProvidersQuery,
|
||||
ServerConfigQuery,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import type { ServerConfigStore } from '../stores/server-config';
|
||||
|
||||
type LowercaseServerFeature = Lowercase<ServerFeature>;
|
||||
type ServerFeatureRecord = {
|
||||
[key in LowercaseServerFeature]: boolean;
|
||||
};
|
||||
|
||||
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
||||
OauthProvidersQuery['serverConfig'];
|
||||
|
||||
export class ServerConfig extends Entity {
|
||||
readonly config$ = new LiveData<ServerConfigType | null>(null);
|
||||
|
||||
readonly features$ = this.config$.map(config => {
|
||||
return config
|
||||
? Array.from(new Set(config.features)).reduce((acc, cur) => {
|
||||
acc[cur.toLowerCase() as LowercaseServerFeature] = true;
|
||||
return acc;
|
||||
}, {} as ServerFeatureRecord)
|
||||
: null;
|
||||
});
|
||||
|
||||
readonly credentialsRequirement$ = this.config$.map(config => {
|
||||
return config ? config.credentialsRequirement : null;
|
||||
});
|
||||
|
||||
constructor(private readonly store: ServerConfigStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise<ServerConfigType>(signal =>
|
||||
this.store.fetchServerConfig(signal)
|
||||
).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(config => {
|
||||
this.config$.next(config);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
revalidateIfNeeded = () => {
|
||||
if (!this.config$.value) {
|
||||
this.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
134
packages/frontend/core/src/modules/cloud/entities/session.ts
Normal file
134
packages/frontend/core/src/modules/cloud/entities/session.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { validateAndReduceImage } from '../../../utils/reduce-image';
|
||||
import type { AccountProfile, AuthStore } from '../stores/auth';
|
||||
|
||||
export interface AuthSessionInfo {
|
||||
account: AuthAccountInfo;
|
||||
}
|
||||
|
||||
export interface AuthAccountInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
email?: string;
|
||||
info?: AccountProfile | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthSessionUnauthenticated {
|
||||
status: 'unauthenticated';
|
||||
}
|
||||
|
||||
export interface AuthSessionAuthenticated {
|
||||
status: 'authenticated';
|
||||
session: AuthSessionInfo;
|
||||
}
|
||||
|
||||
export class AuthSession extends Entity {
|
||||
id = 'affine-cloud' as const;
|
||||
|
||||
session$: LiveData<AuthSessionUnauthenticated | AuthSessionAuthenticated> =
|
||||
LiveData.from(this.store.watchCachedAuthSession(), null).map(session =>
|
||||
session
|
||||
? {
|
||||
status: 'authenticated',
|
||||
session: session as AuthSessionInfo,
|
||||
}
|
||||
: {
|
||||
status: 'unauthenticated',
|
||||
}
|
||||
);
|
||||
|
||||
status$ = this.session$.map(session => session.status);
|
||||
|
||||
account$ = this.session$.map(session =>
|
||||
session.status === 'authenticated' ? session.session.account : null
|
||||
);
|
||||
|
||||
waitForAuthenticated = (signal?: AbortSignal) =>
|
||||
this.session$.waitFor(
|
||||
session => session.status === 'authenticated',
|
||||
signal
|
||||
) as Promise<AuthSessionAuthenticated>;
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
|
||||
constructor(private readonly store: AuthStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() =>
|
||||
fromPromise(this.getSession()).pipe(
|
||||
backoffRetry({
|
||||
count: Infinity,
|
||||
}),
|
||||
mergeMap(sessionInfo => {
|
||||
this.store.setCachedAuthSession(sessionInfo);
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => {
|
||||
this.isRevalidating$.next(true);
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.isRevalidating$.next(false);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private async getSession(): Promise<AuthSessionInfo | null> {
|
||||
const session = await this.store.fetchSession();
|
||||
|
||||
if (session?.user) {
|
||||
const account = {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
label: session.user.name,
|
||||
avatar: session.user.avatarUrl,
|
||||
info: session.user,
|
||||
};
|
||||
const result = {
|
||||
account,
|
||||
};
|
||||
return result;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForRevalidation() {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
await this.store.removeAvatar();
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
const reducedFile = await validateAndReduceImage(file);
|
||||
await this.store.uploadAvatar(reducedFile);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async updateLabel(label: string) {
|
||||
await this.store.updateLabel(label);
|
||||
console.log('updateLabel');
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PricesQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { ServerConfigService } from '../services/server-config';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
|
||||
export class SubscriptionPrices extends Entity {
|
||||
prices$ = new LiveData<PricesQuery['prices'] | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
proPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'Pro') : null
|
||||
);
|
||||
aiPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'AI') : null
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly store: SubscriptionStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
// ensure server config is loaded
|
||||
this.serverConfigService.serverConfig.revalidateIfNeeded();
|
||||
|
||||
const serverConfig =
|
||||
await this.serverConfigService.serverConfig.features$.waitForNonNull(
|
||||
signal
|
||||
);
|
||||
|
||||
if (!serverConfig.payment) {
|
||||
// No payment feature, no subscription
|
||||
return [];
|
||||
}
|
||||
return this.store.fetchSubscriptionPrices(signal);
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.prices$),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { AuthService } from '../services/auth';
|
||||
import type { ServerConfigService } from '../services/server-config';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
|
||||
export type SubscriptionType = NonNullable<
|
||||
SubscriptionQuery['currentUser']
|
||||
>['subscriptions'][number];
|
||||
|
||||
export class Subscription extends Entity {
|
||||
// undefined means no user, null means loading
|
||||
subscription$ = new LiveData<SubscriptionType[] | null | undefined>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
/**
|
||||
* Primary subscription is the subscription that is not AI.
|
||||
*/
|
||||
primary$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan !== SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
isFree$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Free)
|
||||
: null
|
||||
);
|
||||
isPro$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Pro)
|
||||
: null
|
||||
);
|
||||
isSelfHosted$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.some(sub => sub.plan === SubscriptionPlan.SelfHosted)
|
||||
: null
|
||||
);
|
||||
ai$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly store: SubscriptionStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async resumeSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
|
||||
await this.store.mutateResumeSubscription(idempotencyKey, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async cancelSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
|
||||
await this.store.mutateCancelSubscription(idempotencyKey, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async setSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
recurring: SubscriptionRecurring,
|
||||
plan?: SubscriptionPlan
|
||||
) {
|
||||
await this.store.setSubscriptionRecurring(idempotencyKey, recurring, plan);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async waitForRevalidation() {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) => {
|
||||
return fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return undefined; // no subscription if no user
|
||||
}
|
||||
|
||||
// ensure server config is loaded
|
||||
this.serverConfigService.serverConfig.revalidateIfNeeded();
|
||||
|
||||
const serverConfig =
|
||||
await this.serverConfigService.serverConfig.features$.waitForNonNull(
|
||||
signal
|
||||
);
|
||||
|
||||
if (!serverConfig.payment) {
|
||||
// No payment feature, no subscription
|
||||
return {
|
||||
userId: accountId,
|
||||
subscriptions: [],
|
||||
};
|
||||
}
|
||||
const { userId, subscriptions } =
|
||||
await this.store.fetchSubscriptions(signal);
|
||||
if (userId !== accountId) {
|
||||
// The user has changed, ignore the result
|
||||
this.authService.session.revalidate();
|
||||
await this.authService.session.waitForRevalidation();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: userId,
|
||||
subscriptions: subscriptions,
|
||||
};
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
this.store.setCachedSubscriptions(
|
||||
data.userId,
|
||||
data.subscriptions
|
||||
);
|
||||
this.subscription$.next(data.subscriptions);
|
||||
} else {
|
||||
this.subscription$.next(undefined);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
},
|
||||
({ accountId }) => {
|
||||
this.reset();
|
||||
if (!accountId) {
|
||||
this.subscription$.next(null);
|
||||
} else {
|
||||
this.subscription$.next(this.store.getCachedSubscriptions(accountId));
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.subscription$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
this.error$.next(null);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { AuthService } from '../services/auth';
|
||||
import type { UserFeatureStore } from '../stores/user-feature';
|
||||
|
||||
export class UserFeature extends Entity {
|
||||
// undefined means no user, null means loading
|
||||
features$ = new LiveData<FeatureType[] | null | undefined>(null);
|
||||
isEarlyAccess$ = this.features$.map(features =>
|
||||
features === null
|
||||
? null
|
||||
: features?.some(f => f === FeatureType.EarlyAccess)
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly store: UserFeatureStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) => {
|
||||
return fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return; // no feature if no user
|
||||
}
|
||||
|
||||
const { userId, features } = await this.store.getUserFeatures(signal);
|
||||
if (userId !== accountId) {
|
||||
// The user has changed, ignore the result
|
||||
this.authService.session.revalidate();
|
||||
await this.authService.session.waitForRevalidation();
|
||||
return;
|
||||
}
|
||||
return {
|
||||
userId: userId,
|
||||
features: features,
|
||||
};
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
this.features$.next(data.features);
|
||||
} else {
|
||||
this.features$.next(null);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.features$.next(null);
|
||||
this.error$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
}
|
||||
}
|
||||
131
packages/frontend/core/src/modules/cloud/entities/user-quota.ts
Normal file
131
packages/frontend/core/src/modules/cloud/entities/user-quota.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { QuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import bytes from 'bytes';
|
||||
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../error';
|
||||
import type { AuthService } from '../services/auth';
|
||||
import type { UserQuotaStore } from '../stores/user-quota';
|
||||
|
||||
export class UserQuota extends Entity {
|
||||
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota']>(null);
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
/** Formatted used storage */
|
||||
usedFormatted$ = this.used$.map(used =>
|
||||
used !== null ? bytes.format(used) : null
|
||||
);
|
||||
/** Maximum storage limit in bytes */
|
||||
max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null));
|
||||
/** Maximum storage limit formatted */
|
||||
maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null));
|
||||
|
||||
aiActionLimit$ = new LiveData<number | 'unlimited' | null>(null);
|
||||
aiActionUsed$ = new LiveData<number | null>(null);
|
||||
|
||||
/** Percentage of storage used */
|
||||
percent$ = LiveData.computed(get => {
|
||||
const max = get(this.max$);
|
||||
const used = get(this.used$);
|
||||
if (max === null || used === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
100,
|
||||
Math.max(0.5, Number(((used / max) * 100).toFixed(4)))
|
||||
);
|
||||
});
|
||||
|
||||
color$ = this.percent$.map(percent =>
|
||||
percent !== null
|
||||
? percent > 80
|
||||
? cssVar('errorColor')
|
||||
: cssVar('processingColor')
|
||||
: null
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly store: UserQuotaStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) =>
|
||||
fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return; // no quota if no user
|
||||
}
|
||||
const { quota, aiQuota, used } =
|
||||
await this.store.fetchUserQuota(signal);
|
||||
|
||||
return { quota, aiQuota, used };
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
const { aiQuota, quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
this.aiActionUsed$.next(aiQuota.used);
|
||||
this.aiActionLimit$.next(
|
||||
aiQuota.limit === null ? 'unlimited' : aiQuota.limit
|
||||
); // fix me: unlimited status
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
this.aiActionUsed$.next(null);
|
||||
this.aiActionLimit$.next(null);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
),
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
this.aiActionUsed$.next(null);
|
||||
this.aiActionLimit$.next(null);
|
||||
this.error$.next(null);
|
||||
this.isRevalidating$.next(false);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
21
packages/frontend/core/src/modules/cloud/error.ts
Normal file
21
packages/frontend/core/src/modules/cloud/error.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class NetworkError extends Error {
|
||||
constructor(public readonly originError: Error) {
|
||||
super(`Network error: ${originError.message}`);
|
||||
this.stack = originError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNetworkError(error: Error): error is NetworkError {
|
||||
return error instanceof NetworkError;
|
||||
}
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(public readonly originError: Error) {
|
||||
super(`Server error: ${originError.message}`);
|
||||
this.stack = originError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackendError(error: Error): error is BackendError {
|
||||
return error instanceof BackendError;
|
||||
}
|
||||
64
packages/frontend/core/src/modules/cloud/index.ts
Normal file
64
packages/frontend/core/src/modules/cloud/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type { AuthAccountInfo } from './entities/session';
|
||||
export {
|
||||
BackendError,
|
||||
isBackendError,
|
||||
isNetworkError,
|
||||
NetworkError,
|
||||
} from './error';
|
||||
export { AccountChanged, AuthService } from './services/auth';
|
||||
export { FetchService } from './services/fetch';
|
||||
export { GraphQLService } from './services/graphql';
|
||||
export { ServerConfigService } from './services/server-config';
|
||||
export { SubscriptionService } from './services/subscription';
|
||||
export { UserFeatureService } from './services/user-feature';
|
||||
export { UserQuotaService } from './services/user-quota';
|
||||
export { WebSocketService } from './services/websocket';
|
||||
|
||||
import {
|
||||
type Framework,
|
||||
GlobalCacheService,
|
||||
GlobalStateService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { ServerConfig } from './entities/server-config';
|
||||
import { AuthSession } from './entities/session';
|
||||
import { Subscription } from './entities/subscription';
|
||||
import { SubscriptionPrices } from './entities/subscription-prices';
|
||||
import { UserFeature } from './entities/user-feature';
|
||||
import { UserQuota } from './entities/user-quota';
|
||||
import { AuthService } from './services/auth';
|
||||
import { FetchService } from './services/fetch';
|
||||
import { GraphQLService } from './services/graphql';
|
||||
import { ServerConfigService } from './services/server-config';
|
||||
import { SubscriptionService } from './services/subscription';
|
||||
import { UserFeatureService } from './services/user-feature';
|
||||
import { UserQuotaService } from './services/user-quota';
|
||||
import { WebSocketService } from './services/websocket';
|
||||
import { AuthStore } from './stores/auth';
|
||||
import { ServerConfigStore } from './stores/server-config';
|
||||
import { SubscriptionStore } from './stores/subscription';
|
||||
import { UserFeatureStore } from './stores/user-feature';
|
||||
import { UserQuotaStore } from './stores/user-quota';
|
||||
|
||||
export function configureCloudModule(framework: Framework) {
|
||||
framework
|
||||
.service(FetchService)
|
||||
.service(GraphQLService, [FetchService])
|
||||
.service(WebSocketService)
|
||||
.service(ServerConfigService)
|
||||
.entity(ServerConfig, [ServerConfigStore])
|
||||
.store(ServerConfigStore, [GraphQLService])
|
||||
.service(AuthService, [FetchService, AuthStore])
|
||||
.store(AuthStore, [FetchService, GraphQLService, GlobalStateService])
|
||||
.entity(AuthSession, [AuthStore])
|
||||
.service(SubscriptionService, [SubscriptionStore])
|
||||
.store(SubscriptionStore, [GraphQLService, GlobalCacheService])
|
||||
.entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore])
|
||||
.entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore])
|
||||
.service(UserQuotaService)
|
||||
.store(UserQuotaStore, [GraphQLService])
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore])
|
||||
.service(UserFeatureService)
|
||||
.entity(UserFeature, [AuthService, UserFeatureStore])
|
||||
.store(UserFeatureStore, [GraphQLService]);
|
||||
}
|
||||
161
packages/frontend/core/src/modules/cloud/services/auth.ts
Normal file
161
packages/frontend/core/src/modules/cloud/services/auth.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { OAuthProviderType } from '@affine/graphql';
|
||||
import {
|
||||
ApplicationFocused,
|
||||
ApplicationStarted,
|
||||
createEvent,
|
||||
OnEvent,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { distinctUntilChanged, map, skip } from 'rxjs';
|
||||
|
||||
import { type AuthAccountInfo, AuthSession } from '../entities/session';
|
||||
import type { AuthStore } from '../stores/auth';
|
||||
import type { FetchService } from './fetch';
|
||||
|
||||
// Emit when account changed
|
||||
export const AccountChanged = createEvent<AuthAccountInfo | null>(
|
||||
'AccountChanged'
|
||||
);
|
||||
|
||||
export const AccountLoggedIn = createEvent<AuthAccountInfo>('AccountLoggedIn');
|
||||
|
||||
export const AccountLoggedOut =
|
||||
createEvent<AuthAccountInfo>('AccountLoggedOut');
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
|
||||
export class AuthService extends Service {
|
||||
session = this.framework.createEntity(AuthSession);
|
||||
|
||||
constructor(
|
||||
private readonly fetchService: FetchService,
|
||||
private readonly store: AuthStore
|
||||
) {
|
||||
super();
|
||||
|
||||
this.session.account$
|
||||
.pipe(
|
||||
map(a => ({
|
||||
id: a?.id,
|
||||
account: a,
|
||||
})),
|
||||
distinctUntilChanged((a, b) => a.id === b.id), // only emit when the value changes
|
||||
skip(1) // skip the initial value
|
||||
)
|
||||
.subscribe(({ account }) => {
|
||||
if (account === null) {
|
||||
this.eventBus.emit(AccountLoggedOut, account);
|
||||
} else {
|
||||
this.eventBus.emit(AccountLoggedIn, account);
|
||||
}
|
||||
this.eventBus.emit(AccountChanged, account);
|
||||
});
|
||||
}
|
||||
|
||||
private onApplicationStart() {
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
private onApplicationFocused() {
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
async sendEmailMagicLink(
|
||||
email: string,
|
||||
verifyToken: string,
|
||||
challenge?: string
|
||||
) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (challenge) {
|
||||
searchParams.set('challenge', challenge);
|
||||
}
|
||||
searchParams.set('token', verifyToken);
|
||||
const redirectUri = new URL(location.href);
|
||||
if (environment.isDesktop) {
|
||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
||||
}
|
||||
searchParams.set('redirect_uri', redirectUri.toString());
|
||||
|
||||
const res = await this.fetchService.fetch(
|
||||
'/api/auth/sign-in?' + searchParams.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res?.ok) {
|
||||
throw new Error('Failed to send email');
|
||||
}
|
||||
}
|
||||
|
||||
async signInOauth(provider: OAuthProviderType) {
|
||||
if (environment.isDesktop) {
|
||||
await apis?.ui.openExternal(
|
||||
`${
|
||||
runtimeConfig.serverUrlPrefix
|
||||
}/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
|
||||
'/open-app/signin-redirect'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
location.href = `${
|
||||
runtimeConfig.serverUrlPrefix
|
||||
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
|
||||
location.pathname
|
||||
)}`;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async signInPassword(credential: { email: string; password: string }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
const redirectUri = new URL(location.href);
|
||||
if (environment.isDesktop) {
|
||||
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
|
||||
}
|
||||
searchParams.set('redirect_uri', redirectUri.toString());
|
||||
|
||||
const res = await this.fetchService.fetch(
|
||||
'/api/auth/sign-in?' + searchParams.toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credential),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to sign in');
|
||||
}
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.fetchService.fetch('/api/auth/sign-out');
|
||||
this.store.setCachedAuthSession(null);
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
private buildRedirectUri(callbackUrl: string) {
|
||||
const params: string[][] = [];
|
||||
if (environment.isDesktop && window.appInfo.schema) {
|
||||
params.push(['schema', window.appInfo.schema]);
|
||||
}
|
||||
const query =
|
||||
params.length > 0
|
||||
? '?' +
|
||||
params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
return callbackUrl + query;
|
||||
}
|
||||
|
||||
checkUserByEmail(email: string) {
|
||||
return this.store.checkUserByEmail(email);
|
||||
}
|
||||
}
|
||||
84
packages/frontend/core/src/modules/cloud/services/fetch.ts
Normal file
84
packages/frontend/core/src/modules/cloud/services/fetch.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { fromPromise, Service } from '@toeverything/infra';
|
||||
|
||||
import { BackendError, NetworkError } from '../error';
|
||||
|
||||
export function getAffineCloudBaseUrl(): string {
|
||||
if (environment.isDesktop) {
|
||||
return runtimeConfig.serverUrlPrefix;
|
||||
}
|
||||
const { protocol, hostname, port } = window.location;
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
}
|
||||
|
||||
const logger = new DebugLogger('affine:fetch');
|
||||
|
||||
export type FetchInit = RequestInit & { timeout?: number };
|
||||
|
||||
export class FetchService extends Service {
|
||||
rxFetch = (
|
||||
input: string,
|
||||
init?: RequestInit & {
|
||||
// https://github.com/microsoft/TypeScript/issues/54472
|
||||
priority?: 'auto' | 'low' | 'high';
|
||||
} & {
|
||||
traceEvent?: string;
|
||||
}
|
||||
) => {
|
||||
return fromPromise(signal => {
|
||||
return this.fetch(input, { signal, ...init });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch with custom custom timeout and error handling.
|
||||
*/
|
||||
fetch = async (input: string, init?: FetchInit): Promise<Response> => {
|
||||
logger.debug('fetch', input);
|
||||
const externalSignal = init?.signal;
|
||||
if (externalSignal?.aborted) {
|
||||
throw externalSignal.reason;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
externalSignal?.addEventListener('abort', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const timeout = init?.timeout ?? 15000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort('timeout');
|
||||
}, timeout);
|
||||
|
||||
const res = await fetch(new URL(input, getAffineCloudBaseUrl()), {
|
||||
...init,
|
||||
signal: abortController.signal,
|
||||
}).catch(err => {
|
||||
logger.debug('network error', err);
|
||||
throw new NetworkError(err);
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (res.status === 504) {
|
||||
const error = new Error('Gateway Timeout');
|
||||
logger.debug('network error', error);
|
||||
throw new NetworkError(error);
|
||||
}
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
'backend error',
|
||||
new Error(`${res.status} ${res.statusText}`)
|
||||
);
|
||||
let reason: string | any = '';
|
||||
if (res.headers.get('Content-Type')?.includes('application/json')) {
|
||||
try {
|
||||
reason = await res.json();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw new BackendError(
|
||||
new Error(`${res.status} ${res.statusText}`, reason)
|
||||
);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
53
packages/frontend/core/src/modules/cloud/services/graphql.ts
Normal file
53
packages/frontend/core/src/modules/cloud/services/graphql.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
gqlFetcherFactory,
|
||||
GraphQLError,
|
||||
type GraphQLQuery,
|
||||
type QueryOptions,
|
||||
type QueryResponse,
|
||||
} from '@affine/graphql';
|
||||
import { fromPromise, Service } from '@toeverything/infra';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import { BackendError } from '../error';
|
||||
import { AuthService } from './auth';
|
||||
import type { FetchService } from './fetch';
|
||||
|
||||
export class GraphQLService extends Service {
|
||||
constructor(private readonly fetcher: FetchService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly rawGql = gqlFetcherFactory('/graphql', this.fetcher.fetch);
|
||||
|
||||
rxGql = <Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): Observable<QueryResponse<Query>> => {
|
||||
return fromPromise(signal => {
|
||||
return this.gql({
|
||||
...options,
|
||||
context: {
|
||||
signal,
|
||||
...options.context,
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
};
|
||||
|
||||
gql = async <Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>
|
||||
): Promise<QueryResponse<Query>> => {
|
||||
try {
|
||||
return await this.rawGql(options);
|
||||
} catch (err) {
|
||||
if (err instanceof Array) {
|
||||
for (const error of err) {
|
||||
if (error instanceof GraphQLError && error.extensions?.code === 403) {
|
||||
this.framework.get(AuthService).session.revalidate();
|
||||
}
|
||||
}
|
||||
throw new BackendError(new Error('Graphql Error'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { ServerConfig } from '../entities/server-config';
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
export class ServerConfigService extends Service {
|
||||
serverConfig = this.framework.createEntity(ServerConfig);
|
||||
|
||||
private onApplicationStart() {
|
||||
this.serverConfig.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { Subscription } from '../entities/subscription';
|
||||
import { SubscriptionPrices } from '../entities/subscription-prices';
|
||||
import type { SubscriptionStore } from '../stores/subscription';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class SubscriptionService extends Service {
|
||||
subscription = this.framework.createEntity(Subscription);
|
||||
prices = this.framework.createEntity(SubscriptionPrices);
|
||||
|
||||
constructor(private readonly store: SubscriptionStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
async createCheckoutSession(input: CreateCheckoutSessionInput) {
|
||||
return await this.store.createCheckoutSession(input);
|
||||
}
|
||||
|
||||
private onAccountChanged() {
|
||||
this.subscription.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { UserFeature } from '../entities/user-feature';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class UserFeatureService extends Service {
|
||||
userFeature = this.framework.createEntity(UserFeature);
|
||||
|
||||
private onAccountChanged() {
|
||||
this.userFeature.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import { UserQuota } from '../entities/user-quota';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class UserQuotaService extends Service {
|
||||
quota = this.framework.createEntity(UserQuota);
|
||||
|
||||
private onAccountChanged() {
|
||||
this.quota.revalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { Manager } from 'socket.io-client';
|
||||
|
||||
import { getAffineCloudBaseUrl } from '../services/fetch';
|
||||
import { AccountChanged } from './auth';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.reconnect)
|
||||
export class WebSocketService extends Service {
|
||||
ioManager: Manager = new Manager(`${getAffineCloudBaseUrl()}/`, {
|
||||
autoConnect: false,
|
||||
transports: ['websocket'],
|
||||
secure: location.protocol === 'https:',
|
||||
});
|
||||
sockets: Set<Socket> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
newSocket(): Socket {
|
||||
const socket = this.ioManager.socket('/');
|
||||
this.sockets.add(socket);
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
reconnect(): void {
|
||||
for (const socket of this.sockets) {
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
for (const socket of this.sockets) {
|
||||
socket.connect();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
packages/frontend/core/src/modules/cloud/stores/auth.ts
Normal file
97
packages/frontend/core/src/modules/cloud/stores/auth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
getUserQuery,
|
||||
removeAvatarMutation,
|
||||
updateUserProfileMutation,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalStateService } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { AuthSessionInfo } from '../entities/session';
|
||||
import type { FetchService } from '../services/fetch';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export interface AccountProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
hasPassword: boolean;
|
||||
avatarUrl: string | null;
|
||||
emailVerified: string | null;
|
||||
}
|
||||
|
||||
export class AuthStore extends Store {
|
||||
constructor(
|
||||
private readonly fetchService: FetchService,
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalStateService: GlobalStateService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchCachedAuthSession() {
|
||||
return this.globalStateService.globalState.watch<AuthSessionInfo>(
|
||||
'affine-cloud-auth'
|
||||
);
|
||||
}
|
||||
|
||||
setCachedAuthSession(session: AuthSessionInfo | null) {
|
||||
this.globalStateService.globalState.set('affine-cloud-auth', session);
|
||||
}
|
||||
|
||||
async fetchSession() {
|
||||
const url = `/api/auth/session`;
|
||||
const options: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await this.fetchService.fetch(url, options);
|
||||
const data = (await res.json()) as {
|
||||
user?: AccountProfile | null;
|
||||
};
|
||||
if (!res.ok)
|
||||
throw new Error('Get session fetch error: ' + JSON.stringify(data));
|
||||
return data; // Return null if data empty
|
||||
}
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
await this.gqlService.gql({
|
||||
query: uploadAvatarMutation,
|
||||
variables: {
|
||||
avatar: file,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
await this.gqlService.gql({
|
||||
query: removeAvatarMutation,
|
||||
});
|
||||
}
|
||||
|
||||
async updateLabel(label: string) {
|
||||
await this.gqlService.gql({
|
||||
query: updateUserProfileMutation,
|
||||
variables: {
|
||||
input: {
|
||||
name: label,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkUserByEmail(email: string) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: getUserQuery,
|
||||
variables: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
return {
|
||||
isExist: !!data.user,
|
||||
hasPassword: !!data.user?.hasPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
oauthProvidersQuery,
|
||||
serverConfigQuery,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { ServerConfigType } from '../entities/server-config';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class ServerConfigStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchServerConfig(
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<ServerConfigType> {
|
||||
const serverConfigData = await this.gqlService.gql({
|
||||
query: serverConfigQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) {
|
||||
const oauthProvidersData = await this.gqlService.gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
}
|
||||
}
|
||||
130
packages/frontend/core/src/modules/cloud/stores/subscription.ts
Normal file
130
packages/frontend/core/src/modules/cloud/stores/subscription.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
CreateCheckoutSessionInput,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
} from '@affine/graphql';
|
||||
import {
|
||||
cancelSubscriptionMutation,
|
||||
createCheckoutSessionMutation,
|
||||
pricesQuery,
|
||||
resumeSubscriptionMutation,
|
||||
subscriptionQuery,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalCacheService } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { SubscriptionType } from '../entities/subscription';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
const SUBSCRIPTION_CACHE_KEY = 'subscription:';
|
||||
|
||||
export class SubscriptionStore extends Store {
|
||||
constructor(
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalCacheService: GlobalCacheService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchSubscriptions(abortSignal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: subscriptionQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.currentUser) {
|
||||
throw new Error('No logged in');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.currentUser?.id,
|
||||
subscriptions: data.currentUser?.subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
async mutateResumeSubscription(
|
||||
idempotencyKey: string,
|
||||
plan?: SubscriptionPlan,
|
||||
abortSignal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: resumeSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
},
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return data.resumeSubscription;
|
||||
}
|
||||
|
||||
async mutateCancelSubscription(
|
||||
idempotencyKey: string,
|
||||
plan?: SubscriptionPlan,
|
||||
abortSignal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: cancelSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
},
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
return data.cancelSubscription;
|
||||
}
|
||||
|
||||
getCachedSubscriptions(userId: string) {
|
||||
return this.globalCacheService.globalCache.get<SubscriptionType[]>(
|
||||
SUBSCRIPTION_CACHE_KEY + userId
|
||||
);
|
||||
}
|
||||
|
||||
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
|
||||
return this.globalCacheService.globalCache.set(
|
||||
SUBSCRIPTION_CACHE_KEY + userId,
|
||||
subscriptions
|
||||
);
|
||||
}
|
||||
|
||||
setSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
recurring: SubscriptionRecurring,
|
||||
plan?: SubscriptionPlan
|
||||
) {
|
||||
return this.gqlService.gql({
|
||||
query: updateSubscriptionMutation,
|
||||
variables: {
|
||||
idempotencyKey,
|
||||
plan,
|
||||
recurring,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createCheckoutSession(input: CreateCheckoutSessionInput) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: createCheckoutSessionMutation,
|
||||
variables: { input },
|
||||
});
|
||||
return data.createCheckoutSession;
|
||||
}
|
||||
|
||||
async fetchSubscriptionPrices(abortSignal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: pricesQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
return data.prices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getUserFeaturesQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class UserFeatureStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getUserFeatures(signal: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: getUserFeaturesQuery,
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return {
|
||||
userId: data.currentUser?.id,
|
||||
features: data.currentUser?.features,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { quotaQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export class UserQuotaStore extends Store {
|
||||
constructor(private readonly graphqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchUserQuota(abortSignal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: quotaQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.currentUser) {
|
||||
throw new Error('No logged in');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.currentUser.id,
|
||||
aiQuota: data.currentUser.copilot.quota,
|
||||
quota: data.currentUser.quota,
|
||||
used: data.collectAllBlobSizes.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1,15 @@
|
||||
export * from './service';
|
||||
export { CollectionService } from './services/collection';
|
||||
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { CollectionService } from './services/collection';
|
||||
|
||||
export function configureCollectionModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(CollectionService, [WorkspaceService]);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
DeleteCollectionInfo,
|
||||
DeletedCollection,
|
||||
} from '@affine/env/filter';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Array as YArray } from 'yjs';
|
||||
|
||||
@@ -13,15 +13,19 @@ const SETTING_KEY = 'setting';
|
||||
const COLLECTIONS_KEY = 'collections';
|
||||
const COLLECTIONS_TRASH_KEY = 'collections_trash';
|
||||
|
||||
export class CollectionService {
|
||||
constructor(private readonly workspace: Workspace) {}
|
||||
export class CollectionService extends Service {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get doc() {
|
||||
return this.workspace.docCollection.doc;
|
||||
return this.workspaceService.workspace.docCollection.doc;
|
||||
}
|
||||
|
||||
private get setting() {
|
||||
return this.workspace.docCollection.doc.getMap(SETTING_KEY);
|
||||
return this.workspaceService.workspace.docCollection.doc.getMap(
|
||||
SETTING_KEY
|
||||
);
|
||||
}
|
||||
|
||||
private get collectionsYArray(): YArray<Collection> | undefined {
|
||||
@@ -105,7 +109,7 @@ export class CollectionService {
|
||||
return;
|
||||
}
|
||||
const set = new Set(ids);
|
||||
this.workspace.docCollection.doc.transact(() => {
|
||||
this.workspaceService.workspace.docCollection.doc.transact(() => {
|
||||
const indexList: number[] = [];
|
||||
const list: Collection[] = [];
|
||||
collectionsYArray.forEach((collection, i) => {
|
||||
33
packages/frontend/core/src/modules/index.ts
Normal file
33
packages/frontend/core/src/modules/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { configureQuotaModule } from '@affine/core/modules/quota';
|
||||
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureCloudModule } from './cloud';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configurePermissionsModule } from './permissions';
|
||||
import { configureWorkspacePropertiesModule } from './properties';
|
||||
import { configureRightSidebarModule } from './right-sidebar';
|
||||
import { configureShareDocsModule } from './share-doc';
|
||||
import { configureStorageImpls } from './storage';
|
||||
import { configureTagModule } from './tag';
|
||||
import { configureTelemetryModule } from './telemetry';
|
||||
import { configureWorkbenchModule } from './workbench';
|
||||
|
||||
export function configureCommonModules(framework: Framework) {
|
||||
configureInfraModules(framework);
|
||||
configureCollectionModule(framework);
|
||||
configureNavigationModule(framework);
|
||||
configureRightSidebarModule(framework);
|
||||
configureTagModule(framework);
|
||||
configureWorkbenchModule(framework);
|
||||
configureWorkspacePropertiesModule(framework);
|
||||
configureCloudModule(framework);
|
||||
configureQuotaModule(framework);
|
||||
configurePermissionsModule(framework);
|
||||
configureShareDocsModule(framework);
|
||||
configureTelemetryModule(framework);
|
||||
}
|
||||
|
||||
export function configureImpls(framework: Framework) {
|
||||
configureStorageImpls(framework);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ServiceProvider } from '@toeverything/infra';
|
||||
import {
|
||||
ServiceProviderContext,
|
||||
useLiveData,
|
||||
useService,
|
||||
} from '@toeverything/infra';
|
||||
import type React from 'react';
|
||||
|
||||
import { CurrentWorkspaceService } from '../../workspace';
|
||||
|
||||
export const GlobalScopeProvider: React.FC<
|
||||
React.PropsWithChildren<{ provider: ServiceProvider }>
|
||||
> = ({ provider: rootProvider, children }) => {
|
||||
const currentWorkspaceService = useService(CurrentWorkspaceService, {
|
||||
provider: rootProvider,
|
||||
});
|
||||
|
||||
const workspaceProvider = useLiveData(
|
||||
currentWorkspaceService.currentWorkspace$
|
||||
)?.services;
|
||||
|
||||
return (
|
||||
<ServiceProviderContext.Provider value={workspaceProvider ?? rootProvider}>
|
||||
{children}
|
||||
</ServiceProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { SidebarTabName } from './entities/sidebar-tab';
|
||||
export { sidebarTabs } from './entities/sidebar-tabs';
|
||||
export type { SidebarTabName } from './multi-tabs/sidebar-tab';
|
||||
export { sidebarTabs } from './multi-tabs/sidebar-tabs';
|
||||
export { MultiTabSidebarBody } from './view/body';
|
||||
export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher';
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
PageIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageRecord } from '@toeverything/infra';
|
||||
import type { DocRecord } from '@toeverything/infra';
|
||||
import {
|
||||
Doc,
|
||||
PageRecordList,
|
||||
DocService,
|
||||
DocsService,
|
||||
useLiveData,
|
||||
useService,
|
||||
Workspace,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
@@ -43,21 +43,16 @@ const CountDisplay = ({
|
||||
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||
};
|
||||
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
pageRecord: PageRecord;
|
||||
docRecord: DocRecord;
|
||||
right?: ReactNode;
|
||||
}
|
||||
const PageItem = ({
|
||||
pageRecord,
|
||||
right,
|
||||
className,
|
||||
...attrs
|
||||
}: PageItemProps) => {
|
||||
const title = useLiveData(pageRecord.title$);
|
||||
const mode = useLiveData(pageRecord.mode$);
|
||||
const workspace = useService(Workspace);
|
||||
const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
|
||||
const title = useLiveData(docRecord.title$);
|
||||
const mode = useLiveData(docRecord.mode$);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const { isJournal } = useJournalInfoHelper(
|
||||
workspace.docCollection,
|
||||
pageRecord.id
|
||||
docRecord.id
|
||||
);
|
||||
|
||||
const Icon = isJournal
|
||||
@@ -92,8 +87,8 @@ interface JournalBlockProps {
|
||||
|
||||
const EditorJournalPanel = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const doc = useService(Doc);
|
||||
const workspace = useService(Workspace);
|
||||
const doc = useService(DocService).doc;
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const { journalDate, isJournal } = useJournalInfoHelper(
|
||||
workspace.docCollection,
|
||||
doc.id
|
||||
@@ -159,11 +154,11 @@ const EditorJournalPanel = () => {
|
||||
};
|
||||
|
||||
const sortPagesByDate = (
|
||||
pages: PageRecord[],
|
||||
docs: DocRecord[],
|
||||
field: 'updatedDate' | 'createDate',
|
||||
order: 'asc' | 'desc' = 'desc'
|
||||
) => {
|
||||
return [...pages].sort((a, b) => {
|
||||
return [...docs].sort((a, b) => {
|
||||
return (
|
||||
(order === 'asc' ? 1 : -1) *
|
||||
dayjs(b.meta$.value[field]).diff(dayjs(a.meta$.value[field]))
|
||||
@@ -183,27 +178,26 @@ const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
|
||||
);
|
||||
};
|
||||
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
|
||||
const workspace = useService(Workspace);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const t = useAFFiNEI18N();
|
||||
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||
const pageRecordList = useService(PageRecordList);
|
||||
const pageRecords = useLiveData(pageRecordList.records$);
|
||||
const docRecords = useLiveData(useService(DocsService).list.docs$);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
|
||||
const getTodaysPages = useCallback(
|
||||
(field: 'createDate' | 'updatedDate') => {
|
||||
return sortPagesByDate(
|
||||
pageRecords.filter(pageRecord => {
|
||||
const meta = pageRecord.meta$.value;
|
||||
docRecords.filter(docRecord => {
|
||||
const meta = docRecord.meta$.value;
|
||||
if (meta.trash) return false;
|
||||
return meta[field] && dayjs(meta[field]).isSame(date, 'day');
|
||||
}),
|
||||
field
|
||||
);
|
||||
},
|
||||
[date, pageRecords]
|
||||
[date, docRecords]
|
||||
);
|
||||
|
||||
const createdToday = useMemo(
|
||||
@@ -279,7 +273,7 @@ const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
|
||||
}
|
||||
tabIndex={name === activeItem ? 0 : -1}
|
||||
key={index}
|
||||
pageRecord={pageRecord}
|
||||
docRecord={pageRecord}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,25 +290,25 @@ const MAX_CONFLICT_COUNT = 5;
|
||||
interface ConflictListProps
|
||||
extends PropsWithChildren,
|
||||
HTMLAttributes<HTMLDivElement> {
|
||||
pageRecords: PageRecord[];
|
||||
docRecords: DocRecord[];
|
||||
}
|
||||
const ConflictList = ({
|
||||
pageRecords,
|
||||
docRecords,
|
||||
children,
|
||||
className,
|
||||
...attrs
|
||||
}: ConflictListProps) => {
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const workspace = useService(Workspace);
|
||||
const currentDoc = useService(Doc);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const currentDoc = useService(DocService).doc;
|
||||
const { setTrashModal } = useTrashModalHelper(workspace.docCollection);
|
||||
|
||||
const handleOpenTrashModal = useCallback(
|
||||
(pageRecord: PageRecord) => {
|
||||
(docRecord: DocRecord) => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [pageRecord.id],
|
||||
pageTitles: [pageRecord.title$.value],
|
||||
pageIds: [docRecord.id],
|
||||
pageTitles: [docRecord.title$.value],
|
||||
});
|
||||
},
|
||||
[setTrashModal]
|
||||
@@ -322,18 +316,18 @@ const ConflictList = ({
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
|
||||
{pageRecords.map(pageRecord => {
|
||||
const isCurrent = pageRecord.id === currentDoc.id;
|
||||
{docRecords.map(docRecord => {
|
||||
const isCurrent = docRecord.id === currentDoc.id;
|
||||
return (
|
||||
<PageItem
|
||||
aria-selected={isCurrent}
|
||||
pageRecord={pageRecord}
|
||||
key={pageRecord.id}
|
||||
docRecord={docRecord}
|
||||
key={docRecord.id}
|
||||
right={
|
||||
<Menu
|
||||
items={
|
||||
<MoveToTrash
|
||||
onSelect={() => handleOpenTrashModal(pageRecord)}
|
||||
onSelect={() => handleOpenTrashModal(docRecord)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -342,7 +336,7 @@ const ConflictList = ({
|
||||
</IconButton>
|
||||
</Menu>
|
||||
}
|
||||
onClick={() => navigateHelper.openPage(workspace.id, pageRecord.id)}
|
||||
onClick={() => navigateHelper.openPage(workspace.id, docRecord.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -352,30 +346,34 @@ const ConflictList = ({
|
||||
};
|
||||
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const workspace = useService(Workspace);
|
||||
const pageRecordList = useService(PageRecordList);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const docRecordList = useService(DocsService).list;
|
||||
const journalHelper = useJournalHelper(workspace.docCollection);
|
||||
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
|
||||
const pageRecords = useLiveData(pageRecordList.records$).filter(v => {
|
||||
return docs.some(doc => doc.id === v.id);
|
||||
});
|
||||
const docRecords = useLiveData(
|
||||
docRecordList.docs$.map(records =>
|
||||
records.filter(v => {
|
||||
return docs.some(doc => doc.id === v.id);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (docs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<ConflictList
|
||||
className={styles.journalConflictBlock}
|
||||
pageRecords={pageRecords.slice(0, MAX_CONFLICT_COUNT)}
|
||||
docRecords={docRecords.slice(0, MAX_CONFLICT_COUNT)}
|
||||
>
|
||||
{docs.length > MAX_CONFLICT_COUNT ? (
|
||||
<Menu
|
||||
items={
|
||||
<ConflictList pageRecords={pageRecords.slice(MAX_CONFLICT_COUNT)} />
|
||||
<ConflictList docRecords={docRecords.slice(MAX_CONFLICT_COUNT)} />
|
||||
}
|
||||
>
|
||||
<div className={styles.journalConflictMoreTrigger}>
|
||||
{t['com.affine.journal.conflict-show-more']({
|
||||
count: (pageRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
|
||||
count: (docRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
|
||||
})}
|
||||
</div>
|
||||
</Menu>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../entities/sidebar-tab';
|
||||
import type { SidebarTab, SidebarTabProps } from '../multi-tabs/sidebar-tab';
|
||||
import * as styles from './body.css';
|
||||
|
||||
export const MultiTabSidebarBody = (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
|
||||
import { Doc, useService, Workspace } from '@toeverything/infra';
|
||||
import { DocService, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabName } from '../entities/sidebar-tab';
|
||||
import type { SidebarTab, SidebarTabName } from '../multi-tabs/sidebar-tab';
|
||||
import * as styles from './header-switcher.css';
|
||||
|
||||
export interface MultiTabSidebarHeaderSwitcherProps {
|
||||
@@ -20,8 +20,8 @@ export const MultiTabSidebarHeaderSwitcher = ({
|
||||
activeTabName,
|
||||
setActiveTabName,
|
||||
}: MultiTabSidebarHeaderSwitcherProps) => {
|
||||
const workspace = useService(Workspace);
|
||||
const doc = useService(Doc);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const doc = useService(DocService).doc;
|
||||
|
||||
const { isJournal } = useJournalInfoHelper(workspace.docCollection, doc.id);
|
||||
|
||||
|
||||
3
packages/frontend/core/src/modules/navigation/README.md
Normal file
3
packages/frontend/core/src/modules/navigation/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# navigation
|
||||
|
||||
Provide support for forward and back buttons.
|
||||
@@ -1,13 +1,15 @@
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import type { Location } from 'history';
|
||||
import { Observable, switchMap } from 'rxjs';
|
||||
|
||||
import type { Workbench } from '../../workbench';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
|
||||
export class Navigator {
|
||||
constructor(private readonly workbench: Workbench) {}
|
||||
export class Navigator extends Entity {
|
||||
constructor(private readonly workbenchService: WorkbenchService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly history$ = this.workbench.activeView$.map(
|
||||
private readonly history$ = this.workbenchService.workbench.activeView$.map(
|
||||
view => view.history
|
||||
);
|
||||
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
export { Navigator } from './entities/navigator';
|
||||
export { NavigationButtons } from './view/navigation-buttons';
|
||||
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { Navigator } from './entities/navigator';
|
||||
import { NavigatorService } from './services/navigator';
|
||||
|
||||
export function configureNavigationModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(NavigatorService)
|
||||
.entity(Navigator, [WorkbenchService]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { Navigator } from '../entities/navigator';
|
||||
|
||||
export class NavigatorService extends Service {
|
||||
public readonly navigator = this.framework.createEntity(Navigator);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useGeneralShortcuts } from '../../../hooks/affine/use-shortcuts';
|
||||
import { Navigator } from '../entities/navigator';
|
||||
import { NavigatorService } from '../services/navigator';
|
||||
import * as styles from './navigation-buttons.css';
|
||||
import { useRegisterNavigationCommands } from './use-register-navigation-commands';
|
||||
|
||||
@@ -30,7 +30,7 @@ export const NavigationButtons = () => {
|
||||
};
|
||||
}, [shortcuts, t]);
|
||||
|
||||
const navigator = useService(Navigator);
|
||||
const navigator = useService(NavigatorService).navigator;
|
||||
|
||||
const backable = useLiveData(navigator.backable$);
|
||||
const forwardable = useLiveData(navigator.forwardable$);
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Navigator } from '../entities/navigator';
|
||||
import { NavigatorService } from '../services/navigator';
|
||||
|
||||
export function useRegisterNavigationCommands() {
|
||||
const navigator = useService(Navigator);
|
||||
const navigator = useService(NavigatorService).navigator;
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspacePermissionStore } from '../stores/permission';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspacePermission extends Entity {
|
||||
isOwner$ = new LiveData<boolean | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly store: WorkspacePermissionStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
if (
|
||||
this.workspaceService.workspace.flavour ===
|
||||
WorkspaceFlavour.AFFINE_CLOUD
|
||||
) {
|
||||
return await this.store.fetchIsOwner(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.isOwner$),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
onStart(() => this.isLoading$.setValue(true)),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
20
packages/frontend/core/src/modules/permissions/index.ts
Normal file
20
packages/frontend/core/src/modules/permissions/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { WorkspacePermissionService } from './services/permission';
|
||||
|
||||
import { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { WorkspacePermission } from './entities/permission';
|
||||
import { WorkspacePermissionService } from './services/permission';
|
||||
import { WorkspacePermissionStore } from './stores/permission';
|
||||
|
||||
export function configurePermissionsModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspacePermissionService)
|
||||
.store(WorkspacePermissionStore, [GraphQLService])
|
||||
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { WorkspacePermission } from '../entities/permission';
|
||||
|
||||
export class WorkspacePermissionService extends Service {
|
||||
permission = this.framework.createEntity(WorkspacePermission);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import { getIsOwnerQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class WorkspacePermissionStore extends Store {
|
||||
constructor(private readonly graphqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchIsOwner(workspaceId: string, signal?: AbortSignal) {
|
||||
const isOwner = await this.graphqlService.gql({
|
||||
query: getIsOwnerQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return isOwner.isOwner;
|
||||
}
|
||||
}
|
||||
25
packages/frontend/core/src/modules/properties/index.ts
Normal file
25
packages/frontend/core/src/modules/properties/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
FavoriteItemsAdapter,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './services/adapter';
|
||||
export { WorkspaceLegacyProperties } from './services/legacy-properties';
|
||||
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import {
|
||||
FavoriteItemsAdapter,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './services/adapter';
|
||||
import { WorkspaceLegacyProperties } from './services/legacy-properties';
|
||||
|
||||
export function configureWorkspacePropertiesModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceLegacyProperties, [WorkspaceService])
|
||||
.service(WorkspacePropertiesAdapter, [WorkspaceService])
|
||||
.service(FavoriteItemsAdapter, [WorkspacePropertiesAdapter]);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// the adapter is to bridge the workspace rootdoc & native js bindings
|
||||
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
|
||||
import { createYProxy, type Y } from '@blocksuite/store';
|
||||
import { LiveData, type Workspace } from '@toeverything/infra';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { defaultsDeep } from 'lodash-es';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@@ -24,7 +25,7 @@ const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
|
||||
* So that the adapter could be more focused and easier to maintain (like assigning default values)
|
||||
* However the properties for an abstraction may not be limited to a single yjs map.
|
||||
*/
|
||||
export class WorkspacePropertiesAdapter {
|
||||
export class WorkspacePropertiesAdapter extends Service {
|
||||
// provides a easy-to-use interface for workspace properties
|
||||
public readonly proxy: WorkspaceAffineProperties;
|
||||
public readonly properties: Y.Map<any>;
|
||||
@@ -33,9 +34,14 @@ export class WorkspacePropertiesAdapter {
|
||||
private ensuredRoot = false;
|
||||
private ensuredPages = {} as Record<string, boolean>;
|
||||
|
||||
constructor(public readonly workspace: Workspace) {
|
||||
get workspace() {
|
||||
return this.workspaceService.workspace;
|
||||
}
|
||||
|
||||
constructor(public readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
// check if properties exists, if not, create one
|
||||
const rootDoc = workspace.docCollection.doc;
|
||||
const rootDoc = workspaceService.workspace.docCollection.doc;
|
||||
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
|
||||
this.proxy = createYProxy(this.properties);
|
||||
|
||||
@@ -80,8 +86,8 @@ export class WorkspacePropertiesAdapter {
|
||||
source: 'system',
|
||||
type: PagePropertyType.Tags,
|
||||
options:
|
||||
this.workspace.docCollection.meta.properties.tags?.options ??
|
||||
[], // better use a one time migration
|
||||
this.workspaceService.workspace.docCollection.meta.properties
|
||||
.tags?.options ?? [], // better use a one time migration
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -116,8 +122,8 @@ export class WorkspacePropertiesAdapter {
|
||||
}
|
||||
|
||||
// leak some yjs abstraction to modify multiple properties at once
|
||||
transact = this.workspace.docCollection.doc.transact.bind(
|
||||
this.workspace.docCollection.doc
|
||||
transact = this.workspaceService.workspace.docCollection.doc.transact.bind(
|
||||
this.workspaceService.workspace.docCollection.doc
|
||||
);
|
||||
|
||||
get schema() {
|
||||
@@ -150,8 +156,9 @@ export class WorkspacePropertiesAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
export class FavoriteItemsAdapter {
|
||||
export class FavoriteItemsAdapter extends Service {
|
||||
constructor(private readonly adapter: WorkspacePropertiesAdapter) {
|
||||
super();
|
||||
this.migrateFavorites();
|
||||
}
|
||||
|
||||
@@ -191,7 +198,7 @@ export class FavoriteItemsAdapter {
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.adapter.workspace;
|
||||
return this.adapter.workspaceService.workspace;
|
||||
}
|
||||
|
||||
getItemId(item: WorkspaceFavoriteItem) {
|
||||
@@ -1,32 +1,37 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { DocsPropertiesMeta } from '@blocksuite/store';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* @deprecated use WorkspacePropertiesAdapter instead (later)
|
||||
*/
|
||||
export class WorkspaceLegacyProperties {
|
||||
constructor(private readonly workspace: Workspace) {}
|
||||
export class WorkspaceLegacyProperties extends Service {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
get workspaceId() {
|
||||
return this.workspace.id;
|
||||
return this.workspaceService.workspace.id;
|
||||
}
|
||||
|
||||
get properties() {
|
||||
return this.workspace.docCollection.meta.properties;
|
||||
return this.workspaceService.workspace.docCollection.meta.properties;
|
||||
}
|
||||
get tagOptions() {
|
||||
return this.properties.tags?.options ?? [];
|
||||
}
|
||||
|
||||
updateProperties = (properties: DocsPropertiesMeta) => {
|
||||
this.workspace.docCollection.meta.setProperties(properties);
|
||||
this.workspaceService.workspace.docCollection.meta.setProperties(
|
||||
properties
|
||||
);
|
||||
};
|
||||
|
||||
subscribe(cb: () => void) {
|
||||
const disposable = this.workspace.docCollection.meta.docMetaUpdated.on(cb);
|
||||
const disposable =
|
||||
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(cb);
|
||||
return disposable.dispose;
|
||||
}
|
||||
|
||||
@@ -58,10 +63,10 @@ export class WorkspaceLegacyProperties {
|
||||
};
|
||||
|
||||
removeTagOption = (id: string) => {
|
||||
this.workspace.docCollection.doc.transact(() => {
|
||||
this.workspaceService.workspace.docCollection.doc.transact(() => {
|
||||
this.updateTagOptions(this.tagOptions.filter(o => o.id !== id));
|
||||
// need to remove tag from all pages
|
||||
this.workspace.docCollection.docs.forEach(doc => {
|
||||
this.workspaceService.workspace.docCollection.docs.forEach(doc => {
|
||||
const tags = doc.meta?.tags ?? [];
|
||||
if (tags.includes(id)) {
|
||||
this.updatePageTags(
|
||||
@@ -74,7 +79,7 @@ export class WorkspaceLegacyProperties {
|
||||
};
|
||||
|
||||
updatePageTags = (pageId: string, tags: string[]) => {
|
||||
this.workspace.docCollection.setDocMeta(pageId, {
|
||||
this.workspaceService.workspace.docCollection.setDocMeta(pageId, {
|
||||
tags,
|
||||
});
|
||||
};
|
||||
61
packages/frontend/core/src/modules/quota/entities/quota.ts
Normal file
61
packages/frontend/core/src/modules/quota/entities/quota.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { WorkspaceQuotaQuery } from '@affine/graphql';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceQuotaStore } from '../stores/quota';
|
||||
|
||||
type QuotaType = WorkspaceQuotaQuery['workspace']['quota'];
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspaceQuota extends Entity {
|
||||
quota$ = new LiveData<QuotaType | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly store: WorkspaceQuotaStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(signal =>
|
||||
this.store.fetchWorkspaceQuota(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
count: 3,
|
||||
}),
|
||||
mapInto(this.quota$),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
onStart(() => this.isLoading$.setValue(true)),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
20
packages/frontend/core/src/modules/quota/index.ts
Normal file
20
packages/frontend/core/src/modules/quota/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { WorkspaceQuotaService } from './services/quota';
|
||||
|
||||
import { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceQuota } from './entities/quota';
|
||||
import { WorkspaceQuotaService } from './services/quota';
|
||||
import { WorkspaceQuotaStore } from './stores/quota';
|
||||
|
||||
export function configureQuotaModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceQuotaService)
|
||||
.store(WorkspaceQuotaStore, [GraphQLService])
|
||||
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceQuota } from '../entities/quota';
|
||||
|
||||
export class WorkspaceQuotaService extends Service {
|
||||
quota = this.framework.createEntity(WorkspaceQuota);
|
||||
}
|
||||
22
packages/frontend/core/src/modules/quota/stores/quota.ts
Normal file
22
packages/frontend/core/src/modules/quota/stores/quota.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import { workspaceQuotaQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class WorkspaceQuotaStore extends Store {
|
||||
constructor(private readonly graphqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: workspaceQuotaQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return data.workspace.quota;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import { createIsland } from '../../../utils/island';
|
||||
|
||||
export class RightSidebarView {
|
||||
export class RightSidebarView extends Entity {
|
||||
readonly body = createIsland();
|
||||
readonly header = createIsland();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { GlobalState } from '@toeverything/infra';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { RightSidebarView } from './right-sidebar-view';
|
||||
import { RightSidebarView } from './right-sidebar-view';
|
||||
|
||||
const RIGHT_SIDEBAR_KEY = 'app:settings:rightsidebar';
|
||||
|
||||
export class RightSidebar {
|
||||
constructor(private readonly globalState: GlobalState) {}
|
||||
export class RightSidebar extends Entity {
|
||||
constructor(private readonly globalState: GlobalState) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly isOpen$ = LiveData.from(
|
||||
this.globalState.watch<boolean>(RIGHT_SIDEBAR_KEY),
|
||||
false
|
||||
@@ -36,8 +39,10 @@ export class RightSidebar {
|
||||
/**
|
||||
* @private use `RightSidebarViewIsland` instead
|
||||
*/
|
||||
_append(view: RightSidebarView) {
|
||||
_append() {
|
||||
const view = this.framework.createEntity(RightSidebarView);
|
||||
this.views$.next([...this.views$.value, view]);
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
export { RightSidebar } from './entities/right-sidebar';
|
||||
export { RightSidebarService } from './services/right-sidebar';
|
||||
export { RightSidebarContainer } from './view/container';
|
||||
export { RightSidebarViewIsland } from './view/view-island';
|
||||
|
||||
import {
|
||||
type Framework,
|
||||
GlobalState,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { RightSidebar } from './entities/right-sidebar';
|
||||
import { RightSidebarView } from './entities/right-sidebar-view';
|
||||
import { RightSidebarService } from './services/right-sidebar';
|
||||
|
||||
export function configureRightSidebarModule(services: Framework) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.service(RightSidebarService)
|
||||
.entity(RightSidebar, [GlobalState])
|
||||
.entity(RightSidebarView);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { RightSidebar } from '../entities/right-sidebar';
|
||||
|
||||
export class RightSidebarService extends Service {
|
||||
rightSidebar = this.framework.createEntity(RightSidebar);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ResizePanel } from '@affine/component/resize-panel';
|
||||
import { appSidebarOpenAtom } from '@affine/core/components/app-sidebar';
|
||||
import { appSettingAtom, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { RightSidebar } from '../entities/right-sidebar';
|
||||
import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai';
|
||||
import { RightSidebarService } from '../services/right-sidebar';
|
||||
import * as styles from './container.css';
|
||||
import { Header } from './header';
|
||||
|
||||
@@ -15,7 +15,7 @@ export const RightSidebarContainer = () => {
|
||||
const { clientBorder } = useAtomValue(appSettingAtom);
|
||||
const [width, setWidth] = useState(300);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
const rightSidebar = useService(RightSidebarService).rightSidebar;
|
||||
|
||||
const frontView = useLiveData(rightSidebar.front$);
|
||||
const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { RightSidebar } from '../entities/right-sidebar';
|
||||
import { RightSidebarView } from '../entities/right-sidebar-view';
|
||||
import type { RightSidebarView } from '../entities/right-sidebar-view';
|
||||
import { RightSidebarService } from '../services/right-sidebar';
|
||||
|
||||
export interface RightSidebarViewProps {
|
||||
body: JSX.Element;
|
||||
@@ -16,23 +16,29 @@ export const RightSidebarViewIsland = ({
|
||||
header,
|
||||
active,
|
||||
}: RightSidebarViewProps) => {
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
const rightSidebar = useService(RightSidebarService).rightSidebar;
|
||||
|
||||
const view = useMemo(() => new RightSidebarView(), []);
|
||||
const [view, setView] = useState<RightSidebarView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
rightSidebar._append(view);
|
||||
const view = rightSidebar._append();
|
||||
setView(view);
|
||||
return () => {
|
||||
rightSidebar._remove(view);
|
||||
setView(null);
|
||||
};
|
||||
}, [rightSidebar, view]);
|
||||
}, [rightSidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (active && view) {
|
||||
rightSidebar._moveToFront(view);
|
||||
}
|
||||
}, [active, rightSidebar, view]);
|
||||
|
||||
if (!view) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<view.header.Provider>{header}</view.header.Provider>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ServiceCollection } from '@toeverything/infra';
|
||||
import {
|
||||
GlobalCache,
|
||||
GlobalState,
|
||||
PageRecordList,
|
||||
Workspace,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { CollectionService } from './collection';
|
||||
import {
|
||||
LocalStorageGlobalCache,
|
||||
LocalStorageGlobalState,
|
||||
} from './infra-web/storage';
|
||||
import { Navigator } from './navigation';
|
||||
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
|
||||
import { TagService } from './tag';
|
||||
import { Workbench } from './workbench';
|
||||
import {
|
||||
CurrentWorkspaceService,
|
||||
FavoriteItemsAdapter,
|
||||
WorkspaceLegacyProperties,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './workspace';
|
||||
|
||||
export function configureBusinessServices(services: ServiceCollection) {
|
||||
services.add(CurrentWorkspaceService);
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.add(Workbench)
|
||||
.add(Navigator, [Workbench])
|
||||
.add(RightSidebar, [GlobalState])
|
||||
.add(WorkspacePropertiesAdapter, [Workspace])
|
||||
.add(FavoriteItemsAdapter, [WorkspacePropertiesAdapter])
|
||||
.add(CollectionService, [Workspace])
|
||||
.add(WorkspaceLegacyProperties, [Workspace])
|
||||
.add(TagService, [WorkspaceLegacyProperties, PageRecordList]);
|
||||
}
|
||||
|
||||
export function configureWebInfraServices(services: ServiceCollection) {
|
||||
services
|
||||
.addImpl(GlobalCache, LocalStorageGlobalCache)
|
||||
.addImpl(GlobalState, LocalStorageGlobalState);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { GetWorkspacePublicPagesQuery } from '@affine/graphql';
|
||||
import type { GlobalCache, WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { ShareDocsStore } from '../stores/share-docs';
|
||||
|
||||
type ShareDocListType =
|
||||
GetWorkspacePublicPagesQuery['workspace']['publicPages'];
|
||||
|
||||
export const logger = new DebugLogger('affine:share-doc-list');
|
||||
|
||||
export class ShareDocsList extends Entity {
|
||||
list$ = LiveData.from(this.cache.watch<ShareDocListType>('share-docs'), []);
|
||||
isLoading$ = new LiveData<boolean>(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly store: ShareDocsStore,
|
||||
private readonly cache: GlobalCache
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
switchMap(() =>
|
||||
fromPromise(signal =>
|
||||
this.store.getWorkspacesShareDocs(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mergeMap(list => {
|
||||
this.cache.set('share-docs', list);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, err =>
|
||||
logger.error('revalidate share docs error', err)
|
||||
),
|
||||
onStart(() => {
|
||||
this.isLoading$.next(true);
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.isLoading$.next(false);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
GetWorkspacePublicPageByIdQuery,
|
||||
PublicPageMode,
|
||||
} from '@affine/graphql';
|
||||
import type { DocService, WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { switchMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { ShareStore } from '../stores/share';
|
||||
|
||||
type ShareInfoType = GetWorkspacePublicPageByIdQuery['workspace']['publicPage'];
|
||||
|
||||
export class Share extends Entity {
|
||||
info$ = new LiveData<ShareInfoType | undefined | null>(null);
|
||||
isShared$ = this.info$.map(info =>
|
||||
// null means not loaded yet, undefined means not shared
|
||||
info !== null ? info !== undefined : null
|
||||
);
|
||||
sharedMode$ = this.info$.map(info => (info !== null ? info?.mode : null));
|
||||
|
||||
error$ = new LiveData<any>(null);
|
||||
isRevalidating$ = new LiveData<boolean>(false);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly docService: DocService,
|
||||
private readonly store: ShareStore
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
switchMap(() => {
|
||||
return fromPromise(signal =>
|
||||
this.store.getShareInfoByDocId(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
signal
|
||||
)
|
||||
).pipe(
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.info$),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
return this.isRevalidating$.waitFor(v => v === false, signal);
|
||||
}
|
||||
|
||||
async enableShare(mode: PublicPageMode) {
|
||||
await this.store.enableSharePage(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
mode
|
||||
);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async changeShare(mode: PublicPageMode) {
|
||||
await this.enableShare(mode);
|
||||
}
|
||||
|
||||
async disableShare() {
|
||||
await this.store.disableSharePage(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id
|
||||
);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
}
|
||||
35
packages/frontend/core/src/modules/share-doc/index.ts
Normal file
35
packages/frontend/core/src/modules/share-doc/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export { ShareService } from './services/share';
|
||||
export { ShareDocsService } from './services/share-docs';
|
||||
|
||||
import {
|
||||
DocScope,
|
||||
DocService,
|
||||
type Framework,
|
||||
WorkspaceLocalCache,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { GraphQLService } from '../cloud';
|
||||
import { ShareDocsList } from './entities/share-docs-list';
|
||||
import { Share } from './entities/share-info';
|
||||
import { ShareService } from './services/share';
|
||||
import { ShareDocsService } from './services/share-docs';
|
||||
import { ShareStore } from './stores/share';
|
||||
import { ShareDocsStore } from './stores/share-docs';
|
||||
|
||||
export function configureShareDocsModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(ShareDocsService)
|
||||
.store(ShareDocsStore, [GraphQLService])
|
||||
.entity(ShareDocsList, [
|
||||
WorkspaceService,
|
||||
ShareDocsStore,
|
||||
WorkspaceLocalCache,
|
||||
])
|
||||
.scope(DocScope)
|
||||
.service(ShareService)
|
||||
.entity(Share, [WorkspaceService, DocService, ShareStore])
|
||||
.store(ShareStore, [GraphQLService]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { ShareDocsList } from '../entities/share-docs-list';
|
||||
|
||||
export class ShareDocsService extends Service {
|
||||
shareDocs = this.framework.createEntity(ShareDocsList);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { Share } from '../entities/share-info';
|
||||
|
||||
export class ShareService extends Service {
|
||||
share = this.framework.createEntity(Share);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import { getWorkspacePublicPagesQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class ShareDocsStore extends Store {
|
||||
constructor(private readonly graphqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getWorkspacesShareDocs(workspaceId: string, signal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: getWorkspacePublicPagesQuery,
|
||||
variables: {
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return data.workspace.publicPages;
|
||||
}
|
||||
}
|
||||
69
packages/frontend/core/src/modules/share-doc/stores/share.ts
Normal file
69
packages/frontend/core/src/modules/share-doc/stores/share.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { PublicPageMode } from '@affine/graphql';
|
||||
import {
|
||||
getWorkspacePublicPageByIdQuery,
|
||||
publishPageMutation,
|
||||
revokePublicPageMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../../cloud';
|
||||
|
||||
export class ShareStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getShareInfoByDocId(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: getWorkspacePublicPageByIdQuery,
|
||||
variables: {
|
||||
pageId: docId,
|
||||
workspaceId,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
return data.workspace.publicPage ?? undefined;
|
||||
}
|
||||
|
||||
async enableSharePage(
|
||||
workspaceId: string,
|
||||
pageId: string,
|
||||
docMode?: PublicPageMode,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
await this.gqlService.gql({
|
||||
query: publishPageMutation,
|
||||
variables: {
|
||||
pageId,
|
||||
workspaceId,
|
||||
mode: docMode,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async disableSharePage(
|
||||
workspaceId: string,
|
||||
pageId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
await this.gqlService.gql({
|
||||
query: revokePublicPageMutation,
|
||||
variables: {
|
||||
pageId,
|
||||
workspaceId,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
11
packages/frontend/core/src/modules/storage/index.ts
Normal file
11
packages/frontend/core/src/modules/storage/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type Framework, GlobalCache, GlobalState } from '@toeverything/infra';
|
||||
|
||||
import {
|
||||
LocalStorageGlobalCache,
|
||||
LocalStorageGlobalState,
|
||||
} from './impls/storage';
|
||||
|
||||
export function configureStorageImpls(framework: Framework) {
|
||||
framework.impl(GlobalCache, LocalStorageGlobalCache);
|
||||
framework.impl(GlobalState, LocalStorageGlobalState);
|
||||
}
|
||||
79
packages/frontend/core/src/modules/tag/entities/tag-list.ts
Normal file
79
packages/frontend/core/src/modules/tag/entities/tag-list.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import { Tag } from '../entities/tag';
|
||||
import type { TagStore } from '../stores/tag';
|
||||
|
||||
export class TagList extends Entity {
|
||||
constructor(
|
||||
private readonly store: TagStore,
|
||||
private readonly docs: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly tags$ = LiveData.from(this.store.watchTagIds(), []).map(ids => {
|
||||
return ids.map(id => this.framework.createEntity(Tag, { id }));
|
||||
});
|
||||
|
||||
createTag(value: string, color: string) {
|
||||
const newId = this.store.createNewTag(value, color);
|
||||
const newTag = this.framework.createEntity(Tag, { id: newId });
|
||||
return newTag;
|
||||
}
|
||||
|
||||
deleteTag(tagId: string) {
|
||||
this.store.deleteTag(tagId);
|
||||
}
|
||||
|
||||
tagsByPageId$(pageId: string) {
|
||||
return LiveData.computed(get => {
|
||||
const docRecord = get(this.docs.list.doc$(pageId));
|
||||
if (!docRecord) return [];
|
||||
const tagIds = get(docRecord.meta$).tags;
|
||||
|
||||
return get(this.tags$).filter(tag => (tagIds ?? []).includes(tag.id));
|
||||
});
|
||||
}
|
||||
|
||||
tagIdsByPageId$(pageId: string) {
|
||||
return this.tagsByPageId$(pageId).map(tags => tags.map(tag => tag.id));
|
||||
}
|
||||
|
||||
tagByTagId$(tagId?: string) {
|
||||
return this.tags$.map(tags => tags.find(tag => tag.id === tagId));
|
||||
}
|
||||
|
||||
tagMetas$ = LiveData.computed(get => {
|
||||
return get(this.tags$).map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
title: get(tag.value$),
|
||||
color: get(tag.color$),
|
||||
pageCount: get(tag.pageIds$).length,
|
||||
createDate: get(tag.createDate$),
|
||||
updatedDate: get(tag.updateDate$),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
private filterFn(value: string, query?: string) {
|
||||
const trimmedQuery = query?.trim().toLowerCase() ?? '';
|
||||
const trimmedValue = value.trim().toLowerCase();
|
||||
return trimmedValue.includes(trimmedQuery);
|
||||
}
|
||||
|
||||
filterTagsByName$(name: string) {
|
||||
return LiveData.computed(get => {
|
||||
return get(this.tags$).filter(tag =>
|
||||
this.filterFn(get(tag.value$), name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
tagByTagValue$(value: string) {
|
||||
return LiveData.computed(get => {
|
||||
return get(this.tags$).find(tag => this.filterFn(get(tag.value$), value));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,33 @@
|
||||
import type { Tag as TagSchema } from '@affine/env/filter';
|
||||
import type { PageRecordList } from '@toeverything/infra';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceLegacyProperties } from '../../workspace';
|
||||
import type { TagStore } from '../stores/tag';
|
||||
import { tagColorMap } from './utils';
|
||||
|
||||
export class Tag {
|
||||
export class Tag extends Entity<{ id: string }> {
|
||||
id = this.props.id;
|
||||
constructor(
|
||||
readonly id: string,
|
||||
private readonly properties: WorkspaceLegacyProperties,
|
||||
private readonly pageRecordList: PageRecordList
|
||||
) {}
|
||||
private readonly store: TagStore,
|
||||
private readonly docs: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly tagOption$ = this.properties.tagOptions$.map(
|
||||
tags => tags.find(tag => tag.id === this.id) as TagSchema
|
||||
);
|
||||
private readonly tagOption$ = LiveData.from(
|
||||
this.store.watchTagInfo(this.id),
|
||||
undefined
|
||||
).map(tagInfo => tagInfo);
|
||||
|
||||
value$ = this.tagOption$.map(tag => tag?.value || '');
|
||||
|
||||
color$ = this.tagOption$.map(tag => tagColorMap(tag?.color) || '');
|
||||
color$ = this.tagOption$.map(tag => tagColorMap(tag?.color ?? '') || '');
|
||||
|
||||
createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now());
|
||||
|
||||
updateDate$ = this.tagOption$.map(tag => tag?.updateDate || Date.now());
|
||||
|
||||
rename(value: string) {
|
||||
this.properties.updateTagOption(this.id, {
|
||||
this.store.updateTagInfo(this.id, {
|
||||
id: this.id,
|
||||
value,
|
||||
color: this.color$.value,
|
||||
@@ -35,17 +37,13 @@ export class Tag {
|
||||
}
|
||||
|
||||
changeColor(color: string) {
|
||||
this.properties.updateTagOption(this.id, {
|
||||
id: this.id,
|
||||
value: this.value$.value,
|
||||
this.store.updateTagInfo(this.id, {
|
||||
color,
|
||||
createDate: this.createDate$.value,
|
||||
updateDate: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
tag(pageId: string) {
|
||||
const pageRecord = this.pageRecordList.record$(pageId).value;
|
||||
const pageRecord = this.docs.list.doc$(pageId).value;
|
||||
if (!pageRecord) {
|
||||
return;
|
||||
}
|
||||
@@ -55,7 +53,7 @@ export class Tag {
|
||||
}
|
||||
|
||||
untag(pageId: string) {
|
||||
const pageRecord = this.pageRecordList.record$(pageId).value;
|
||||
const pageRecord = this.docs.list.doc$(pageId).value;
|
||||
if (!pageRecord) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +63,7 @@ export class Tag {
|
||||
}
|
||||
|
||||
readonly pageIds$ = LiveData.computed(get => {
|
||||
const pages = get(this.pageRecordList.records$);
|
||||
const pages = get(this.docs.list.docs$);
|
||||
return pages
|
||||
.filter(page => get(page.meta$).tags?.includes(this.id))
|
||||
.map(page => page.id);
|
||||
|
||||
@@ -2,3 +2,24 @@ export { Tag } from './entities/tag';
|
||||
export { tagColorMap } from './entities/utils';
|
||||
export { TagService } from './service/tag';
|
||||
export { DeleteTagConfirmModal } from './view/delete-tag-modal';
|
||||
|
||||
import {
|
||||
DocsService,
|
||||
type Framework,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceLegacyProperties } from '../properties';
|
||||
import { Tag } from './entities/tag';
|
||||
import { TagList } from './entities/tag-list';
|
||||
import { TagService } from './service/tag';
|
||||
import { TagStore } from './stores/tag';
|
||||
|
||||
export function configureTagModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(TagService)
|
||||
.store(TagStore, [WorkspaceLegacyProperties])
|
||||
.entity(TagList, [TagStore, DocsService])
|
||||
.entity(Tag, [TagStore, DocsService]);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,7 @@
|
||||
import type { PageRecordList } from '@toeverything/infra';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceLegacyProperties } from '../../workspace';
|
||||
import { Tag } from '../entities/tag';
|
||||
import { TagList } from '../entities/tag-list';
|
||||
|
||||
export class TagService {
|
||||
constructor(
|
||||
private readonly properties: WorkspaceLegacyProperties,
|
||||
private readonly pageRecordList: PageRecordList
|
||||
) {}
|
||||
|
||||
readonly tags$ = this.properties.tagOptions$.map(tags =>
|
||||
tags.map(tag => new Tag(tag.id, this.properties, this.pageRecordList))
|
||||
);
|
||||
|
||||
createTag(value: string, color: string) {
|
||||
const newId = nanoid();
|
||||
this.properties.updateTagOptions([
|
||||
...this.properties.tagOptions$.value,
|
||||
{
|
||||
id: newId,
|
||||
value,
|
||||
color,
|
||||
createDate: Date.now(),
|
||||
updateDate: Date.now(),
|
||||
},
|
||||
]);
|
||||
const newTag = new Tag(newId, this.properties, this.pageRecordList);
|
||||
return newTag;
|
||||
}
|
||||
|
||||
deleteTag(tagId: string) {
|
||||
this.properties.removeTagOption(tagId);
|
||||
}
|
||||
|
||||
tagsByPageId$(pageId: string) {
|
||||
return LiveData.computed(get => {
|
||||
const pageRecord = get(this.pageRecordList.record$(pageId));
|
||||
if (!pageRecord) return [];
|
||||
const tagIds = get(pageRecord.meta$).tags;
|
||||
|
||||
return get(this.tags$).filter(tag => (tagIds ?? []).includes(tag.id));
|
||||
});
|
||||
}
|
||||
|
||||
tagIdsByPageId$(pageId: string) {
|
||||
return this.tagsByPageId$(pageId).map(tags => tags.map(tag => tag.id));
|
||||
}
|
||||
|
||||
tagByTagId$(tagId?: string) {
|
||||
return this.tags$.map(tags => tags.find(tag => tag.id === tagId));
|
||||
}
|
||||
|
||||
tagMetas$ = LiveData.computed(get => {
|
||||
return get(this.tags$).map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
title: get(tag.value$),
|
||||
color: get(tag.color$),
|
||||
pageCount: get(tag.pageIds$).length,
|
||||
createDate: get(tag.createDate$),
|
||||
updatedDate: get(tag.updateDate$),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
private filterFn(value: string, query?: string) {
|
||||
const trimmedQuery = query?.trim().toLowerCase() ?? '';
|
||||
const trimmedValue = value.trim().toLowerCase();
|
||||
return trimmedValue.includes(trimmedQuery);
|
||||
}
|
||||
|
||||
filterTagsByName$(name: string) {
|
||||
return LiveData.computed(get => {
|
||||
return get(this.tags$).filter(tag =>
|
||||
this.filterFn(get(tag.value$), name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
tagByTagValue$(value: string) {
|
||||
return LiveData.computed(get => {
|
||||
return get(this.tags$).find(tag => this.filterFn(get(tag.value$), value));
|
||||
});
|
||||
}
|
||||
export class TagService extends Service {
|
||||
tagList = this.framework.createEntity(TagList);
|
||||
}
|
||||
|
||||
59
packages/frontend/core/src/modules/tag/stores/tag.ts
Normal file
59
packages/frontend/core/src/modules/tag/stores/tag.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Tag as TagSchema } from '@affine/env/filter';
|
||||
import { Store } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { WorkspaceLegacyProperties } from '../../properties';
|
||||
|
||||
export class TagStore extends Store {
|
||||
constructor(private readonly properties: WorkspaceLegacyProperties) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchTagIds() {
|
||||
return this.properties.tagOptions$
|
||||
.map(tags => tags.map(tag => tag.id))
|
||||
.asObservable();
|
||||
}
|
||||
|
||||
createNewTag(value: string, color: string) {
|
||||
const newId = nanoid();
|
||||
this.properties.updateTagOptions([
|
||||
...this.properties.tagOptions$.value,
|
||||
{
|
||||
id: newId,
|
||||
value,
|
||||
color,
|
||||
createDate: Date.now(),
|
||||
updateDate: Date.now(),
|
||||
},
|
||||
]);
|
||||
return newId;
|
||||
}
|
||||
|
||||
deleteTag(id: string) {
|
||||
this.properties.removeTagOption(id);
|
||||
}
|
||||
|
||||
watchTagInfo(id: string) {
|
||||
return this.properties.tagOptions$.map(
|
||||
tags => tags.find(tag => tag.id === id) as TagSchema | undefined
|
||||
);
|
||||
}
|
||||
|
||||
updateTagInfo(id: string, tagInfo: Partial<TagSchema>) {
|
||||
const tag = this.properties.tagOptions$.value.find(tag => tag.id === id) as
|
||||
| TagSchema
|
||||
| undefined;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
this.properties.updateTagOption(id, {
|
||||
id: id,
|
||||
value: tag.value,
|
||||
color: tag.color,
|
||||
createDate: tag.createDate,
|
||||
updateDate: Date.now(),
|
||||
...tagInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const DeleteTagConfirmModal = ({
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const tagService = useService(TagService);
|
||||
const tags = useLiveData(tagService.tags$);
|
||||
const tags = useLiveData(tagService.tagList.tags$);
|
||||
const selectedTags = useMemo(() => {
|
||||
return tags.filter(tag => selectedTagIds.includes(tag.id));
|
||||
}, [selectedTagIds, tags]);
|
||||
@@ -25,7 +25,7 @@ export const DeleteTagConfirmModal = ({
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
selectedTagIds.forEach(tagId => {
|
||||
tagService.deleteTag(tagId);
|
||||
tagService.tagList.deleteTag(tagId);
|
||||
});
|
||||
|
||||
toast(
|
||||
|
||||
8
packages/frontend/core/src/modules/telemetry/index.ts
Normal file
8
packages/frontend/core/src/modules/telemetry/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { AuthService } from '../cloud';
|
||||
import { TelemetryService } from './services/telemetry';
|
||||
|
||||
export function configureTelemetryModule(framework: Framework) {
|
||||
framework.service(TelemetryService, [AuthService]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import {
|
||||
AccountChanged,
|
||||
type AuthAccountInfo,
|
||||
type AuthService,
|
||||
} from '../../cloud';
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class TelemetryService extends Service {
|
||||
constructor(private readonly auth: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
onApplicationStart() {
|
||||
const account = this.auth.session.account$.value;
|
||||
if (account) {
|
||||
mixpanel.identify(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountChanged(account: AuthAccountInfo | null) {
|
||||
if (account === null) {
|
||||
mixpanel.reset();
|
||||
} else {
|
||||
mixpanel.reset();
|
||||
mixpanel.identify(account.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import type { Location, To } from 'history';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { createIsland } from '../../../utils/island';
|
||||
import { createNavigableHistory } from '../../../utils/navigable-history';
|
||||
import type { ViewScope } from '../scopes/view';
|
||||
|
||||
export class View {
|
||||
constructor(defaultPath: To = { pathname: '/all' }) {
|
||||
export class View extends Entity {
|
||||
id = this.scope.props.id;
|
||||
|
||||
constructor(public readonly scope: ViewScope) {
|
||||
super();
|
||||
this.history = createNavigableHistory({
|
||||
initialEntries: [defaultPath],
|
||||
initialEntries: [
|
||||
this.scope.props.defaultLocation ?? { pathname: '/all' },
|
||||
],
|
||||
initialIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
id = nanoid();
|
||||
|
||||
history = createNavigableHistory({
|
||||
initialEntries: ['/all'],
|
||||
initialIndex: 0,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { combineLatest, map, switchMap } from 'rxjs';
|
||||
|
||||
import { View } from './view';
|
||||
import { ViewScope } from '../scopes/view';
|
||||
import { ViewService } from '../services/view';
|
||||
import type { View } from './view';
|
||||
|
||||
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
|
||||
|
||||
@@ -12,8 +15,11 @@ interface WorkbenchOpenOptions {
|
||||
replaceHistory?: boolean;
|
||||
}
|
||||
|
||||
export class Workbench {
|
||||
readonly views$ = new LiveData([new View()]);
|
||||
export class Workbench extends Entity {
|
||||
readonly views$ = new LiveData([
|
||||
this.framework.createScope(ViewScope, { id: nanoid() }).get(ViewService)
|
||||
.view,
|
||||
]);
|
||||
|
||||
activeViewIndex$ = new LiveData(0);
|
||||
activeView$ = LiveData.from(
|
||||
@@ -35,7 +41,9 @@ export class Workbench {
|
||||
}
|
||||
|
||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
||||
const view = new View(defaultLocation);
|
||||
const view = this.framework
|
||||
.createScope(ViewScope, { id: nanoid(), defaultLocation })
|
||||
.get(ViewService).view;
|
||||
const newViews = [...this.views$.value];
|
||||
newViews.splice(this.indexAt(at), 0, view);
|
||||
this.views$.next(newViews);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
export { View } from './entities/view';
|
||||
export { Workbench } from './entities/workbench';
|
||||
export { ViewScope as View } from './scopes/view';
|
||||
export { WorkbenchService } from './services/workbench';
|
||||
export { useIsActiveView } from './view/use-is-active-view';
|
||||
export { ViewBodyIsland } from './view/view-body-island';
|
||||
export { ViewHeaderIsland } from './view/view-header-island';
|
||||
export { WorkbenchLink } from './view/workbench-link';
|
||||
export { WorkbenchRoot } from './view/workbench-root';
|
||||
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
|
||||
import { View } from './entities/view';
|
||||
import { Workbench } from './entities/workbench';
|
||||
import { ViewScope } from './scopes/view';
|
||||
import { ViewService } from './services/view';
|
||||
import { WorkbenchService } from './services/workbench';
|
||||
|
||||
export function configureWorkbenchModule(services: Framework) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkbenchService)
|
||||
.entity(Workbench)
|
||||
.scope(ViewScope)
|
||||
.entity(View, [ViewScope])
|
||||
.service(ViewService);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Scope } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
|
||||
export class ViewScope extends Scope<{
|
||||
id: string;
|
||||
defaultLocation?: To | undefined;
|
||||
}> {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { View } from '../entities/view';
|
||||
|
||||
export class ViewService extends Service {
|
||||
view = this.framework.createEntity(View);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { Workbench } from '../entities/workbench';
|
||||
|
||||
export class WorkbenchService extends Service {
|
||||
workbench = this.framework.createEntity(Workbench);
|
||||
}
|
||||
@@ -6,13 +6,11 @@ import { useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
|
||||
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
|
||||
import {
|
||||
appSidebarOpenAtom,
|
||||
SidebarSwitch,
|
||||
} from '../../../components/app-sidebar';
|
||||
import { RightSidebar } from '../../right-sidebar';
|
||||
import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai';
|
||||
import { SidebarSwitch } from '../../../components/app-sidebar/sidebar-header/sidebar-switch';
|
||||
import { RightSidebarService } from '../../right-sidebar';
|
||||
import { ViewService } from '../services/view';
|
||||
import * as styles from './route-container.css';
|
||||
import { useView } from './use-view';
|
||||
import { useViewPosition } from './use-view-position';
|
||||
|
||||
export interface Props {
|
||||
@@ -43,10 +41,10 @@ const ToggleButton = ({
|
||||
};
|
||||
|
||||
export const RouteContainer = ({ route }: Props) => {
|
||||
const view = useView();
|
||||
const view = useService(ViewService).view;
|
||||
const viewPosition = useViewPosition();
|
||||
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
const rightSidebar = useService(RightSidebar);
|
||||
const rightSidebar = useService(RightSidebarService).rightSidebar;
|
||||
const rightSidebarOpen = useLiveData(rightSidebar.isOpen$);
|
||||
const rightSidebarHasViews = useLiveData(rightSidebar.hasViews$);
|
||||
const handleToggleRightSidebar = useCallback(() => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import { Workbench } from '../../entities/workbench';
|
||||
import { WorkbenchService } from '../../services/workbench';
|
||||
import { SplitViewIndicator } from './indicator';
|
||||
import * as styles from './split-view.css';
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({
|
||||
const [indicatorPressed, setIndicatorPressed] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const size = useLiveData(view.size$);
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const activeView = useLiveData(workbench.activeView$);
|
||||
const views = useLiveData(workbench.views$);
|
||||
const isLast = views[views.length - 1] === view;
|
||||
@@ -109,7 +109,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({
|
||||
|
||||
const SplitViewMenu = ({ view }: { view: View }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const views = useLiveData(workbench.views$);
|
||||
|
||||
const viewIndex = views.findIndex(v => v === view);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { View } from '../../entities/view';
|
||||
import { Workbench } from '../../entities/workbench';
|
||||
import { WorkbenchService } from '../../services/workbench';
|
||||
import { SplitViewPanel } from './panel';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import * as styles from './split-view.css';
|
||||
@@ -49,7 +49,7 @@ export const SplitView = ({
|
||||
const [slots, setSlots] = useState<SlotsMap>({});
|
||||
const [resizingViewId, setResizingViewId] = useState<View['id'] | null>(null);
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { useView } from './use-view';
|
||||
import { ViewService } from '../services/view';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
export function useIsActiveView() {
|
||||
const workbench = useService(Workbench);
|
||||
const currentView = useView();
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const view = useService(ViewService).view;
|
||||
|
||||
const activeView = useLiveData(workbench.activeView$);
|
||||
return currentView === activeView;
|
||||
return view === activeView;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useService } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { useView } from './use-view';
|
||||
import { ViewService } from '../services/view';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
export const useViewPosition = () => {
|
||||
const workbench = useService(Workbench);
|
||||
const view = useView();
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const view = useService(ViewService).view;
|
||||
|
||||
const [position, setPosition] = useState(() =>
|
||||
calcPosition(view, workbench.views$.value)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
|
||||
export const ViewContext = createContext<View | null>(null);
|
||||
|
||||
export const useView = () => {
|
||||
const view = useContext(ViewContext);
|
||||
if (!view) {
|
||||
throw new Error(
|
||||
'No view found in context. Make sure you are rendering inside a ViewRoot.'
|
||||
);
|
||||
}
|
||||
return view;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useView } from './use-view';
|
||||
import { useService } from '@toeverything/infra';
|
||||
|
||||
import { ViewService } from '../services/view';
|
||||
|
||||
export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => {
|
||||
const view = useView();
|
||||
const view = useService(ViewService).view;
|
||||
return <view.body.Provider>{children}</view.body.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useView } from './use-view';
|
||||
import { useService } from '@toeverything/infra';
|
||||
|
||||
import { ViewService } from '../services/view';
|
||||
|
||||
export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => {
|
||||
const view = useView();
|
||||
const view = useService(ViewService).view;
|
||||
return <view.header.Provider>{children}</view.header.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { FrameworkScope, useLiveData } from '@toeverything/infra';
|
||||
import { lazy as reactLazy, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
createMemoryRouter,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { viewRoutes } from '../../../router';
|
||||
import type { View } from '../entities/view';
|
||||
import { RouteContainer } from './route-container';
|
||||
import { ViewContext } from './use-view';
|
||||
|
||||
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
|
||||
const Component = reactLazy(() =>
|
||||
@@ -43,7 +42,7 @@ export const ViewRoot = ({ view }: { view: View }) => {
|
||||
|
||||
// https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736
|
||||
return (
|
||||
<ViewContext.Provider value={view}>
|
||||
<FrameworkScope scope={view.scope}>
|
||||
<UNSAFE_LocationContext.Provider value={null as any}>
|
||||
<UNSAFE_RouteContext.Provider
|
||||
value={{
|
||||
@@ -55,6 +54,6 @@ export const ViewRoot = ({ view }: { view: View }) => {
|
||||
<RouterProvider router={viewRouter} />
|
||||
</UNSAFE_RouteContext.Provider>
|
||||
</UNSAFE_LocationContext.Provider>
|
||||
</ViewContext.Provider>
|
||||
</FrameworkScope>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { To } from 'history';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
export const WorkbenchLink = ({
|
||||
to,
|
||||
@@ -13,7 +13,7 @@ export const WorkbenchLink = ({
|
||||
}: React.PropsWithChildren<
|
||||
{ to: To } & React.HTMLProps<HTMLAnchorElement>
|
||||
>) => {
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const basename = useLiveData(workbench.basename$);
|
||||
const link =
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
import { Workbench } from '../entities/workbench';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
|
||||
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
|
||||
import { SplitView } from './split-view/split-view';
|
||||
@@ -15,7 +15,7 @@ const useAdapter = environment.isDesktop
|
||||
: useBindWorkbenchToBrowserRouter;
|
||||
|
||||
export const WorkbenchRoot = () => {
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
// for debugging
|
||||
(window as any).workbench = workbench;
|
||||
@@ -53,7 +53,7 @@ export const WorkbenchRoot = () => {
|
||||
};
|
||||
|
||||
const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
||||
const workbench = useService(Workbench);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const handleOnFocus = useCallback(() => {
|
||||
workbench.active(index);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import {
|
||||
createWorkspaceMutation,
|
||||
deleteWorkspaceMutation,
|
||||
getIsOwnerQuery,
|
||||
getWorkspacesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import {
|
||||
ApplicationStarted,
|
||||
type BlobStorage,
|
||||
catchErrorInto,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
type GlobalState,
|
||||
LiveData,
|
||||
onComplete,
|
||||
OnEvent,
|
||||
onStart,
|
||||
type Workspace,
|
||||
type WorkspaceEngineProvider,
|
||||
type WorkspaceFlavourProvider,
|
||||
type WorkspaceMetadata,
|
||||
type WorkspaceProfileInfo,
|
||||
} from '@toeverything/infra';
|
||||
import { effect, globalBlockSuiteSchema, Service } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { EMPTY, lastValueFrom, map, mergeMap, timeout } from 'rxjs';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import type {
|
||||
AuthService,
|
||||
GraphQLService,
|
||||
WebSocketService,
|
||||
} from '../../cloud';
|
||||
import { AccountChanged } from '../../cloud';
|
||||
import type { WorkspaceEngineStorageProvider } from '../providers/engine';
|
||||
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
|
||||
import { CloudAwarenessConnection } from './engine/awareness-cloud';
|
||||
import { CloudBlobStorage } from './engine/blob-cloud';
|
||||
import { CloudDocEngineServer } from './engine/doc-cloud';
|
||||
import { CloudStaticDocStorage } from './engine/doc-cloud-static';
|
||||
|
||||
const CLOUD_WORKSPACES_CACHE_KEY = 'cloud-workspace:';
|
||||
|
||||
const logger = new DebugLogger('affine:cloud-workspace-flavour-provider');
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.revalidate)
|
||||
@OnEvent(AccountChanged, e => e.revalidate)
|
||||
export class CloudWorkspaceFlavourProviderService
|
||||
extends Service
|
||||
implements WorkspaceFlavourProvider
|
||||
{
|
||||
constructor(
|
||||
private readonly globalState: GlobalState,
|
||||
private readonly authService: AuthService,
|
||||
private readonly storageProvider: WorkspaceEngineStorageProvider,
|
||||
private readonly graphqlService: GraphQLService,
|
||||
private readonly webSocketService: WebSocketService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
flavour: WorkspaceFlavour = WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
async deleteWorkspace(id: string): Promise<void> {
|
||||
await this.graphqlService.gql({
|
||||
query: deleteWorkspaceMutation,
|
||||
variables: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
this.revalidate();
|
||||
await this.waitForLoaded();
|
||||
}
|
||||
async createWorkspace(
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata> {
|
||||
const tempId = nanoid();
|
||||
|
||||
// create workspace on cloud, get workspace id
|
||||
const {
|
||||
createWorkspace: { id: workspaceId },
|
||||
} = await this.graphqlService.gql({
|
||||
query: createWorkspaceMutation,
|
||||
});
|
||||
|
||||
// save the initial state to local storage, then sync to cloud
|
||||
const blobStorage = this.storageProvider.getBlobStorage(workspaceId);
|
||||
const docStorage = this.storageProvider.getDocStorage(workspaceId);
|
||||
|
||||
const docCollection = new DocCollection({
|
||||
id: tempId,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
blobStorages: [() => ({ crud: blobStorage })],
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
await initial(docCollection, blobStorage);
|
||||
|
||||
// save workspace to local storage, should be vary fast
|
||||
await docStorage.doc.set(
|
||||
workspaceId,
|
||||
encodeStateAsUpdate(docCollection.doc)
|
||||
);
|
||||
for (const subdocs of docCollection.doc.getSubdocs()) {
|
||||
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
|
||||
this.revalidate();
|
||||
await this.waitForLoaded();
|
||||
|
||||
return { id: workspaceId, flavour: WorkspaceFlavour.AFFINE_CLOUD };
|
||||
}
|
||||
revalidate = effect(
|
||||
map(() => {
|
||||
return { accountId: this.authService.session.account$.value?.id };
|
||||
}),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) => {
|
||||
return fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return null; // no cloud workspace if no account
|
||||
}
|
||||
|
||||
const { workspaces } = await this.graphqlService.gql({
|
||||
query: getWorkspacesQuery,
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = workspaces.map(({ id }) => id);
|
||||
return {
|
||||
accountId,
|
||||
workspaces: ids.map(id => ({
|
||||
id,
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
})),
|
||||
};
|
||||
}).pipe(
|
||||
mergeMap(data => {
|
||||
if (data) {
|
||||
const { accountId, workspaces } = data;
|
||||
this.globalState.set(
|
||||
CLOUD_WORKSPACES_CACHE_KEY + accountId,
|
||||
workspaces
|
||||
);
|
||||
this.workspaces$.next(workspaces);
|
||||
} else {
|
||||
this.workspaces$.next([]);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, err => {
|
||||
logger.error('error to revalidate cloud workspaces', err);
|
||||
}),
|
||||
onStart(() => this.isLoading$.next(true)),
|
||||
onComplete(() => this.isLoading$.next(false))
|
||||
);
|
||||
},
|
||||
({ accountId }) => {
|
||||
if (accountId) {
|
||||
this.workspaces$.next(
|
||||
this.globalState.get(CLOUD_WORKSPACES_CACHE_KEY + accountId) ?? []
|
||||
);
|
||||
} else {
|
||||
this.workspaces$.next([]);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
error$ = new LiveData<any>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
workspaces$ = new LiveData<WorkspaceMetadata[]>([]);
|
||||
async getWorkspaceProfile(
|
||||
id: string
|
||||
): Promise<WorkspaceProfileInfo | undefined> {
|
||||
// get information from both cloud and local storage
|
||||
|
||||
// we use affine 'static' storage here, which use http protocol, no need to websocket.
|
||||
const cloudStorage = new CloudStaticDocStorage(id);
|
||||
const docStorage = this.storageProvider.getDocStorage(id);
|
||||
// download root doc
|
||||
const localData = await docStorage.doc.get(id);
|
||||
const cloudData = await cloudStorage.pull(id);
|
||||
|
||||
const isOwner = await this.getIsOwner(id);
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return {
|
||||
isOwner,
|
||||
};
|
||||
}
|
||||
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
if (localData) applyUpdate(bs.doc, localData);
|
||||
if (cloudData) applyUpdate(bs.doc, cloudData.data);
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
isOwner,
|
||||
};
|
||||
}
|
||||
async getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
|
||||
const localBlob = await this.storageProvider.getBlobStorage(id).get(blob);
|
||||
|
||||
if (localBlob) {
|
||||
return localBlob;
|
||||
}
|
||||
|
||||
const cloudBlob = new CloudBlobStorage(id);
|
||||
return await cloudBlob.get(blob);
|
||||
}
|
||||
getEngineProvider(workspace: Workspace): WorkspaceEngineProvider {
|
||||
return {
|
||||
getAwarenessConnections: () => {
|
||||
return [
|
||||
new BroadcastChannelAwarenessConnection(
|
||||
workspace.id,
|
||||
workspace.awareness
|
||||
),
|
||||
new CloudAwarenessConnection(
|
||||
workspace.id,
|
||||
workspace.awareness,
|
||||
this.webSocketService.newSocket()
|
||||
),
|
||||
];
|
||||
},
|
||||
getDocServer: () => {
|
||||
return new CloudDocEngineServer(
|
||||
workspace.id,
|
||||
this.webSocketService.newSocket()
|
||||
);
|
||||
},
|
||||
getDocStorage: () => {
|
||||
return this.storageProvider.getDocStorage(workspace.id);
|
||||
},
|
||||
getLocalBlobStorage: () => {
|
||||
return this.storageProvider.getBlobStorage(workspace.id);
|
||||
},
|
||||
getRemoteBlobStorages() {
|
||||
return [new CloudBlobStorage(workspace.id)];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getIsOwner(workspaceId: string) {
|
||||
return (
|
||||
await lastValueFrom(
|
||||
this.graphqlService
|
||||
.rxGql({
|
||||
query: getIsOwnerQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
.pipe(timeout(3000))
|
||||
)
|
||||
).isOwner;
|
||||
}
|
||||
|
||||
private waitForLoaded() {
|
||||
return this.isLoading$.waitFor(loading => !loading);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AwarenessConnection } from '@toeverything/infra';
|
||||
import type { Awareness } from 'y-protocols/awareness.js';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
} from 'y-protocols/awareness.js';
|
||||
|
||||
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
|
||||
|
||||
type ChannelMessage =
|
||||
| { type: 'connect' }
|
||||
| { type: 'update'; update: Uint8Array };
|
||||
|
||||
export class BroadcastChannelAwarenessConnection
|
||||
implements AwarenessConnection
|
||||
{
|
||||
channel: BroadcastChannel | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly workspaceId: string,
|
||||
private readonly awareness: Awareness
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.channel = new BroadcastChannel('awareness:' + this.workspaceId);
|
||||
this.channel.postMessage({
|
||||
type: 'connect',
|
||||
} satisfies ChannelMessage);
|
||||
this.awareness.on('update', (changes: AwarenessChanges, origin: unknown) =>
|
||||
this.handleAwarenessUpdate(changes, origin)
|
||||
);
|
||||
this.channel.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<ChannelMessage>) => {
|
||||
this.handleChannelMessage(event);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
|
||||
handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) {
|
||||
if (origin === 'remote') {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedClients = Object.values(changes).reduce((res, cur) =>
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||
this.channel?.postMessage({
|
||||
type: 'update',
|
||||
update: update,
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
|
||||
handleChannelMessage(event: MessageEvent<ChannelMessage>) {
|
||||
if (event.data.type === 'update') {
|
||||
const update = event.data.update;
|
||||
applyAwarenessUpdate(this.awareness, update, 'remote');
|
||||
}
|
||||
if (event.data.type === 'connect') {
|
||||
this.channel?.postMessage({
|
||||
type: 'update',
|
||||
update: encodeAwarenessUpdate(this.awareness, [
|
||||
this.awareness.clientID,
|
||||
]),
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { AwarenessConnection } from '@toeverything/infra';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
removeAwarenessStates,
|
||||
} from 'y-protocols/awareness';
|
||||
|
||||
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
|
||||
|
||||
const logger = new DebugLogger('affine:awareness:socketio');
|
||||
|
||||
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
|
||||
|
||||
export class CloudAwarenessConnection implements AwarenessConnection {
|
||||
constructor(
|
||||
private readonly workspaceId: string,
|
||||
private readonly awareness: Awareness,
|
||||
private readonly socket: Socket
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.socket.on('server-awareness-broadcast', this.awarenessBroadcast);
|
||||
this.socket.on(
|
||||
'new-client-awareness-init',
|
||||
this.newClientAwarenessInitHandler
|
||||
);
|
||||
this.awareness.on('update', this.awarenessUpdate);
|
||||
|
||||
window.addEventListener('beforeunload', this.windowBeforeUnloadHandler);
|
||||
|
||||
this.socket.on('connect', () => this.handleConnect());
|
||||
this.socket.on('server-version-rejected', this.handleReject);
|
||||
|
||||
if (this.socket.connected) {
|
||||
this.handleConnect();
|
||||
} else {
|
||||
this.socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.awareness.clientID],
|
||||
'disconnect'
|
||||
);
|
||||
this.awareness.off('update', this.awarenessUpdate);
|
||||
this.socket.emit('client-leave-awareness', this.workspaceId);
|
||||
this.socket.off('server-awareness-broadcast', this.awarenessBroadcast);
|
||||
this.socket.off(
|
||||
'new-client-awareness-init',
|
||||
this.newClientAwarenessInitHandler
|
||||
);
|
||||
this.socket.off('connect', this.handleConnect);
|
||||
this.socket.off('server-version-rejected', this.handleReject);
|
||||
window.removeEventListener('unload', this.windowBeforeUnloadHandler);
|
||||
}
|
||||
|
||||
awarenessBroadcast = ({
|
||||
workspaceId: wsId,
|
||||
awarenessUpdate,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
awarenessUpdate: string;
|
||||
}) => {
|
||||
if (wsId !== this.workspaceId) {
|
||||
return;
|
||||
}
|
||||
applyAwarenessUpdate(
|
||||
this.awareness,
|
||||
base64ToUint8Array(awarenessUpdate),
|
||||
'remote'
|
||||
);
|
||||
};
|
||||
|
||||
awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
||||
if (origin === 'remote') {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedClients = Object.values(changes).reduce((res, cur) =>
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||
uint8ArrayToBase64(update)
|
||||
.then(encodedUpdate => {
|
||||
this.socket.emit('awareness-update', {
|
||||
workspaceId: this.workspaceId,
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
newClientAwarenessInitHandler = () => {
|
||||
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [
|
||||
this.awareness.clientID,
|
||||
]);
|
||||
uint8ArrayToBase64(awarenessUpdate)
|
||||
.then(encodedAwarenessUpdate => {
|
||||
this.socket.emit('awareness-update', {
|
||||
workspaceId: this.workspaceId,
|
||||
awarenessUpdate: encodedAwarenessUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
windowBeforeUnloadHandler = () => {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.awareness.clientID],
|
||||
'window unload'
|
||||
);
|
||||
};
|
||||
|
||||
handleConnect = () => {
|
||||
this.socket.emit(
|
||||
'client-handshake-awareness',
|
||||
{
|
||||
workspaceId: this.workspaceId,
|
||||
version: runtimeConfig.appVersion,
|
||||
},
|
||||
(res: any) => {
|
||||
logger.debug('awareness handshake finished', res);
|
||||
this.socket.emit('awareness-init', this.workspaceId, (res: any) => {
|
||||
logger.debug('awareness-init finished', res);
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleReject = () => {
|
||||
this.socket.off('server-version-rejected', this.handleReject);
|
||||
this.disconnect();
|
||||
this.socket.disconnect();
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user