diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index d49802c9e5..5670fd7284 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -22,6 +22,7 @@ import { PermissionService } from '../../core/workspaces/permission'; import { FileUpload, MutexService, + Throttle, TooManyRequestsException, } from '../../fundamentals'; import { ChatSessionService } from './session'; @@ -147,6 +148,7 @@ export class CopilotType { workspaceId!: string | undefined; } +@Throttle() @Resolver(() => CopilotType) export class CopilotResolver { private readonly logger = new Logger(CopilotResolver.name); @@ -309,6 +311,7 @@ export class CopilotResolver { } } +@Throttle() @Resolver(() => UserType) export class UserCopilotResolver { constructor(private readonly permissions: PermissionService) {} diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 68dd218a00..fcdf6881b5 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -571,6 +571,13 @@ export class SubscriptionService { const [plan, recurring] = this.decodePlanFromSubscription(subscription); const planActivated = SubscriptionActivated.includes(subscription.status); + // update features first, features modify are idempotent + // so there is no need to skip if a subscription already exists. + this.event.emit('user.subscription.activated', { + userId: user.id, + plan, + }); + let nextBillAt: Date | null = null; if (planActivated && !subscription.canceled_at) { // get next bill date from upcoming invoice @@ -624,11 +631,6 @@ export class SubscriptionService { data: update, }); } else { - this.event.emit('user.subscription.activated', { - userId: user.id, - plan, - }); - return await this.db.userSubscription.create({ data: { userId: user.id, diff --git a/packages/frontend/core/src/components/affine/setting-modal/atoms.ts b/packages/frontend/core/src/components/affine/setting-modal/atoms.ts new file mode 100644 index 0000000000..a740fac2a2 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const settingModalScrollContainerAtom = atom(null); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx index ab2a22eb7f..b3a8643088 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -190,6 +190,7 @@ const Settings = () => { return ( { @@ -70,12 +78,32 @@ const PricingCollapsible = ({ export interface PlanLayoutProps { cloud?: ReactNode; ai?: ReactNode; + aiTip?: boolean; } -export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { +export const PlanLayout = ({ cloud, ai, aiTip }: PlanLayoutProps) => { const t = useAFFiNEI18N(); const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom); const aiPricingPlanRef = useRef(null); + const aiScrollTipRef = useRef(null); + const settingModalScrollContainer = useAtomValue( + settingModalScrollContainerAtom + ); + + const updateAiTipState = useCallback(() => { + if (!aiTip) return; + const aiContainer = aiPricingPlanRef.current; + if (!settingModalScrollContainer || !aiContainer) return; + + const minVisibleHeight = 30; + + const containerRect = settingModalScrollContainer.getBoundingClientRect(); + const aiTop = aiContainer.getBoundingClientRect().top - containerRect.top; + const aiIntoView = aiTop < containerRect.height - minVisibleHeight; + if (aiIntoView) { + settingModalScrollContainer.dataset.aiVisible = ''; + } + }, [aiTip, settingModalScrollContainer]); // TODO: Need a better solution to handle this situation useLayoutEffect(() => { @@ -88,6 +116,23 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { }); }, [scrollAnchor, setOpenSettingModal]); + useEffect(() => { + if (!settingModalScrollContainer || !aiScrollTipRef.current) return; + + settingModalScrollContainer.addEventListener('scroll', updateAiTipState); + updateAiTipState(); + return () => { + settingModalScrollContainer.removeEventListener( + 'scroll', + updateAiTipState + ); + }; + }, [settingModalScrollContainer, updateAiTipState]); + + const scrollAiIntoView = useCallback(() => { + aiPricingPlanRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + return (
{/* TODO: SettingHeader component shouldn't have margin itself */} @@ -98,12 +143,35 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => { {cloud} {ai ? ( <> - +
{ai}
) : null} + + {aiTip && settingModalScrollContainer + ? createPortal( +
+
+ +
Meet AFFiNE AI
+
+
NEW
+
+
+ +
, + settingModalScrollContainer, + 'aiScrollTip' + ) + : null}
); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx index d2bf8acd98..048ed26f90 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -16,9 +16,16 @@ import { } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { debounce } from 'lodash-es'; -import { Suspense, useCallback, useLayoutEffect, useRef } from 'react'; +import { + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from 'react'; import { AccountSetting } from './account-setting'; +import { settingModalScrollContainerAtom } from './atoms'; import { GeneralSetting } from './general-setting'; import { SettingSidebar } from './setting-sidebar'; import * as style from './style.css'; @@ -56,6 +63,9 @@ const SettingModalInner = ({ const modalContentRef = useRef(null); const modalContentWrapperRef = useRef(null); + const setSettingModalScrollContainer = useSetAtom( + settingModalScrollContainerAtom + ); useLayoutEffect(() => { if (!modalProps.open) return; @@ -66,13 +76,24 @@ const SettingModalInner = ({ if (!modalContentRef.current || !modalContentWrapperRef.current) return; const wrapperWidth = modalContentWrapperRef.current.offsetWidth; + const wrapperHeight = modalContentWrapperRef.current.offsetHeight; const contentWidth = modalContentRef.current.offsetWidth; - modalContentRef.current?.style.setProperty( + const wrapper = modalContentWrapperRef.current; + + wrapper?.style.setProperty( '--setting-modal-width', `${wrapperWidth}px` ); - modalContentRef.current?.style.setProperty( + wrapper?.style.setProperty( + '--setting-modal-height', + `${wrapperHeight}px` + ); + wrapper?.style.setProperty( + '--setting-modal-content-width', + `${contentWidth}px` + ); + wrapper?.style.setProperty( '--setting-modal-gap-x', `${(wrapperWidth - contentWidth) / 2}px` ); @@ -87,6 +108,13 @@ const SettingModalInner = ({ }; }, [modalProps.open]); + useEffect(() => { + setSettingModalScrollContainer(modalContentWrapperRef.current); + return () => { + setSettingModalScrollContainer(null); + }; + }, [setSettingModalScrollContainer]); + const onTabChange = useCallback( (key: ActiveTab, meta: WorkspaceMetadata | null) => { onSettingClick({ activeTab: key, workspaceMetadata: meta }); diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx index 9026c6002d..c9b1ac30da 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx @@ -1,8 +1,5 @@ import { IconButton } from '@affine/component'; -import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; -import { DocService, useService, WorkspaceService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; -import { useEffect } from 'react'; import type { SidebarTab, SidebarTabName } from '../multi-tabs/sidebar-tab'; import * as styles from './header-switcher.css'; @@ -20,20 +17,8 @@ export const MultiTabSidebarHeaderSwitcher = ({ activeTabName, setActiveTabName, }: MultiTabSidebarHeaderSwitcherProps) => { - const workspace = useService(WorkspaceService).workspace; - const doc = useService(DocService).doc; - - const { isJournal } = useJournalInfoHelper(workspace.docCollection, doc.id); - const activeExtension = tabs.find(ext => ext.name === activeTabName); - // if journal is active, set selected to journal - useEffect(() => { - const journalExtension = tabs.find(ext => ext.name === 'journal'); - isJournal && journalExtension && setActiveTabName('journal'); - !isJournal && setActiveTabName('outline'); - }, [tabs, isJournal, setActiveTabName]); - const vars = assignInlineVars({ [styles.activeIdx]: String( tabs.findIndex(ext => ext.name === activeExtension?.name) ?? 0