From ddd7cab414a2319a25e22ab6aad13df2aeacb695 Mon Sep 17 00:00:00 2001 From: JimmFly <447268514@qq.com> Date: Wed, 15 Nov 2023 07:49:25 +0000 Subject: [PATCH] feat(core): support share edgeless mode (#4856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #3287 ### 🤖 Generated by Copilot at d3fdf86 ### Summary 📄🚀🔗 This pull request adds a feature to the frontend component of AFFiNE that allows the user to share a page in either `page` or `edgeless` mode, which affects the appearance and functionality of the page. It also adds the necessary GraphQL operations, types, and schema to support this feature in the backend, and updates the tests and the storybook stories accordingly. * Modify the `useIsSharedPage` hook to accept an optional `shareMode` argument and use the `getWorkspacePublicPagesQuery`, `publishPageMutation`, and `revokePublicPageMutation` from `@affine/graphql` --- .../src/modules/workspaces/controller.ts | 22 +- .../disable-public-link/index.tsx | 0 .../disable-public-sharing.tsx | 2 +- .../src/components/share-menu/index.tsx | 3 - .../src/components/share-menu/styles.ts | 91 -------- packages/frontend/core/src/atoms/index.ts | 2 +- .../affine/share-page-modal/index.tsx | 10 +- .../share-page-modal}/share-menu/index.css.ts | 0 .../share-menu/index.jotai.ts | 0 .../share-page-modal/share-menu/index.tsx | 1 + .../share-menu/share-export.tsx | 10 +- .../share-menu/share-menu.tsx | 19 +- .../share-menu/share-page.tsx | 123 ++++++---- .../share-menu/use-share-url.ts | 14 +- .../block-suite-header-title/index.tsx | 20 +- .../block-suite-mode-switch/index.tsx | 46 +++- .../cloud/share-header-left-item/index.tsx | 26 +++ .../share-header-left-item/styles.css.ts | 15 ++ .../share-header-left-item/user-avatar.tsx | 45 ++++ .../authenticated-item.tsx | 30 +++ .../cloud/share-header-right-item/index.tsx | 18 ++ .../share-header-right-item/styles.css.ts | 15 ++ .../src/components/page-detail-editor.tsx | 22 +- .../core/src/components/share-header.tsx | 44 ++++ .../share-page-not-found-error.css.ts | 15 ++ .../src/hooks/affine/use-is-shared-page.ts | 211 ++++++++++++++---- .../core/src/pages/share/detail-page.tsx | 55 +++-- .../graphql/get-workspace-public-pages.gql | 8 + .../graphql/get-workspace-shared-pages.gql | 5 - .../frontend/graphql/src/graphql/index.ts | 54 +++-- .../graphql/src/graphql/public-page.gql | 10 + .../graphql/src/graphql/revoke-page.gql | 3 - .../src/graphql/revoke-public-page.gql | 7 + .../graphql/src/graphql/share-page.gql | 3 - packages/frontend/graphql/src/schema.ts | 68 ++++-- packages/frontend/i18n/src/resources/en.json | 15 ++ .../workspace/src/providers/cloud/index.ts | 22 +- tests/affine-cloud/e2e/collaboration.spec.ts | 47 ++++ .../src/stories/share-menu.stories.tsx | 31 +-- 39 files changed, 800 insertions(+), 332 deletions(-) rename packages/frontend/component/src/components/{share-menu => }/disable-public-link/index.tsx (100%) delete mode 100644 packages/frontend/component/src/components/share-menu/index.tsx delete mode 100644 packages/frontend/component/src/components/share-menu/styles.ts rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/index.css.ts (100%) rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/index.jotai.ts (100%) create mode 100644 packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.tsx rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/share-export.tsx (87%) rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/share-menu.tsx (87%) rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/share-page.tsx (71%) rename packages/frontend/{component/src/components => core/src/components/affine/share-page-modal}/share-menu/use-share-url.ts (67%) create mode 100644 packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx create mode 100644 packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts create mode 100644 packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx create mode 100644 packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx create mode 100644 packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx create mode 100644 packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts create mode 100644 packages/frontend/core/src/components/share-header.tsx create mode 100644 packages/frontend/core/src/components/share-page-not-found-error.css.ts create mode 100644 packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql delete mode 100644 packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql create mode 100644 packages/frontend/graphql/src/graphql/public-page.gql delete mode 100644 packages/frontend/graphql/src/graphql/revoke-page.gql create mode 100644 packages/frontend/graphql/src/graphql/revoke-public-page.gql delete mode 100644 packages/frontend/graphql/src/graphql/share-page.gql diff --git a/packages/backend/server/src/modules/workspaces/controller.ts b/packages/backend/server/src/modules/workspaces/controller.ts index 219a10fdf8..65cee2bc3d 100644 --- a/packages/backend/server/src/modules/workspaces/controller.ts +++ b/packages/backend/server/src/modules/workspaces/controller.ts @@ -12,12 +12,13 @@ import { import type { Response } from 'express'; import format from 'pretty-time'; +import { PrismaService } from '../../prisma'; import { StorageProvide } from '../../storage'; import { DocID } from '../../utils/doc'; import { Auth, CurrentUser, Publicable } from '../auth'; import { DocManager } from '../doc'; import { UserType } from '../users'; -import { PermissionService } from './permission'; +import { PermissionService, PublicPageMode } from './permission'; @Controller('/api/workspaces') export class WorkspacesController { @@ -26,7 +27,8 @@ export class WorkspacesController { constructor( @Inject(StorageProvide) private readonly storage: Storage, private readonly permission: PermissionService, - private readonly docManager: DocManager + private readonly docManager: DocManager, + private readonly prisma: PrismaService ) {} // get workspace blob @@ -82,6 +84,22 @@ export class WorkspacesController { throw new NotFoundException('Doc not found'); } + if (!docId.isWorkspace) { + // fetch the publish page mode for publish page + const publishPage = await this.prisma.workspacePage.findUnique({ + where: { + workspaceId_pageId: { + workspaceId: docId.workspace, + pageId: docId.guid, + }, + }, + }); + const publishPageMode = + publishPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page'; + + res.setHeader('publish-mode', publishPageMode); + } + res.setHeader('content-type', 'application/octet-stream'); res.send(update); this.logger.debug(`workspaces doc api: ${format(process.hrtime(start))}`); diff --git a/packages/frontend/component/src/components/share-menu/disable-public-link/index.tsx b/packages/frontend/component/src/components/disable-public-link/index.tsx similarity index 100% rename from packages/frontend/component/src/components/share-menu/disable-public-link/index.tsx rename to packages/frontend/component/src/components/disable-public-link/index.tsx diff --git a/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx b/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx index e89b37d76d..296c220db8 100644 --- a/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx +++ b/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx @@ -6,7 +6,7 @@ import { type MenuItemProps, } from '@toeverything/components/menu'; -import { PublicLinkDisableModal } from '../../share-menu'; +import { PublicLinkDisableModal } from '../../disable-public-link'; export const DisablePublicSharing = (props: MenuItemProps) => { const t = useAFFiNEI18N(); diff --git a/packages/frontend/component/src/components/share-menu/index.tsx b/packages/frontend/component/src/components/share-menu/index.tsx deleted file mode 100644 index 6dabe4f3d1..0000000000 --- a/packages/frontend/component/src/components/share-menu/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from './disable-public-link'; -export * from './share-menu'; -export * from './styles'; diff --git a/packages/frontend/component/src/components/share-menu/styles.ts b/packages/frontend/component/src/components/share-menu/styles.ts deleted file mode 100644 index 86e2e18c85..0000000000 --- a/packages/frontend/component/src/components/share-menu/styles.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Button } from '@toeverything/components/button'; - -import { displayFlex, styled } from '../..'; - -export const TabItem = styled('li')<{ isActive?: boolean }>(({ isActive }) => { - { - return { - ...displayFlex('center', 'center'), - flex: '1', - height: '30px', - color: 'var(--affine-text-primary-color)', - opacity: isActive ? 1 : 0.2, - fontWeight: '500', - fontSize: 'var(--affine-font-base)', - lineHeight: 'var(--affine-line-height)', - cursor: 'pointer', - transition: 'all 0.15s ease', - padding: '0 10px', - marginBottom: '4px', - borderRadius: '4px', - position: 'relative', - ':hover': { - background: 'var(--affine-hover-color)', - opacity: 1, - color: isActive - ? 'var(--affine-t/ext-primary-color)' - : 'var(--affine-text-secondary-color)', - svg: { - fill: isActive - ? 'var(--affine-text-primary-color)' - : 'var(--affine-text-secondary-color)', - }, - }, - svg: { - fontSize: '20px', - marginRight: '12px', - }, - ':after': { - content: '""', - position: 'absolute', - bottom: '-6px', - left: '0', - width: '100%', - height: '2px', - background: 'var(--affine-text-primary-color)', - opacity: 0.2, - }, - }; - } -}); -export const StyledIndicator = styled('div')(() => { - return { - height: '2px', - background: 'var(--affine-text-primary-color)', - position: 'absolute', - left: '0', - transition: 'left .3s, width .3s', - }; -}); -export const StyledInput = styled('input')(() => { - return { - padding: '4px 8px', - height: '28px', - color: 'var(--affine-placeholder-color)', - border: `1px solid ${'var(--affine-placeholder-color)'}`, - cursor: 'default', - overflow: 'hidden', - userSelect: 'text', - borderRadius: '4px', - flexGrow: 1, - marginRight: '10px', - }; -}); -export const StyledDisableButton = styled(Button)(() => { - return { - color: '#FF631F', - height: '32px', - border: 'none', - marginTop: '16px', - borderRadius: '8px', - padding: '0', - }; -}); -export const StyledLinkSpan = styled('span')(() => { - return { - marginLeft: '4px', - color: 'var(--affine-primary-color)', - fontWeight: '500', - cursor: 'pointer', - }; -}); diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 82e911331b..c275bf321a 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -40,7 +40,7 @@ export const authAtom = atom({ export const openDisableCloudAlertModalAtom = atom(false); -type PageMode = 'page' | 'edgeless'; +export type PageMode = 'page' | 'edgeless'; type PageLocalSetting = { mode: PageMode; }; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx index 80b0b9973f..1e4395ce46 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx @@ -1,4 +1,3 @@ -import { ShareMenu } from '@affine/component/share-menu'; import { type AffineOfficialWorkspace, WorkspaceFlavour, @@ -6,10 +5,9 @@ import { import type { Page } from '@blocksuite/store'; import { useCallback, useState } from 'react'; -import { useExportPage } from '../../../hooks/affine/use-export-page'; -import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page'; import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace'; import { EnableAffineCloudModal } from '../enable-affine-cloud-modal'; +import { ShareMenu } from './share-menu'; type SharePageModalProps = { workspace: AffineOfficialWorkspace; @@ -19,7 +17,7 @@ type SharePageModalProps = { export const SharePageModal = ({ workspace, page }: SharePageModalProps) => { const onTransformWorkspace = useOnTransformWorkspace(); const [open, setOpen] = useState(false); - const exportHandler = useExportPage(page); + const handleConfirm = useCallback(() => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { return; @@ -31,15 +29,13 @@ export const SharePageModal = ({ workspace, page }: SharePageModalProps) => { ); setOpen(false); }, [onTransformWorkspace, workspace]); + return ( <> setOpen(true)} - togglePagePublic={async () => {}} - exportHandler={exportHandler} /> {workspace.flavour === WorkspaceFlavour.LOCAL ? ( { +export const ShareExport = ({ workspace, currentPage }: ShareMenuProps) => { const t = useAFFiNEI18N(); const workspaceId = workspace.id; const pageId = currentPage.id; @@ -22,6 +19,7 @@ export const ShareExport = ({ pageId, urlType: 'workspace', }); + const exportHandler = useExportPage(currentPage); return ( <> diff --git a/packages/frontend/component/src/components/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx similarity index 87% rename from packages/frontend/component/src/components/share-menu/share-menu.tsx rename to packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx index a40c42adfa..9b364cc04a 100644 --- a/packages/frontend/component/src/components/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx @@ -12,9 +12,11 @@ import { Button } from '@toeverything/components/button'; import { Divider } from '@toeverything/components/divider'; import { Menu } from '@toeverything/components/menu'; +import { useIsSharedPage } from '../../../../hooks/affine/use-is-shared-page'; import * as styles from './index.css'; import { ShareExport } from './share-export'; import { SharePage } from './share-page'; + export interface ShareMenuProps< Workspace extends AffineOfficialWorkspace = | AffineCloudWorkspace @@ -23,13 +25,7 @@ export interface ShareMenuProps< > { workspace: Workspace; currentPage: Page; - useIsSharedPage: ( - workspaceId: string, - pageId: string - ) => [isSharePage: boolean, setIsSharePage: (enable: boolean) => void]; onEnableAffineCloud: () => void; - togglePagePublic: () => Promise; - exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise; } const ShareMenuContent = (props: ShareMenuProps) => { @@ -73,9 +69,14 @@ const LocalShareMenu = (props: ShareMenuProps) => { const CloudShareMenu = (props: ShareMenuProps) => { const t = useAFFiNEI18N(); - - const { workspace, currentPage, useIsSharedPage } = props; - const [isSharedPage] = useIsSharedPage(workspace.id, currentPage.id); + const { + workspace: { id: workspaceId }, + currentPage, + } = props; + const { isSharedPage } = useIsSharedPage( + workspaceId, + currentPage.spaceDoc.guid + ); return ( { export const AffineSharePage = (props: ShareMenuProps) => { const { workspace: { id: workspaceId }, - currentPage: { id: pageId }, + currentPage, } = props; - const [isPublic, setIsPublic] = props.useIsSharedPage(workspaceId, pageId); + const pageId = currentPage.id; const [showDisable, setShowDisable] = useState(false); + const { + isSharedPage, + enableShare, + changeShare, + currentShareMode, + disableShare, + } = useIsSharedPage(workspaceId, currentPage.spaceDoc.guid); + const currentPageMode = useAtomValue(currentModeAtom); + + const defaultMode = useMemo(() => { + if (isSharedPage) { + // if it's a shared page, use the share mode + return currentShareMode; + } + // default to current page mode + return currentPageMode; + }, [currentPageMode, currentShareMode, isSharedPage]); + const [mode, setMode] = useState(defaultMode); + const { sharingUrl, onClickCopyLink } = useSharingUrl({ workspaceId, pageId, @@ -75,16 +101,26 @@ export const AffineSharePage = (props: ShareMenuProps) => { const t = useAFFiNEI18N(); const onClickCreateLink = useCallback(() => { - setIsPublic(true); - }, [setIsPublic]); + enableShare(mode); + }, [enableShare, mode]); const onDisablePublic = useCallback(() => { - setIsPublic(false); + disableShare(); toast('Successfully disabled', { portal: document.body, }); setShowDisable(false); - }, [setIsPublic]); + }, [disableShare]); + + const onShareModeChange = useCallback( + (value: PageMode) => { + setMode(value); + if (isSharedPage) { + changeShare(value); + } + }, + [changeShare, isSharedPage] + ); return ( <> @@ -103,10 +139,12 @@ export const AffineSharePage = (props: ShareMenuProps) => { fontSize: 'var(--affine-font-xs)', lineHeight: '20px', }} - value={isPublic ? sharingUrl : `${runtimeConfig.serverUrlPrefix}/...`} + value={ + isSharedPage ? sharingUrl : `${runtimeConfig.serverUrlPrefix}/...` + } readOnly /> - {isPublic ? ( + {isSharedPage ? ( )} - {runtimeConfig.enableEnhanceShareMode ? ( -
-
- {t['com.affine.share-menu.ShareMode']()} -
-
- {}} - > - - {t['com.affine.pageMode.page']()} - - - {t['com.affine.pageMode.edgeless']()} - - -
+
+
+ {t['com.affine.share-menu.ShareMode']()}
- ) : null} - {isPublic ? ( +
+ + + {t['com.affine.pageMode.page']()} + + + {t['com.affine.pageMode.edgeless']()} + + +
+
+ {isSharedPage ? ( <> {runtimeConfig.enableEnhanceShareMode && ( <> diff --git a/packages/frontend/component/src/components/share-menu/use-share-url.ts b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts similarity index 67% rename from packages/frontend/component/src/components/share-menu/use-share-url.ts rename to packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts index a1eaea3d66..d349235f6f 100644 --- a/packages/frontend/component/src/components/share-menu/use-share-url.ts +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts @@ -1,8 +1,7 @@ +import { toast } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useCallback, useMemo } from 'react'; -import { toast } from '../../ui/toast'; - type UrlType = 'share' | 'workspace'; type UseSharingUrl = { @@ -16,7 +15,14 @@ export const generateUrl = ({ pageId, urlType, }: UseSharingUrl) => { - return `${runtimeConfig.serverUrlPrefix}/${urlType}/${workspaceId}/${pageId}`; + // to generate a private url like https://affine.app/workspace/123/456 + // to generate a public url like https://affine.app/share/123/456 + // or https://affine.app/share/123/456?mode=edgeless + + const url = new URL( + `${runtimeConfig.serverUrlPrefix}/${urlType}/${workspaceId}/${pageId}` + ); + return url.toString(); }; export const useSharingUrl = ({ @@ -27,7 +33,7 @@ export const useSharingUrl = ({ const t = useAFFiNEI18N(); const sharingUrl = useMemo( () => generateUrl({ workspaceId, pageId, urlType }), - [urlType, workspaceId, pageId] + [workspaceId, pageId, urlType] ); const onClickCopyLink = useCallback(() => { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx index 68d02cb88b..cdbbd3bc8b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx @@ -1,5 +1,4 @@ import type { AffineOfficialWorkspace } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useBlockSuitePageMeta, usePageMetaHelper, @@ -13,6 +12,7 @@ import { useState, } from 'react'; +import type { PageMode } from '../../../atoms'; import { EditorModeSwitch } from '../block-suite-mode-switch'; import { PageMenu } from './operation-menu'; import * as styles from './styles.css'; @@ -20,6 +20,8 @@ import * as styles from './styles.css'; export interface BlockSuiteHeaderTitleProps { workspace: AffineOfficialWorkspace; pageId: string; + isPublic?: boolean; + publicMode?: PageMode; } const EditableTitle = ({ @@ -54,6 +56,8 @@ const StableTitle = ({ workspace, pageId, onRename, + isPublic, + publicMode, }: BlockSuiteHeaderTitleProps & { onRename?: () => void; }) => { @@ -64,11 +68,19 @@ const StableTitle = ({ const title = pageMeta?.title; + const handleRename = useCallback(() => { + if (!isPublic && onRename) { + onRename(); + } + }, [isPublic, onRename]); + return (
{title || 'Untitled'} - + {isPublic ? null : }
); }; @@ -139,7 +151,7 @@ const BlockSuiteTitleWithRename = (props: BlockSuiteHeaderTitleProps) => { }; export const BlockSuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => { - if (props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC) { + if (props.isPublic) { return ; } return ; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx index 7cc1f7a348..477fa4be2c 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx @@ -6,6 +6,7 @@ import { useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; import { useCallback, useEffect } from 'react'; +import type { PageMode } from '../../../atoms'; import { currentModeAtom } from '../../../atoms/mode'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import type { BlockSuiteWorkspace } from '../../../shared'; @@ -18,6 +19,8 @@ export type EditorModeSwitchProps = { blockSuiteWorkspace: BlockSuiteWorkspace; pageId: string; style?: CSSProperties; + isPublic?: boolean; + publicMode?: PageMode; }; const TooltipContent = () => { const t = useAFFiNEI18N(); @@ -34,6 +37,8 @@ export const EditorModeSwitch = ({ style, blockSuiteWorkspace, pageId, + isPublic, + publicMode, }: EditorModeSwitchProps) => { const t = useAFFiNEI18N(); const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( @@ -47,7 +52,7 @@ export const EditorModeSwitch = ({ const currentMode = useAtomValue(currentModeAtom); useEffect(() => { - if (trash) { + if (trash || isPublic) { return; } const keydown = (e: KeyboardEvent) => { @@ -64,41 +69,58 @@ export const EditorModeSwitch = ({ document.addEventListener('keydown', keydown, { capture: true }); return () => document.removeEventListener('keydown', keydown, { capture: true }); - }, [currentMode, pageId, t, togglePageMode, trash]); + }, [currentMode, isPublic, pageId, t, togglePageMode, trash]); const onSwitchToPageMode = useCallback(() => { - if (currentMode === 'page') { + if (currentMode === 'page' || isPublic) { return; } switchToPageMode(pageId); toast(t['com.affine.toastMessage.pageMode']()); - }, [currentMode, pageId, switchToPageMode, t]); + }, [currentMode, isPublic, pageId, switchToPageMode, t]); + const onSwitchToEdgelessMode = useCallback(() => { - if (currentMode === 'edgeless') { + if (currentMode === 'edgeless' || isPublic) { return; } switchToEdgelessMode(pageId); toast(t['com.affine.toastMessage.edgelessMode']()); - }, [currentMode, pageId, switchToEdgelessMode, t]); + }, [currentMode, isPublic, pageId, switchToEdgelessMode, t]); + + const shouldHide = useCallback( + (mode: PageMode) => + (trash && currentMode !== mode) || (isPublic && publicMode !== mode), + [currentMode, isPublic, publicMode, trash] + ); + + const shouldActive = useCallback( + (mode: PageMode) => (isPublic ? false : currentMode === mode), + [currentMode, isPublic] + ); return ( - }> + } + options={{ + hidden: isPublic || trash, + }} + > diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx b/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx new file mode 100644 index 0000000000..7a674c2ff1 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx @@ -0,0 +1,26 @@ +import { Logo1Icon } from '@blocksuite/icons'; + +import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; +import * as styles from './styles.css'; +import { PublishPageUserAvatar } from './user-avatar'; + +const ShareHeaderLeftItem = () => { + const loginStatus = useCurrentLoginStatus(); + if (loginStatus === 'authenticated') { + return ; + } + + return ( + + + + ); +}; + +export default ShareHeaderLeftItem; diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts b/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts new file mode 100644 index 0000000000..3000c5d257 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx b/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx new file mode 100644 index 0000000000..43558bdd03 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx @@ -0,0 +1,45 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { SignOutIcon } from '@blocksuite/icons'; +import { Avatar } from '@toeverything/components/avatar'; +import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { signOutCloud } from '../../../utils/cloud-utils'; +import * as styles from './styles.css'; + +export const PublishPageUserAvatar = () => { + const user = useCurrentUser(); + const t = useAFFiNEI18N(); + const location = useLocation(); + + const handleSignOut = useAsyncCallback(async () => { + await signOutCloud({ callbackUrl: location.pathname }); + }, [location.pathname]); + + const menuItem = useMemo(() => { + return ( + + + + } + data-testid="share-page-sign-out-option" + onClick={handleSignOut} + > + {t['com.affine.workspace.cloud.account.logout']()} + + ); + }, [handleSignOut, t]); + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx new file mode 100644 index 0000000000..f927723365 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx @@ -0,0 +1,30 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { Button } from '@toeverything/components/button'; + +import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { useMembers } from '../../../hooks/affine/use-members'; +import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; +import type { ShareHeaderRightItemProps } from '.'; + +export const AuthenticatedItem = ({ ...props }: ShareHeaderRightItemProps) => { + const { workspaceId, pageId } = props; + const user = useCurrentUser(); + const members = useMembers(workspaceId, 0); + const isMember = members.some(m => m.id === user.id); + const t = useAFFiNEI18N(); + const { jumpToPage } = useNavigateHelper(); + + if (isMember) { + return ( + + ); + } + + return null; +}; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx new file mode 100644 index 0000000000..564b657917 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx @@ -0,0 +1,18 @@ +import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; +import { AuthenticatedItem } from './authenticated-item'; + +export type ShareHeaderRightItemProps = { + workspaceId: string; + pageId: string; +}; + +const ShareHeaderRightItem = ({ ...props }: ShareHeaderRightItemProps) => { + const loginStatus = useCurrentLoginStatus(); + if (loginStatus === 'authenticated') { + return ; + } + // TODO: Add TOC + return null; +}; + +export default ShareHeaderRightItem; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts b/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts new file mode 100644 index 0000000000..3000c5d257 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 084843a2bc..5cb4c47eac 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -31,7 +31,7 @@ import { import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { useLocation } from 'react-router-dom'; -import { pageSettingFamily } from '../atoms'; +import { type PageMode, pageSettingFamily } from '../atoms'; import { fontStyleOptions } from '../atoms/settings'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper'; @@ -50,6 +50,7 @@ export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void; export interface PageDetailEditorProps { isPublic?: boolean; + publishMode?: PageMode; workspace: Workspace; pageId: string; onLoad?: OnLoadEditor; @@ -91,6 +92,7 @@ const EditorWrapper = memo(function EditorWrapper({ pageId, onLoad, isPublic, + publishMode, }: PageDetailEditorProps) { const page = useBlockSuiteWorkspacePage(workspace, pageId); if (!page) { @@ -105,7 +107,16 @@ const EditorWrapper = memo(function EditorWrapper({ const pageSettingAtom = pageSettingFamily(pageId); const pageSetting = useAtomValue(pageSettingAtom); - const currentMode = pageSetting?.mode ?? 'page'; + + const mode = useMemo(() => { + const currentMode = pageSetting.mode; + const shareMode = publishMode || currentMode; + + if (isPublic) { + return shareMode; + } + return currentMode; + }, [isPublic, publishMode, pageSetting.mode]); const { appSettings } = useAppSettingHelper(); @@ -120,13 +131,16 @@ const EditorWrapper = memo(function EditorWrapper({ const setEditorMode = useCallback( (mode: 'page' | 'edgeless') => { + if (isPublic) { + return; + } if (mode === 'edgeless') { switchToEdgelessMode(pageId); } else { switchToPageMode(pageId); } }, - [switchToEdgelessMode, switchToPageMode, pageId] + [isPublic, switchToEdgelessMode, pageId, switchToPageMode] ); const [editor, setEditor] = useState(); @@ -191,7 +205,7 @@ const EditorWrapper = memo(function EditorWrapper({ '--affine-font-family': value, } as CSSProperties } - mode={isPublic ? 'page' : currentMode} + mode={mode} page={page} onModeChange={setEditorMode} defaultSelectedBlockId={blockId} diff --git a/packages/frontend/core/src/components/share-header.tsx b/packages/frontend/core/src/components/share-header.tsx new file mode 100644 index 0000000000..20f6c6ab64 --- /dev/null +++ b/packages/frontend/core/src/components/share-header.tsx @@ -0,0 +1,44 @@ +import type { Workspace } from '@blocksuite/store'; +import { useSetAtom } from 'jotai/react'; + +import type { PageMode } from '../atoms'; +import { appHeaderAtom, mainContainerAtom } from '../atoms/element'; +import { useWorkspace } from '../hooks/use-workspace'; +import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title'; +import ShareHeaderLeftItem from './cloud/share-header-left-item'; +import ShareHeaderRightItem from './cloud/share-header-right-item'; +import { Header } from './pure/header'; + +export function ShareHeader({ + workspace, + pageId, + publishMode, +}: { + workspace: Workspace; + pageId: string; + publishMode: PageMode; +}) { + const setAppHeader = useSetAtom(appHeaderAtom); + + const currentWorkspace = useWorkspace(workspace.id); + + return ( +
} + center={ + + } + right={ + + } + bottomBorder + /> + ); +} diff --git a/packages/frontend/core/src/components/share-page-not-found-error.css.ts b/packages/frontend/core/src/components/share-page-not-found-error.css.ts new file mode 100644 index 0000000000..bfd01d03f4 --- /dev/null +++ b/packages/frontend/core/src/components/share-page-not-found-error.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + position: 'absolute', + top: '16px', + left: '16px', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts b/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts index 5f8cba129c..49f6cc110b 100644 --- a/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts +++ b/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts @@ -1,57 +1,190 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; import { - getWorkspaceSharedPagesQuery, - revokePageMutation, - sharePageMutation, + getWorkspacePublicPagesQuery, + PublicPageMode, + publishPageMutation, + revokePublicPageMutation, } from '@affine/graphql'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useMutation, useQuery } from '@affine/workspace/affine/gql'; +import { useSetAtom } from 'jotai'; import { useCallback, useMemo } from 'react'; +import type { PageMode } from '../../atoms'; + +type NoParametersKeys = { + [K in keyof T]: T[K] extends () => any ? K : never; +}[keyof T]; + +type i18nKey = NoParametersKeys>; + +type NotificationKey = + | 'enableSuccessTitle' + | 'enableSuccessMessage' + | 'enableErrorTitle' + | 'enableErrorMessage' + | 'changeSuccessTitle' + | 'changeErrorTitle' + | 'changeErrorMessage' + | 'disableSuccessTitle' + | 'disableSuccessMessage' + | 'disableErrorTitle' + | 'disableErrorMessage'; + +const notificationToI18nKey: Record = { + enableSuccessTitle: + 'com.affine.share-menu.create-public-link.notification.success.title', + enableSuccessMessage: + 'com.affine.share-menu.create-public-link.notification.success.message', + enableErrorTitle: + 'com.affine.share-menu.create-public-link.notification.fail.title', + enableErrorMessage: + 'com.affine.share-menu.create-public-link.notification.fail.message', + changeSuccessTitle: + 'com.affine.share-menu.confirm-modify-mode.notification.success.title', + changeErrorTitle: + 'com.affine.share-menu.confirm-modify-mode.notification.fail.title', + changeErrorMessage: + 'com.affine.share-menu.confirm-modify-mode.notification.fail.message', + disableSuccessTitle: + 'com.affine.share-menu.disable-publish-link.notification.success.title', + disableSuccessMessage: + 'com.affine.share-menu.disable-publish-link.notification.success.message', + disableErrorTitle: + 'com.affine.share-menu.disable-publish-link.notification.fail.title', + disableErrorMessage: + 'com.affine.share-menu.disable-publish-link.notification.fail.message', +}; + export function useIsSharedPage( workspaceId: string, pageId: string -): [isSharedPage: boolean, setSharedPage: (enable: boolean) => void] { +): { + isSharedPage: boolean; + changeShare: (mode: PageMode) => void; + disableShare: () => void; + currentShareMode: PageMode; + enableShare: (mode: PageMode) => void; +} { + const t = useAFFiNEI18N(); + const pushNotification = useSetAtom(pushNotificationAtom); const { data, mutate } = useQuery({ - query: getWorkspaceSharedPagesQuery, + query: getWorkspacePublicPagesQuery, variables: { workspaceId, }, }); + const { trigger: enableSharePage } = useMutation({ - mutation: sharePageMutation, + mutation: publishPageMutation, }); const { trigger: disableSharePage } = useMutation({ - mutation: revokePageMutation, + mutation: revokePublicPageMutation, }); - return [ - useMemo( - () => data.workspace.sharedPages.some(id => id === pageId), - [data.workspace.sharedPages, pageId] - ), - useCallback( - (enable: boolean) => { - // todo: push notification - if (enable) { - enableSharePage({ - workspaceId, - pageId, - }) - .then(() => { - return mutate(); - }) - .catch(console.error); - } else { - disableSharePage({ - workspaceId, - pageId, - }) - .then(() => { - return mutate(); - }) - .catch(console.error); - } - mutate().catch(console.error); - }, - [disableSharePage, enableSharePage, mutate, pageId, workspaceId] - ), - ]; + + const [isSharedPage, currentShareMode] = useMemo(() => { + const publicPage = data?.workspace.publicPages.find( + publicPage => publicPage.id === pageId + ); + const isPageShared = !!publicPage; + + const currentShareMode: PageMode = + publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page'; + + return [isPageShared, currentShareMode]; + }, [data?.workspace.publicPages, pageId]); + + const enableShare = useCallback( + (mode: PageMode) => { + const publishMode = + mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; + + enableSharePage({ workspaceId, pageId, mode: publishMode }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['enableSuccessTitle']](), + message: t[notificationToI18nKey['enableSuccessMessage']](), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['enableErrorTitle']](), + message: t[notificationToI18nKey['enableErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, + [enableSharePage, mutate, pageId, pushNotification, t, workspaceId] + ); + + const changeShare = useCallback( + (mode: PageMode) => { + const publishMode = + mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; + + enableSharePage({ workspaceId, pageId, mode: publishMode }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['changeSuccessTitle']](), + message: t[ + 'com.affine.share-menu.confirm-modify-mode.notification.success.message' + ]({ + preMode: + publishMode === PublicPageMode.Edgeless + ? PublicPageMode.Page + : PublicPageMode.Edgeless, + currentMode: publishMode, + }), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['changeErrorTitle']](), + message: t[notificationToI18nKey['changeErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, + [enableSharePage, mutate, pageId, pushNotification, t, workspaceId] + ); + + const disableShare = useCallback(() => { + disableSharePage({ workspaceId, pageId }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['disableSuccessTitle']](), + message: t[notificationToI18nKey['disableSuccessMessage']](), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['disableErrorTitle']](), + message: t[notificationToI18nKey['disableErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, [disableSharePage, mutate, pageId, pushNotification, t, workspaceId]); + + return useMemo( + () => ({ + isSharedPage, + currentShareMode, + enableShare, + disableShare, + changeShare, + }), + [isSharedPage, currentShareMode, enableShare, disableShare, changeShare] + ); } diff --git a/packages/frontend/core/src/pages/share/detail-page.tsx b/packages/frontend/core/src/pages/share/detail-page.tsx index ab9bb26a58..07ce82d3a8 100644 --- a/packages/frontend/core/src/pages/share/detail-page.tsx +++ b/packages/frontend/core/src/pages/share/detail-page.tsx @@ -3,6 +3,7 @@ import { DebugLogger } from '@affine/debug'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { getOrCreateWorkspace } from '@affine/workspace/manager'; import { downloadBinaryFromCloud } from '@affine/workspace/providers'; +import type { CloudDoc } from '@affine/workspace/providers/cloud'; import { assertExists } from '@blocksuite/global/utils'; import type { Page } from '@blocksuite/store'; import { noop } from 'foxact/noop'; @@ -18,12 +19,25 @@ import { import { applyUpdate } from 'yjs'; import { PageDetailEditor } from '../../adapters/shared'; +import type { PageMode } from '../../atoms'; import { AppContainer } from '../../components/affine/app-container'; +import { ShareHeader } from '../../components/share-header'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; -function assertArrayBuffer(value: unknown): asserts value is ArrayBuffer { - if (!(value instanceof ArrayBuffer)) { - throw new Error('value is not ArrayBuffer'); +type LoaderData = { + page: Page; + publishMode: PageMode; +}; + +function assertDownloadResponse( + value: CloudDoc | boolean +): asserts value is CloudDoc { + if ( + !value || + !((value as CloudDoc).arrayBuffer instanceof ArrayBuffer) || + typeof (value as CloudDoc).publishMode !== 'string' + ) { + throw new Error('value is not a valid download response'); } } @@ -41,33 +55,42 @@ export const loader: LoaderFunction = async ({ params }) => { ); // download root workspace { - const buffer = await downloadBinaryFromCloud(workspaceId, workspaceId); - assertArrayBuffer(buffer); - applyUpdate(workspace.doc, new Uint8Array(buffer)); + const response = await downloadBinaryFromCloud(workspaceId, workspaceId); + assertDownloadResponse(response); + const { arrayBuffer } = response; + applyUpdate(workspace.doc, new Uint8Array(arrayBuffer)); } const page = workspace.getPage(pageId); assertExists(page, 'cannot find page'); // download page - { - const buffer = await downloadBinaryFromCloud( - workspaceId, - page.spaceDoc.guid - ); - assertArrayBuffer(buffer); - applyUpdate(page.spaceDoc, new Uint8Array(buffer)); - } + + const response = await downloadBinaryFromCloud( + workspaceId, + page.spaceDoc.guid + ); + assertDownloadResponse(response); + const { arrayBuffer, publishMode } = response; + + applyUpdate(page.spaceDoc, new Uint8Array(arrayBuffer)); + logger.info('workspace', workspace); workspace.awarenessStore.setReadonly(page, true); - return page; + return { page, publishMode }; }; export const Component = (): ReactElement => { - const page = useLoaderData() as Page; + const { page, publishMode } = useLoaderData() as LoaderData; return ( + noop, [])} diff --git a/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql b/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql new file mode 100644 index 0000000000..fcfd7fc6e7 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql @@ -0,0 +1,8 @@ +query getWorkspacePublicPages($workspaceId: String!) { + workspace(id: $workspaceId) { + publicPages { + id + mode + } + } +} diff --git a/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql b/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql deleted file mode 100644 index 3d94e0a739..0000000000 --- a/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql +++ /dev/null @@ -1,5 +0,0 @@ -query getWorkspaceSharedPages($workspaceId: String!) { - workspace(id: $workspaceId) { - sharedPages - } -} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 5de29e1419..4fd2c04148 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -320,15 +320,18 @@ query getWorkspacePublicById($id: String!) { }`, }; -export const getWorkspaceSharedPagesQuery = { - id: 'getWorkspaceSharedPagesQuery' as const, - operationName: 'getWorkspaceSharedPages', +export const getWorkspacePublicPagesQuery = { + id: 'getWorkspacePublicPagesQuery' as const, + operationName: 'getWorkspacePublicPages', definitionName: 'workspace', containsFile: false, query: ` -query getWorkspaceSharedPages($workspaceId: String!) { +query getWorkspacePublicPages($workspaceId: String!) { workspace(id: $workspaceId) { - sharedPages + publicPages { + id + mode + } } }`, }; @@ -428,6 +431,20 @@ query prices { }`, }; +export const publishPageMutation = { + id: 'publishPageMutation' as const, + operationName: 'publishPage', + definitionName: 'publishPage', + containsFile: false, + query: ` +mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageMode = Page) { + publishPage(workspaceId: $workspaceId, pageId: $pageId, mode: $mode) { + id + mode + } +}`, +}; + export const removeAvatarMutation = { id: 'removeAvatarMutation' as const, operationName: 'removeAvatar', @@ -469,14 +486,18 @@ mutation revokeMemberPermission($workspaceId: String!, $userId: String!) { }`, }; -export const revokePageMutation = { - id: 'revokePageMutation' as const, - operationName: 'revokePage', - definitionName: 'revokePage', +export const revokePublicPageMutation = { + id: 'revokePublicPageMutation' as const, + operationName: 'revokePublicPage', + definitionName: 'revokePublicPage', containsFile: false, query: ` -mutation revokePage($workspaceId: String!, $pageId: String!) { - revokePage(workspaceId: $workspaceId, pageId: $pageId) +mutation revokePublicPage($workspaceId: String!, $pageId: String!) { + revokePublicPage(workspaceId: $workspaceId, pageId: $pageId) { + id + mode + public + } }`, }; @@ -537,17 +558,6 @@ mutation setWorkspacePublicById($id: ID!, $public: Boolean!) { }`, }; -export const sharePageMutation = { - id: 'sharePageMutation' as const, - operationName: 'sharePage', - definitionName: 'sharePage', - containsFile: false, - query: ` -mutation sharePage($workspaceId: String!, $pageId: String!) { - sharePage(workspaceId: $workspaceId, pageId: $pageId) -}`, -}; - export const signInMutation = { id: 'signInMutation' as const, operationName: 'signIn', diff --git a/packages/frontend/graphql/src/graphql/public-page.gql b/packages/frontend/graphql/src/graphql/public-page.gql new file mode 100644 index 0000000000..7074bc153a --- /dev/null +++ b/packages/frontend/graphql/src/graphql/public-page.gql @@ -0,0 +1,10 @@ +mutation publishPage( + $workspaceId: String! + $pageId: String! + $mode: PublicPageMode = Page +) { + publishPage(workspaceId: $workspaceId, pageId: $pageId, mode: $mode) { + id + mode + } +} diff --git a/packages/frontend/graphql/src/graphql/revoke-page.gql b/packages/frontend/graphql/src/graphql/revoke-page.gql deleted file mode 100644 index df8de2aba0..0000000000 --- a/packages/frontend/graphql/src/graphql/revoke-page.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation revokePage($workspaceId: String!, $pageId: String!) { - revokePage(workspaceId: $workspaceId, pageId: $pageId) -} diff --git a/packages/frontend/graphql/src/graphql/revoke-public-page.gql b/packages/frontend/graphql/src/graphql/revoke-public-page.gql new file mode 100644 index 0000000000..1515b3d501 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/revoke-public-page.gql @@ -0,0 +1,7 @@ +mutation revokePublicPage($workspaceId: String!, $pageId: String!) { + revokePublicPage(workspaceId: $workspaceId, pageId: $pageId) { + id + mode + public + } +} diff --git a/packages/frontend/graphql/src/graphql/share-page.gql b/packages/frontend/graphql/src/graphql/share-page.gql deleted file mode 100644 index 26a4259412..0000000000 --- a/packages/frontend/graphql/src/graphql/share-page.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation sharePage($workspaceId: String!, $pageId: String!) { - sharePage(workspaceId: $workspaceId, pageId: $pageId) -} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index f026ab0f8d..99b6282ca3 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -340,13 +340,20 @@ export type GetWorkspacePublicByIdQuery = { workspace: { __typename?: 'WorkspaceType'; public: boolean }; }; -export type GetWorkspaceSharedPagesQueryVariables = Exact<{ +export type GetWorkspacePublicPagesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; -export type GetWorkspaceSharedPagesQuery = { +export type GetWorkspacePublicPagesQuery = { __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; sharedPages: Array }; + workspace: { + __typename?: 'WorkspaceType'; + publicPages: Array<{ + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + }>; + }; }; export type GetWorkspaceQueryVariables = Exact<{ @@ -422,6 +429,21 @@ export type PricesQuery = { }>; }; +export type PublishPageMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + pageId: Scalars['String']['input']; + mode?: InputMaybe; +}>; + +export type PublishPageMutation = { + __typename?: 'Mutation'; + publishPage: { + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + }; +}; + export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutation = { @@ -455,14 +477,19 @@ export type RevokeMemberPermissionMutation = { revoke: boolean; }; -export type RevokePageMutationVariables = Exact<{ +export type RevokePublicPageMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; pageId: Scalars['String']['input']; }>; -export type RevokePageMutation = { +export type RevokePublicPageMutation = { __typename?: 'Mutation'; - revokePage: boolean; + revokePublicPage: { + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + public: boolean; + }; }; export type SendChangeEmailMutationVariables = Exact<{ @@ -516,13 +543,6 @@ export type SetWorkspacePublicByIdMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type SharePageMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - pageId: Scalars['String']['input']; -}>; - -export type SharePageMutation = { __typename?: 'Mutation'; sharePage: boolean }; - export type SignInMutationVariables = Exact<{ email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -683,9 +703,9 @@ export type Queries = response: GetWorkspacePublicByIdQuery; } | { - name: 'getWorkspaceSharedPagesQuery'; - variables: GetWorkspaceSharedPagesQueryVariables; - response: GetWorkspaceSharedPagesQuery; + name: 'getWorkspacePublicPagesQuery'; + variables: GetWorkspacePublicPagesQueryVariables; + response: GetWorkspacePublicPagesQuery; } | { name: 'getWorkspaceQuery'; @@ -774,6 +794,11 @@ export type Mutations = variables: LeaveWorkspaceMutationVariables; response: LeaveWorkspaceMutation; } + | { + name: 'publishPageMutation'; + variables: PublishPageMutationVariables; + response: PublishPageMutation; + } | { name: 'removeAvatarMutation'; variables: RemoveAvatarMutationVariables; @@ -790,9 +815,9 @@ export type Mutations = response: RevokeMemberPermissionMutation; } | { - name: 'revokePageMutation'; - variables: RevokePageMutationVariables; - response: RevokePageMutation; + name: 'revokePublicPageMutation'; + variables: RevokePublicPageMutationVariables; + response: RevokePublicPageMutation; } | { name: 'sendChangeEmailMutation'; @@ -819,11 +844,6 @@ export type Mutations = variables: SetWorkspacePublicByIdMutationVariables; response: SetWorkspacePublicByIdMutation; } - | { - name: 'sharePageMutation'; - variables: SharePageMutationVariables; - response: SharePageMutation; - } | { name: 'signInMutation'; variables: SignInMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index bc73c4fdf4..0d2e5f1cb3 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -334,6 +334,21 @@ "com.affine.share-menu.ShareViaExportDescription": "Download a static copy of your page to share with others.", "com.affine.share-menu.ShareWithLink": "Share with link", "com.affine.share-menu.ShareWithLinkDescription": "Create a link you can easily share with anyone. The visitors will open your page in the form od a document", + "com.affine.share-menu.confirm-modify-mode.title": "Modify the sharing method?", + "com.affine.share-menu.confirm-modify-mode.description": "Once modified, new public link will be created. Please share it with others again.", + "com.affine.share-menu.confirm-modify-mode.confirm-button": "Modify", + "com.affine.share-menu.confirm-modify-mode.notification.success.title": "Modified successfully", + "com.affine.share-menu.confirm-modify-mode.notification.success.message": "You have changed the public link from {{preMode}} Mode to {{currentMode}} Mode.", + "com.affine.share-menu.confirm-modify-mode.notification.fail.title": "Failed to modify", + "com.affine.share-menu.confirm-modify-mode.notification.fail.message": "Please try again later.", + "com.affine.share-menu.create-public-link.notification.success.title": "Public link created", + "com.affine.share-menu.create-public-link.notification.success.message": "You can share this document with link.", + "com.affine.share-menu.create-public-link.notification.fail.title": "Failed to create public link", + "com.affine.share-menu.create-public-link.notification.fail.message": "Please try again later.", + "com.affine.share-menu.disable-publish-link.notification.success.title": "Public link disabled", + "com.affine.share-menu.disable-publish-link.notification.success.message": "This page is no longer shared publicly.", + "com.affine.share-menu.disable-publish-link.notification.fail.title": "Failed to disable public link", + "com.affine.share-menu.disable-publish-link.notification.fail.message": "Please try again later.", "com.affine.shortcutsTitle.edgeless": "Edgeless", "com.affine.shortcutsTitle.general": "General", "com.affine.shortcutsTitle.markdownSyntax": "Markdown Syntax", diff --git a/packages/frontend/workspace/src/providers/cloud/index.ts b/packages/frontend/workspace/src/providers/cloud/index.ts index b374451d5e..a5dd71c4b6 100644 --- a/packages/frontend/workspace/src/providers/cloud/index.ts +++ b/packages/frontend/workspace/src/providers/cloud/index.ts @@ -10,10 +10,17 @@ const logger = new DebugLogger('affine:cloud'); const hashMap = new Map(); +type DocPublishMode = 'edgeless' | 'page'; + +export type CloudDoc = { + arrayBuffer: ArrayBuffer; + publishMode: DocPublishMode; +}; + export async function downloadBinaryFromCloud( rootGuid: string, pageGuid: string -): Promise { +): Promise { if (hashMap.has(`${rootGuid}/${pageGuid}`)) { return true; } @@ -25,17 +32,22 @@ export async function downloadBinaryFromCloud( } ); if (response.ok) { + const publishMode = (response.headers.get('publish-mode') || + 'page') as DocPublishMode; const arrayBuffer = await response.arrayBuffer(); hashMap.set(`${rootGuid}/${pageGuid}`, arrayBuffer); - return arrayBuffer; + + // return both arrayBuffer and publish mode + return { arrayBuffer, publishMode }; } return false; } async function downloadBinary(rootGuid: string, doc: Doc) { - const buffer = await downloadBinaryFromCloud(rootGuid, doc.guid); - if (typeof buffer !== 'boolean') { - Y.applyUpdate(doc, new Uint8Array(buffer), 'affine-cloud'); + const response = await downloadBinaryFromCloud(rootGuid, doc.guid); + if (typeof response !== 'boolean') { + const { arrayBuffer } = response; + Y.applyUpdate(doc, new Uint8Array(arrayBuffer), 'affine-cloud'); } } diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index a97af04e28..2be5db6571 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -7,6 +7,7 @@ import { loginUser, } from '@affine-test/kit/utils/cloud'; import { dropFile } from '@affine-test/kit/utils/drop-file'; +import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor'; import { clickNewPageButton, getBlockSuiteEditorTitle, @@ -78,6 +79,52 @@ test.describe('collaboration', () => { } }); + test('share page with default edgeless', async ({ page, browser }) => { + await page.reload(); + await waitForEditorLoad(page); + await createLocalWorkspace( + { + name: 'test', + }, + page + ); + await enableCloudWorkspaceFromShareButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.pressSequentially('TEST TITLE', { + delay: 50, + }); + await page.keyboard.press('Enter', { delay: 50 }); + await page.keyboard.type('TEST CONTENT', { delay: 50 }); + await clickEdgelessModeButton(page); + await expect(page.locator('affine-edgeless-page')).toBeVisible({ + timeout: 1000, + }); + await page.getByTestId('cloud-share-menu-button').click(); + await page.getByTestId('share-menu-create-link-button').click(); + await page.getByTestId('share-menu-copy-link-button').click(); + + // check share page is accessible + { + const context = await browser.newContext(); + const url: string = await page.evaluate(() => + navigator.clipboard.readText() + ); + const page2 = await context.newPage(); + await page2.goto(url); + await waitForEditorLoad(page2); + await expect(page.locator('affine-edgeless-page')).toBeVisible({ + timeout: 1000, + }); + expect(await page2.textContent('affine-paragraph')).toContain( + 'TEST CONTENT' + ); + const logo = page2.getByTestId('share-page-logo'); + const editButton = page2.getByTestId('share-page-edit-button'); + await expect(editButton).not.toBeVisible(); + await expect(logo).toBeVisible(); + } + }); + test('can collaborate with other user and name should display when editing', async ({ page, browser, diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index b711e0c2e4..7bafe3c318 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -1,9 +1,6 @@ import { toast } from '@affine/component'; -import { - PublicLinkDisableModal, - StyledDisableButton, -} from '@affine/component/share-menu'; -import { ShareMenu } from '@affine/component/share-menu'; +import { PublicLinkDisableModal } from '@affine/component/disable-public-link'; +import { ShareMenu } from '@affine/core/components/affine/share-page-modal/share-menu'; import type { AffineCloudWorkspace, LocalWorkspace, @@ -24,20 +21,6 @@ export default { }, } satisfies Meta; -const sharePageMap = new Map([]); -// todo: use a real hook -const useIsSharedPage = ( - _workspaceId: string, - pageId: string -): [isSharePage: boolean, setIsSharePage: (enable: boolean) => void] => { - const [isShared, setIsShared] = useState(sharePageMap.get(pageId) ?? false); - const togglePagePublic = (enable: boolean) => { - setIsShared(enable); - sharePageMap.set(pageId, enable); - }; - return [isShared, togglePagePublic]; -}; - async function initPage(page: Page) { await page.waitForLoaded(); // Add page block and surface block at root level @@ -88,11 +71,8 @@ export const Basic: StoryFn = () => { return ( ); }; @@ -119,11 +99,8 @@ export const AffineBasic: StoryFn = () => { return ( ); }; @@ -133,9 +110,7 @@ export const DisableModal: StoryFn = () => { use(promise); return ( <> - setOpen(!open)}> - Disable Public Link - +
setOpen(!open)}>Disable Public Link
{