mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-20 07:47:19 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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'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'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}>
|
||||
|
||||
@@ -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'](),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './cloud-quota-modal';
|
||||
export * from './local-quota-modal';
|
||||
@@ -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'](),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
packages/frontend/core/src/hooks/use-workspace-quota.ts
Normal file
41
packages/frontend/core/src/hooks/use-workspace-quota.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user