diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index ff55b110f..70199007f 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -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) { diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index af6f916ef..ab03a8348 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -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; @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; } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 844f24efa..5783aaa54 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -190,9 +190,10 @@ type Query { } type QuotaQueryType { - blobLimit: Int! - storageQuota: Int! - usedSize: Int! + blobLimit: SafeInt! + humanReadableName: String! + storageQuota: SafeInt! + usedSize: SafeInt! } type RemoveAvatar { diff --git a/packages/common/workspace/src/engine/blob.ts b/packages/common/workspace/src/engine/blob.ts index 8f4b5e596..1c5b6541e 100644 --- a/packages/common/workspace/src/engine/blob.ts +++ b/packages/common/workspace/src/engine/blob.ts @@ -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(); + singleBlobSizeLimit: number = 100 * 1024 * 1024; + onAbortLargeBlob = new Slot(); + + 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); }) ) ) diff --git a/packages/common/workspace/src/engine/error.ts b/packages/common/workspace/src/engine/error.ts new file mode 100644 index 000000000..db74dc86f --- /dev/null +++ b/packages/common/workspace/src/engine/error.ts @@ -0,0 +1,5 @@ +export class BlobStorageOverCapacity extends Error { + constructor(public originError?: any) { + super('Blob storage over capacity.'); + } +} diff --git a/packages/common/workspace/src/engine/index.ts b/packages/common/workspace/src/engine/index.ts index 6f2d3f18f..25ff836b6 100644 --- a/packages/common/workspace/src/engine/index.ts +++ b/packages/common/workspace/src/engine/index.ts @@ -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'; diff --git a/packages/frontend/component/src/components/notification-center/index.jotai.ts b/packages/frontend/component/src/components/notification-center/index.jotai.ts index c793112cd..26bc44de4 100644 --- a/packages/frontend/component/src/components/notification-center/index.jotai.ts +++ b/packages/frontend/component/src/components/notification-center/index.jotai.ts @@ -11,7 +11,8 @@ export type Notification = { progressingBar?: boolean; multimedia?: React.ReactNode | JSX.Element; // actions - undo?: () => Promise; + action?: () => Promise; + actionLabel?: string; }; const notificationsBaseAtom = atom([]); @@ -48,19 +49,19 @@ export const pushNotificationAtom = atom( set(notificationsBaseAtom, notifications => notifications.filter(notification => notification.key !== key) ); - const undo: (() => Promise) | undefined = newNotification.undo + const action: (() => Promise) | undefined = newNotification.action ? (() => { - const undo: () => Promise = newNotification.undo; - return async function undoNotificationWrapper() { + const action: () => Promise = newNotification.action; + return async function actionNotificationWrapper() { removeNotification(); - return undo(); + return action(); }; })() : undefined; set(notificationsBaseAtom, notifications => [ // push to the top - { ...newNotification, undo }, + { ...newNotification, action }, ...notifications, ]); } diff --git a/packages/frontend/component/src/components/notification-center/index.tsx b/packages/frontend/component/src/components/notification-center/index.tsx index 73870ad83..712301f43 100644 --- a/packages/frontend/component/src/components/notification-center/index.tsx +++ b/packages/frontend/component/src/components/notification-center/index.tsx @@ -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 {
{notification.title}
- {notification.undo && ( + {notification.action && (
- UNDO + {notification.actionLabel ?? + t['com.affine.keyboardShortcuts.undo']()}
)} {notification.multimedia ? null : ( { - 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 (
- {freePlan + {!isProWorkspace ? t[ 'com.affine.history.confirm-restore-modal.plan-prompt.limited-title' ]() @@ -191,14 +193,15 @@ const PlanPrompt = () => { />
); - }, [closeFreePlanPrompt, freePlan, t]); + }, [closeFreePlanPrompt, isProWorkspace, t]); const planDescription = useMemo(() => { - if (freePlan) { + if (!isProWorkspace) { return ( <> - Free users can view up to the last 7 days of page history. + With the workspace creator's Free account, every member can + access up to 7 days of version history. {isOwner ? ( { } else { return ( - Pro users can view up to the last 30 days of page history. + With the workspace creator's Pro account, every member enjoys the + privilege of accessing up to 30 days of version history. ); } - }, [freePlan, isOwner, onClickUpgrade, t]); + }, [isOwner, isProWorkspace, onClickUpgrade, t]); return !planPromptClosed ? (
diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx new file mode 100644 index 000000000..eb9e85aa1 --- /dev/null +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx @@ -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 ( + + ); +}; diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/index.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/index.tsx new file mode 100644 index 000000000..486c4c572 --- /dev/null +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/index.tsx @@ -0,0 +1,2 @@ +export * from './cloud-quota-modal'; +export * from './local-quota-modal'; diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx new file mode 100644 index 000000000..0ec8bc847 --- /dev/null +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/local-quota-modal.tsx @@ -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 ( + + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index 440f49b74..319dbaa68 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -79,6 +79,7 @@ export function getPlanDetail(t: ReturnType) { 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) { 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' }), ], }, ], diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts index 7c45d3694..ab2eb8b2e 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs.ts @@ -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'); } } diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx index 4f9db6649..289270db4 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx @@ -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(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, diff --git a/packages/frontend/core/src/hooks/use-workspace-quota.ts b/packages/frontend/core/src/hooks/use-workspace-quota.ts new file mode 100644 index 000000000..642022640 --- /dev/null +++ b/packages/frontend/core/src/hooks/use-workspace-quota.ts @@ -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, + }, + }; +}; diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index e9cca6932..2d1e2aa43 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -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() { )} - {currentWorkspace && } + {currentWorkspace ? : null} + {currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && ( + + )} + {currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && ( + + )} ); } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 928d5cd8f..59c0a5ac2 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -905,6 +905,7 @@ export const workspaceQuotaQuery = { query workspaceQuota($id: String!) { workspace(id: $id) { quota { + humanReadableName storageQuota usedSize blobLimit diff --git a/packages/frontend/graphql/src/graphql/workspace-quota.gql b/packages/frontend/graphql/src/graphql/workspace-quota.gql index b82247b70..7b9cc193e 100644 --- a/packages/frontend/graphql/src/graphql/workspace-quota.gql +++ b/packages/frontend/graphql/src/graphql/workspace-quota.gql @@ -1,6 +1,7 @@ query workspaceQuota($id: String!) { workspace(id: $id) { quota { + humanReadableName storageQuota usedSize blobLimit diff --git a/packages/frontend/graphql/src/index.ts b/packages/frontend/graphql/src/index.ts index 7a4a44387..f0238e95e 100644 --- a/packages/frontend/graphql/src/index.ts +++ b/packages/frontend/graphql/src/index.ts @@ -18,3 +18,5 @@ export function getBaseUrl(): string { } export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql'); + +export { GraphQLError } from 'graphql'; diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 48fd0af10..af7a63be6 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -854,6 +854,7 @@ export type WorkspaceQuotaQuery = { __typename?: 'WorkspaceType'; quota: { __typename?: 'QuotaQueryType'; + humanReadableName: string; storageQuota: number; usedSize: number; blobLimit: number; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 4fc3ca9fb..1c3884f49 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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": "You’ve 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", diff --git a/packages/frontend/workspace-impl/src/cloud/blob.ts b/packages/frontend/workspace-impl/src/cloud/blob.ts index 5d6d54ec9..8e15df763 100644 --- a/packages/frontend/workspace-impl/src/cloud/blob.ts +++ b/packages/frontend/workspace-impl/src/cloud/blob.ts @@ -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({ diff --git a/tests/storybook/src/stories/notification-center.stories.tsx b/tests/storybook/src/stories/notification-center.stories.tsx index 2a98e4f9c..8547e9d5f 100644 --- a/tests/storybook/src/stories/notification-center.stories.tsx +++ b/tests/storybook/src/stories/notification-center.stories.tsx @@ -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,