feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions

View File

@@ -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();
}
}

View 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();
}
}

View File

@@ -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))
);
})
);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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();
}
}

View 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;
}

View 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]);
}

View 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);
}
}

View 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;
};
}

View 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;
}
};
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View 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,
};
}
}

View File

@@ -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: [] };
}
}

View 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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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]);
}

View File

@@ -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) => {

View 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);
}

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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>

View File

@@ -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 = (

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
# navigation
Provide support for forward and back buttons.

View File

@@ -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
);

View File

@@ -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]);
}

View File

@@ -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);
}

View File

@@ -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$);

View File

@@ -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> = [];

View File

@@ -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))
);
})
);
}

View 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]);
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { WorkspacePermission } from '../entities/permission';
export class WorkspacePermissionService extends Service {
permission = this.framework.createEntity(WorkspacePermission);
}

View File

@@ -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;
}
}

View 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]);
}

View File

@@ -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) {

View File

@@ -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,
});
};

View 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))
);
})
);
}

View 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]);
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { WorkspaceQuota } from '../entities/quota';
export class WorkspaceQuotaService extends Service {
quota = this.framework.createEntity(WorkspaceQuota);
}

View 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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}
/**

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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);
})
)
)
);
}

View File

@@ -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();
}
}

View 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]);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View 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,
},
});
}
}

View 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);
}

View 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));
});
}
}

View File

@@ -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);

View File

@@ -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]);
}

View File

@@ -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);
}

View 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,
});
}
}

View File

@@ -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(

View 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]);
}

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
import { Scope } from '@toeverything/infra';
import type { To } from 'history';
export class ViewScope extends Scope<{
id: string;
defaultLocation?: To | undefined;
}> {}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { View } from '../entities/view';
export class ViewService extends Service {
view = this.framework.createEntity(View);
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { Workbench } from '../entities/workbench';
export class WorkbenchService extends Service {
workbench = this.framework.createEntity(Workbench);
}

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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, {

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;
};

View File

@@ -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>;
};

View File

@@ -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>;
};

View File

@@ -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>
);
};

View File

@@ -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 =

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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