feat(workspace): add blob and storage limit (#5535)

close TOV-343 AFF-508 TOV-461 TOV-460 TOV-419

Add `isOverCapacity ` status to detect if blob usage exceeds limits.
Add `onCapacityChange` and `onBlobSet` to monitor if the storage or blob exceeds the capacity limit.
Global modals `LocalQuotaModal` and `CloudQuotaModal` have been added, with the upload size of the blob being limited within the modal components.
The notification component has been adjusted, now you can pass in `action` click events and `actionLabel` .
This commit is contained in:
JimmFly
2024-01-24 07:34:51 +00:00
parent c566952e09
commit 25897dc404
25 changed files with 394 additions and 58 deletions

View File

@@ -25,6 +25,7 @@ export class QuotaManagementService {
storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit,
humanReadableName: quota.feature.humanReadable.name,
};
}
@@ -45,11 +46,12 @@ export class QuotaManagementService {
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found');
const { storageQuota, blobLimit } = await this.getUserQuota(owner.id);
const { humanReadableName, storageQuota, blobLimit } =
await this.getUserQuota(owner.id);
// get all workspaces size of owner used
const usedSize = await this.getUserUsage(owner.id);
return { storageQuota, usedSize, blobLimit };
return { humanReadableName, storageQuota, usedSize, blobLimit };
}
async checkBlobQuota(workspaceId: string, size: number) {

View File

@@ -1,4 +1,5 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features';
@@ -42,13 +43,16 @@ export type Quota = z.infer<typeof QuotaSchema>;
@ObjectType()
export class QuotaQueryType {
@Field(() => Int)
@Field(() => String)
humanReadableName!: string;
@Field(() => SafeIntResolver)
storageQuota!: number;
@Field(() => Int)
@Field(() => SafeIntResolver)
usedSize!: number;
@Field(() => Int)
@Field(() => SafeIntResolver)
blobLimit!: number;
}

View File

@@ -190,9 +190,10 @@ type Query {
}
type QuotaQueryType {
blobLimit: Int!
storageQuota: Int!
usedSize: Int!
blobLimit: SafeInt!
humanReadableName: String!
storageQuota: SafeInt!
usedSize: SafeInt!
}
type RemoveAvatar {

View File

@@ -1,8 +1,15 @@
import { DebugLogger } from '@affine/debug';
import { Slot } from '@blocksuite/global/utils';
import { difference } from 'lodash-es';
import { BlobStorageOverCapacity } from './error';
const logger = new DebugLogger('affine:blob-engine');
export interface BlobStatus {
isStorageOverCapacity: boolean;
}
/**
* # BlobEngine
*
@@ -12,6 +19,19 @@ const logger = new DebugLogger('affine:blob-engine');
*/
export class BlobEngine {
private abort: AbortController | null = null;
private _status: BlobStatus = { isStorageOverCapacity: false };
onStatusChange = new Slot<BlobStatus>();
singleBlobSizeLimit: number = 100 * 1024 * 1024;
onAbortLargeBlob = new Slot<Blob>();
private set status(s: BlobStatus) {
logger.debug('status change', s);
this._status = s;
this.onStatusChange.emit(s);
}
get status() {
return this._status;
}
constructor(
private readonly local: BlobStorage,
@@ -53,7 +73,7 @@ export class BlobEngine {
}
async sync() {
if (this.local.readonly) {
if (this.local.readonly || this._status.isStorageOverCapacity) {
return;
}
logger.debug('start syncing blob...');
@@ -78,6 +98,11 @@ export class BlobEngine {
await remote.set(key, data);
}
} catch (err) {
if (err instanceof BlobStorageOverCapacity) {
this.status = {
isStorageOverCapacity: true,
};
}
logger.error(
`error when sync ${key} from [${this.local.name}] to [${remote.name}]`,
err
@@ -122,6 +147,12 @@ export class BlobEngine {
throw new Error('local peer is readonly');
}
if (value.size > this.singleBlobSizeLimit) {
this.onAbortLargeBlob.emit(value);
logger.error('blob over limit, abort set');
return key;
}
// await upload to the local peer
await this.local.set(key, value);
@@ -131,7 +162,7 @@ export class BlobEngine {
.filter(r => !r.readonly)
.map(peer =>
peer.set(key, value).catch(err => {
logger.error('error when upload to peer', err);
logger.error('Error when uploading to peer', err);
})
)
)

View File

@@ -0,0 +1,5 @@
export class BlobStorageOverCapacity extends Error {
constructor(public originError?: any) {
super('Blob storage over capacity.');
}
}

View File

@@ -2,11 +2,12 @@ import { Slot } from '@blocksuite/global/utils';
import { throwIfAborted } from '../utils/throw-if-aborted';
import type { AwarenessProvider } from './awareness';
import type { BlobEngine } from './blob';
import type { BlobEngine, BlobStatus } from './blob';
import type { SyncEngine, SyncEngineStatus } from './sync';
export interface WorkspaceEngineStatus {
sync: SyncEngineStatus;
blob: BlobStatus;
}
/**
@@ -34,10 +35,18 @@ export class WorkspaceEngine {
) {
this._status = {
sync: sync.status,
blob: blob.status,
};
sync.onStatusChange.on(status => {
this.status = {
sync: status,
blob: blob.status,
};
});
blob.onStatusChange.on(status => {
this.status = {
sync: sync.status,
blob: status,
};
});
}
@@ -71,4 +80,5 @@ export class WorkspaceEngine {
export * from './awareness';
export * from './blob';
export * from './error';
export * from './sync';

View File

@@ -11,7 +11,8 @@ export type Notification = {
progressingBar?: boolean;
multimedia?: React.ReactNode | JSX.Element;
// actions
undo?: () => Promise<void>;
action?: () => Promise<void>;
actionLabel?: string;
};
const notificationsBaseAtom = atom<Notification[]>([]);
@@ -48,19 +49,19 @@ export const pushNotificationAtom = atom<null, [Notification], void>(
set(notificationsBaseAtom, notifications =>
notifications.filter(notification => notification.key !== key)
);
const undo: (() => Promise<void>) | undefined = newNotification.undo
const action: (() => Promise<void>) | undefined = newNotification.action
? (() => {
const undo: () => Promise<void> = newNotification.undo;
return async function undoNotificationWrapper() {
const action: () => Promise<void> = newNotification.action;
return async function actionNotificationWrapper() {
removeNotification();
return undo();
return action();
};
})()
: undefined;
set(notificationsBaseAtom, notifications => [
// push to the top
{ ...newNotification, undo },
{ ...newNotification, action },
...notifications,
]);
}

View File

@@ -2,6 +2,7 @@
// License on the MIT
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
import * as Toast from '@radix-ui/react-toast';
import clsx from 'clsx';
@@ -71,6 +72,7 @@ const typeColorMap = {
};
function NotificationCard(props: NotificationCardProps): ReactNode {
const t = useAFFiNEI18N();
const removeNotification = useSetAtom(removeNotificationAtom);
const { notification, notifications, setHeights, heights, index } = props;
@@ -134,7 +136,6 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
notificationNode.style.height = 'auto';
const newHeight = notificationNode.getBoundingClientRect().height;
notificationNode.style.height = originalHeight;
setInitialHeight(newHeight);
setHeights(heights => {
@@ -190,9 +191,9 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
};
}, [duration, expand, onClickRemove]);
const onClickUndo = useCallback(() => {
if (notification.undo) {
notification.undo().catch(err => {
const onClickAction = useCallback(() => {
if (notification.action) {
notification.action().catch(err => {
console.error(err);
});
}
@@ -298,7 +299,7 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
<div className={styles.notificationTitleContactStyle}>
{notification.title}
</div>
{notification.undo && (
{notification.action && (
<div
className={clsx(styles.undoButtonStyle, {
[styles.darkColorStyle]:
@@ -306,15 +307,16 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
notification.theme !== 'default',
[styles.undoButtonWithMediaStyle]: notification.multimedia,
})}
onClick={onClickUndo}
onClick={onClickAction}
>
UNDO
{notification.actionLabel ??
t['com.affine.keyboardShortcuts.undo']()}
</div>
)}
{notification.multimedia ? null : (
<IconButton
className={clsx(styles.closeButtonStyle, {
[styles.closeButtonWithoutUndoStyle]: !notification.undo,
[styles.closeButtonWithoutUndoStyle]: !notification.action,
})}
style={{
color:

View File

@@ -13,6 +13,7 @@ export const openQuickSearchModalAtom = atom(false);
export const openOnboardingModalAtom = atom(false);
export const openSignOutModalAtom = atom(false);
export const openPaymentDisableAtom = atom(false);
export const openQuotaModalAtom = atom(false);
export type SettingAtom = Pick<
SettingProps,

View File

@@ -6,10 +6,9 @@ import { openSettingModalAtom, type PageMode } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { timestampToLocalTime } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons';
@@ -154,10 +153,13 @@ const HistoryEditorPreview = ({
const planPromptClosedAtom = atom(false);
const PlanPrompt = () => {
const [subscription] = useUserSubscription();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const freePlan = subscription?.plan === SubscriptionPlan.Free;
const workspaceQuota = useWorkspaceQuota(currentWorkspace.id);
const isProWorkspace = useMemo(() => {
return workspaceQuota?.humanReadable.name.toLowerCase() !== 'free';
}, [workspaceQuota]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom);
@@ -178,7 +180,7 @@ const PlanPrompt = () => {
const planTitle = useMemo(() => {
return (
<div className={styles.planPromptTitle}>
{freePlan
{!isProWorkspace
? t[
'com.affine.history.confirm-restore-modal.plan-prompt.limited-title'
]()
@@ -191,14 +193,15 @@ const PlanPrompt = () => {
/>
</div>
);
}, [closeFreePlanPrompt, freePlan, t]);
}, [closeFreePlanPrompt, isProWorkspace, t]);
const planDescription = useMemo(() => {
if (freePlan) {
if (!isProWorkspace) {
return (
<>
<Trans i18nKey="com.affine.history.confirm-restore-modal.free-plan-prompt.description">
Free users can view up to the <b>last 7 days</b> of page history.
With the workspace creator&apos;s Free account, every member can
access up to <b>7 days</b> of version history.
</Trans>
{isOwner ? (
<span
@@ -215,11 +218,12 @@ const PlanPrompt = () => {
} else {
return (
<Trans i18nKey="com.affine.history.confirm-restore-modal.pro-plan-prompt.description">
Pro users can view up to the <b>last 30 days</b> of page history.
With the workspace creator&apos;s Pro account, every member enjoys the
privilege of accessing up to <b>30 days</b> of version history.
</Trans>
);
}
}, [freePlan, isOwner, onClickUpgrade, t]);
}, [isOwner, isProWorkspace, onClickUpgrade, t]);
return !planPromptClosed ? (
<div className={styles.planPromptWrapper}>

View File

@@ -0,0 +1,91 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom, openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useUserQuota } from '@affine/core/hooks/use-quota';
import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import bytes from 'bytes';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
export const CloudQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [open, setOpen] = useAtom(openQuotaModalAtom);
const workspaceQuota = useWorkspaceQuota(currentWorkspace.id);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const userQuota = useUserQuota();
const isFreePlanOwner = useMemo(() => {
return isOwner && userQuota?.humanReadable.name.toLowerCase() === 'free';
}, [isOwner, userQuota?.humanReadable.name]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const handleUpgradeConfirm = useCallback(() => {
if (isFreePlanOwner) {
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
}
setOpen(false);
}, [isFreePlanOwner, setOpen, setSettingModalAtom]);
const description = useMemo(() => {
if (userQuota && isFreePlanOwner) {
return t['com.affine.payment.blob-limit.description.owner.free']({
planName: userQuota.humanReadable.name,
currentQuota: userQuota.humanReadable.blobLimit,
upgradeQuota: '100MB',
});
}
if (isOwner && userQuota?.humanReadable.name.toLowerCase() === 'pro') {
return t['com.affine.payment.blob-limit.description.owner.pro']({
planName: userQuota.humanReadable.name,
quota: userQuota.humanReadable.blobLimit,
});
}
return t['com.affine.payment.blob-limit.description.member']({
quota: workspaceQuota.humanReadable.blobLimit,
});
}, [
isFreePlanOwner,
isOwner,
t,
userQuota,
workspaceQuota.humanReadable.blobLimit,
]);
useEffect(() => {
currentWorkspace.engine.blob.singleBlobSizeLimit = bytes.parse(
workspaceQuota.blobLimit.toString()
);
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob.on(() => {
setOpen(true);
});
return () => {
disposable?.dispose();
};
}, [currentWorkspace.engine.blob, setOpen, workspaceQuota.blobLimit]);
return (
<ConfirmModal
open={open}
title={t['com.affine.payment.blob-limit.title']()}
onOpenChange={setOpen}
description={description}
cancelButtonOptions={{
hidden: !isFreePlanOwner,
}}
onConfirm={handleUpgradeConfirm}
confirmButtonOptions={{
type: 'primary',
children: isFreePlanOwner
? t['com.affine.payment.upgrade']()
: t['Got it'](),
}}
/>
);
};

View File

@@ -0,0 +1,2 @@
export * from './cloud-quota-modal';
export * from './local-quota-modal';

View File

@@ -0,0 +1,44 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom } from '@affine/core/atoms';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useEffect } from 'react';
export const LocalQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [open, setOpen] = useAtom(openQuotaModalAtom);
const onConfirm = useCallback(() => {
setOpen(false);
}, [setOpen]);
useEffect(() => {
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob.on(() => {
setOpen(true);
});
return () => {
disposable?.dispose();
};
}, [currentWorkspace.engine.blob.onAbortLargeBlob, setOpen]);
return (
<ConfirmModal
open={open}
title={t['com.affine.payment.blob-limit.title']()}
description={t['com.affine.payment.blob-limit.description.local']({
quota: '100MB',
})}
onOpenChange={setOpen}
cancelButtonOptions={{
hidden: true,
}}
onConfirm={onConfirm}
confirmButtonOptions={{
type: 'primary',
children: t['Got it'](),
}}
/>
);
};

View File

@@ -79,6 +79,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
t['com.affine.payment.benefit-5']({ capacity: '10M' }),
t['com.affine.payment.benefit-6']({ capacity: '3' }),
t['com.affine.payment.benefit-7']({ capacity: '7' }),
],
},
],
@@ -96,6 +97,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
t['com.affine.payment.benefit-5']({ capacity: '100M' }),
t['com.affine.payment.benefit-6']({ capacity: '10' }),
t['com.affine.payment.benefit-7']({ capacity: '30' }),
],
},
],

View File

@@ -11,13 +11,9 @@ import ReactDOMServer from 'react-dom/server';
class CustomAttachmentService extends AttachmentService {
override mounted(): void {
//TODO: get user type from store
const userType = 'pro';
if (userType === 'pro') {
this.maxFileSize = bytes.parse('100MB');
} else {
this.maxFileSize = bytes.parse('10MB');
}
// blocksuite default max file size is 10MB, we override it to 2GB
// but the real place to limit blob size is CloudQuotaModal / LocalQuotaModal
this.maxFileSize = bytes.parse('2GB');
}
}

View File

@@ -1,11 +1,15 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { Avatar } from '@affine/component/ui/avatar';
import { Loading } from '@affine/component/ui/loading';
import { Tooltip } from '@affine/component/ui/tooltip';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace';
import {
CloudWorkspaceIcon,
@@ -14,7 +18,7 @@ import {
NoNetworkIcon,
UnsyncIcon,
} from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { debounce, mean } from 'lodash-es';
import {
forwardRef,
@@ -86,12 +90,23 @@ const OfflineStatus = () => {
};
const useSyncEngineSyncProgress = () => {
const t = useAFFiNEI18N();
const isOnline = useSystemOnline();
const pushNotification = useSetAtom(pushNotificationAtom);
const [syncEngineStatus, setSyncEngineStatus] =
useState<SyncEngineStatus | null>(null);
const [isOverCapacity, setIsOverCapacity] = useState(false);
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const jumpToPricePlan = useCallback(async () => {
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
}, [setSettingModalAtom]);
// debounce sync engine status
useEffect(() => {
@@ -108,10 +123,39 @@ const useSyncEngineSyncProgress = () => {
}
)
);
const disposableOverCapacity =
currentWorkspace.engine.blob.onStatusChange.on(
debounce(status => {
const isOver = status?.isStorageOverCapacity;
if (!isOver) {
setIsOverCapacity(false);
return;
}
setIsOverCapacity(true);
if (isOwner) {
pushNotification({
type: 'warning',
title: t['com.affine.payment.storage-limit.title'](),
message:
t['com.affine.payment.storage-limit.description.owner'](),
actionLabel: t['com.affine.payment.storage-limit.view'](),
action: jumpToPricePlan,
});
} else {
pushNotification({
type: 'warning',
title: t['com.affine.payment.storage-limit.title'](),
message:
t['com.affine.payment.storage-limit.description.member'](),
});
}
})
);
return () => {
disposable?.dispose();
disposableOverCapacity?.dispose();
};
}, [currentWorkspace]);
}, [currentWorkspace, isOwner, jumpToPricePlan, pushNotification, t]);
const progress = useMemo(() => {
if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) {
@@ -151,20 +195,29 @@ const useSyncEngineSyncProgress = () => {
if (syncEngineStatus.retrying) {
return 'Sync disconnected due to unexpected issues, reconnecting.';
}
if (isOverCapacity) {
return 'Sync failed due to insufficient cloud storage space.';
}
return 'Synced with AFFiNE Cloud';
}, [currentWorkspace.flavour, isOnline, progress, syncEngineStatus]);
}, [
currentWorkspace.flavour,
isOnline,
isOverCapacity,
progress,
syncEngineStatus,
]);
const CloudWorkspaceSyncStatus = useCallback(() => {
if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) {
return SyncingWorkspaceStatus({
progress: progress ? Math.max(progress, 0.2) : undefined,
});
} else if (syncEngineStatus.retrying) {
} else if (syncEngineStatus.retrying || isOverCapacity) {
return UnSyncWorkspaceStatus();
} else {
return CloudWorkspaceStatus();
}
}, [progress, syncEngineStatus]);
}, [isOverCapacity, progress, syncEngineStatus]);
return {
message: content,

View File

@@ -0,0 +1,41 @@
import { workspaceQuotaQuery } from '@affine/graphql';
import bytes from 'bytes';
import { useCallback } from 'react';
import { useQuery } from './use-query';
export const useWorkspaceQuota = (workspaceId: string) => {
const { data } = useQuery({
query: workspaceQuotaQuery,
variables: {
id: workspaceId,
},
});
const changeToHumanReadable = useCallback((value: string | number) => {
return bytes.format(bytes.parse(value));
}, []);
const quotaData = data.workspace.quota;
const blobLimit = BigInt(quotaData.blobLimit);
const storageQuota = BigInt(quotaData.storageQuota);
const usedSize = BigInt(quotaData.usedSize);
const humanReadableBlobLimit = changeToHumanReadable(blobLimit.toString());
const humanReadableStorageQuota = changeToHumanReadable(
storageQuota.toString()
);
const humanReadableUsedSize = changeToHumanReadable(usedSize.toString());
return {
blobLimit,
storageQuota,
usedSize,
humanReadable: {
name: quotaData.humanReadableName,
blobLimit: humanReadableBlobLimit,
storageQuota: humanReadableStorageQuota,
usedSize: humanReadableUsedSize,
},
};
};

View File

@@ -67,6 +67,18 @@ const SignOutModal = lazy(() =>
}))
);
const LocalQuotaModal = lazy(() =>
import('../components/affine/quota-reached-modal').then(module => ({
default: module.LocalQuotaModal,
}))
);
const CloudQuotaModal = lazy(() =>
import('../components/affine/quota-reached-modal').then(module => ({
default: module.CloudQuotaModal,
}))
);
export const Setting = () => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] =
@@ -169,7 +181,13 @@ export function CurrentWorkspaceModals() {
</Suspense>
)}
<WorkspaceGuideModal />
{currentWorkspace && <Setting />}
{currentWorkspace ? <Setting /> : null}
{currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && (
<LocalQuotaModal />
)}
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && (
<CloudQuotaModal />
)}
</>
);
}

View File

@@ -905,6 +905,7 @@ export const workspaceQuotaQuery = {
query workspaceQuota($id: String!) {
workspace(id: $id) {
quota {
humanReadableName
storageQuota
usedSize
blobLimit

View File

@@ -1,6 +1,7 @@
query workspaceQuota($id: String!) {
workspace(id: $id) {
quota {
humanReadableName
storageQuota
usedSize
blobLimit

View File

@@ -18,3 +18,5 @@ export function getBaseUrl(): string {
}
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
export { GraphQLError } from 'graphql';

View File

@@ -854,6 +854,7 @@ export type WorkspaceQuotaQuery = {
__typename?: 'WorkspaceType';
quota: {
__typename?: 'QuotaQueryType';
humanReadableName: string;
storageQuota: number;
usedSize: number;
blobLimit: number;

View File

@@ -354,7 +354,7 @@
"com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart tot apply update.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart ot apply update.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "Youve got the latest version of AFFiNE.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.",
"com.affine.aboutAFFiNE.community.title": "Communities",
@@ -724,6 +724,7 @@
"com.affine.payment.benefit-4": "{{capacity}} of Cloud Storage",
"com.affine.payment.benefit-5": "{{capacity}} of maximum file size",
"com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}",
"com.affine.payment.benefit-7": "{{capacity}}-days version history",
"com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription",
"com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
"com.affine.payment.billing-setting.change-plan": "Change Plan",
@@ -805,6 +806,15 @@
"com.affine.payment.member-limit.pro.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. If you want to continue adding collaboration members, you can create a new workspace.",
"com.affine.payment.member-limit.free.confirm": "Upgrade",
"com.affine.payment.member-limit.pro.confirm": "Got it",
"com.affine.payment.blob-limit.title": "You have reached the limit",
"com.affine.payment.blob-limit.description.local": "The maximum file upload size for local workspaces is {{quota}}.",
"com.affine.payment.blob-limit.description.owner.free": "{{planName}} users can upload files with a maximum size of {{currentQuota}}. You can upgrade your account to unlock a maximum file size of {{upgradeQuota}}.",
"com.affine.payment.blob-limit.description.owner.pro": "{{planName}} users can upload files with a maximum size of {{quota}}.",
"com.affine.payment.blob-limit.description.member": "The maximum file upload size for this joined workspace is {{quota}}. You can contact the owner of this workspace.",
"com.affine.payment.storage-limit.title": "Sync failed",
"com.affine.payment.storage-limit.description.owner": "Cloud storage is insufficient. You can upgrade your account to unlock more cloud storage.",
"com.affine.payment.storage-limit.description.member": "Cloud storage is insufficient. Please contact the owner of that workspace.",
"com.affine.payment.storage-limit.view": "View",
"com.affine.publicLinkDisableModal.button.cancel": "Cancel",
"com.affine.publicLinkDisableModal.button.disable": "Disable",
"com.affine.publicLinkDisableModal.description": "Disabling this public link will prevent anyone with the link from accessing this page.",
@@ -1029,8 +1039,8 @@
"com.affine.history.confirm-restore-modal.load-more": "Load More",
"com.affine.history.confirm-restore-modal.plan-prompt.title": "HELP INFO",
"com.affine.history.confirm-restore-modal.plan-prompt.limited-title": "LIMITED PAGE HISTORY",
"com.affine.history.confirm-restore-modal.free-plan-prompt.description": "Free users can view up to the <1>last 7 days<1> of page history.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.description": "Pro users can view up to the <1>last 30 days<1> of page history.",
"com.affine.history.confirm-restore-modal.free-plan-prompt.description": "With the workspace creator's Free account, every member can access up to <1>7 days<1> of version history.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.description": "With the workspace creator's Pro account, every member enjoys the privilege of accessing up to <1>30 days<1> of version history.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.upgrade": "Upgrade",
"com.affine.share-page.header.present": "Present",
"com.affine.page-operation.add-linked-page": "Add linked page",

View File

@@ -2,11 +2,14 @@ import {
deleteBlobMutation,
fetchWithTraceReport,
getBaseUrl,
GraphQLError,
listBlobsQuery,
setBlobMutation,
} from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import type { BlobStorage } from '@affine/workspace';
import { BlobStorageOverCapacity } from '@affine/workspace';
import { isArray } from 'lodash-es';
import { bufferToBlob } from '../utils/buffer-to-blob';
@@ -31,14 +34,24 @@ export const createAffineCloudBlobStorage = (
},
set: async (key, value) => {
// set blob will check blob size & quota
const result = await fetcher({
return await fetcher({
query: setBlobMutation,
variables: {
workspaceId,
blob: new File([value], key),
},
});
return result.setBlob;
})
.then(res => res.setBlob)
.catch(err => {
if (isArray(err)) {
err.map(e => {
if (e instanceof GraphQLError && e.extensions.code === 413) {
throw new BlobStorageOverCapacity(e);
} else throw e;
});
}
throw err;
});
},
list: async () => {
const result = await fetcher({

View File

@@ -39,7 +39,7 @@ export const Basic = () => {
message: `${key} message`,
timeout: 3000,
progressingBar: true,
undo: async () => {
action: async () => {
console.log('undo');
},
type: 'info',
@@ -218,7 +218,7 @@ export const Basic = () => {
type: 'info',
multimedia: image,
timeout: 3000,
undo: async () => {
action: async () => {
console.log('undo');
},
progressingBar: true,