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

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