refactor(component): make component pure (#5427)

This commit is contained in:
EYHN
2023-12-28 09:57:26 +00:00
parent e11e8277ca
commit 9d51f9596f
19 changed files with 307 additions and 273 deletions

View File

@@ -42,8 +42,6 @@
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toolbar": "^1.0.4", "@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@toeverything/hooks": "workspace:*",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^0.7.24", "@toeverything/theme": "^0.7.24",
"@vanilla-extract/dynamic": "^2.0.3", "@vanilla-extract/dynamic": "^2.0.3",
"bytes": "^3.1.2", "bytes": "^3.1.2",

View File

@@ -1,17 +1,18 @@
import { Unreachable } from '@affine/env/constant'; import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons'; import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Tooltip } from '../../../ui/tooltip'; import { Tooltip } from '../../../ui/tooltip';
import * as styles from './index.css'; import * as styles from './index.css';
export interface AddPageButtonPureProps { export interface AddPageButtonProps {
onClickUpdate: () => void; onQuitAndInstall: () => void;
onDismissCurrentChangelog: () => void; onDownloadUpdate: () => void;
currentChangelogUnread: boolean; onDismissChangelog: () => void;
onOpenChangelog: () => void;
changelogUnread: boolean;
updateReady: boolean; updateReady: boolean;
updateAvailable: { updateAvailable: {
version: string; version: string;
@@ -33,8 +34,8 @@ interface ButtonContentProps {
autoDownload: boolean; autoDownload: boolean;
downloadProgress: number | null; downloadProgress: number | null;
appQuitting: boolean; appQuitting: boolean;
currentChangelogUnread: boolean; changelogUnread: boolean;
onDismissCurrentChangelog: () => void; onDismissChangelog: () => void;
} }
function DownloadUpdate({ updateAvailable }: ButtonContentProps) { function DownloadUpdate({ updateAvailable }: ButtonContentProps) {
@@ -114,14 +115,14 @@ function OpenDownloadPage({ updateAvailable }: ButtonContentProps) {
); );
} }
function WhatsNew({ onDismissCurrentChangelog }: ButtonContentProps) { function WhatsNew({ onDismissChangelog }: ButtonContentProps) {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const onClickClose: React.MouseEventHandler = useCallback( const onClickClose: React.MouseEventHandler = useCallback(
e => { e => {
onDismissCurrentChangelog(); onDismissChangelog();
e.stopPropagation(); e.stopPropagation();
}, },
[onDismissCurrentChangelog] [onDismissChangelog]
); );
return ( return (
<> <>
@@ -149,42 +150,76 @@ const getButtonContentRenderer = (props: ButtonContentProps) => {
} }
} else if (props.updateAvailable && !props.updateAvailable?.allowAutoUpdate) { } else if (props.updateAvailable && !props.updateAvailable?.allowAutoUpdate) {
return OpenDownloadPage; return OpenDownloadPage;
} else if (props.currentChangelogUnread) { } else if (props.changelogUnread) {
return WhatsNew; return WhatsNew;
} }
return null; return null;
}; };
export function AppUpdaterButtonPure({ export function AppUpdaterButton({
updateReady, updateReady,
onClickUpdate, changelogUnread,
onDismissCurrentChangelog, onDismissChangelog,
currentChangelogUnread, onDownloadUpdate,
onQuitAndInstall,
onOpenChangelog,
updateAvailable, updateAvailable,
autoDownload, autoDownload,
downloadProgress, downloadProgress,
appQuitting, appQuitting,
className, className,
style, style,
}: AddPageButtonPureProps) { }: AddPageButtonProps) {
const handleClick = useCallback(() => {
if (updateReady) {
onQuitAndInstall();
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
if (autoDownload) {
// wait for download to finish
} else {
onDownloadUpdate();
}
} else {
window.open(
`https://github.com/toeverything/AFFiNE/releases/tag/v${updateAvailable.version}`,
'_blank'
);
}
} else if (changelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
onOpenChangelog();
} else {
throw new Unreachable();
}
}, [
updateReady,
updateAvailable,
changelogUnread,
onQuitAndInstall,
autoDownload,
onDownloadUpdate,
onOpenChangelog,
]);
const contentProps = useMemo( const contentProps = useMemo(
() => ({ () => ({
updateReady, updateReady,
updateAvailable, updateAvailable,
currentChangelogUnread, changelogUnread,
autoDownload, autoDownload,
downloadProgress, downloadProgress,
appQuitting, appQuitting,
onDismissCurrentChangelog, onDismissChangelog,
}), }),
[ [
updateReady, updateReady,
updateAvailable, updateAvailable,
currentChangelogUnread, changelogUnread,
autoDownload, autoDownload,
downloadProgress, downloadProgress,
appQuitting, appQuitting,
onDismissCurrentChangelog, onDismissChangelog,
] ]
); );
@@ -222,6 +257,10 @@ export function AppUpdaterButtonPure({
updateReady, updateReady,
]); ]);
if (!updateAvailable && !changelogUnread) {
return null;
}
return wrapWithTooltip( return wrapWithTooltip(
<button <button
style={style} style={style}
@@ -229,7 +268,7 @@ export function AppUpdaterButtonPure({
data-has-update={!!updateAvailable} data-has-update={!!updateAvailable}
data-updating={appQuitting} data-updating={appQuitting}
data-disabled={disabled} data-disabled={disabled}
onClick={onClickUpdate} onClick={handleClick}
> >
{ContentComponent ? <ContentComponent {...contentProps} /> : null} {ContentComponent ? <ContentComponent {...contentProps} /> : null}
<div className={styles.particles} aria-hidden="true"></div> <div className={styles.particles} aria-hidden="true"></div>
@@ -238,77 +277,3 @@ export function AppUpdaterButtonPure({
updateAvailable?.version updateAvailable?.version
); );
} }
// Although it is called an input, it is actually a button.
export function AppUpdaterButton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
const {
quitAndInstall,
appQuitting,
autoDownload,
downloadUpdate,
readChangelog,
changelogUnread,
updateReady,
updateAvailable,
downloadProgress,
currentVersion,
} = useAppUpdater();
const handleClickUpdate = useCallback(() => {
if (updateReady) {
quitAndInstall();
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
if (autoDownload) {
// wait for download to finish
} else {
downloadUpdate();
}
} else {
window.open(
`https://github.com/toeverything/AFFiNE/releases/tag/v${currentVersion}`,
'_blank'
);
}
} else if (changelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
readChangelog();
} else {
throw new Unreachable();
}
}, [
updateReady,
updateAvailable,
changelogUnread,
quitAndInstall,
autoDownload,
downloadUpdate,
currentVersion,
readChangelog,
]);
if (!updateAvailable && !changelogUnread) {
return null;
}
return (
<AppUpdaterButtonPure
appQuitting={appQuitting}
autoDownload={autoDownload}
updateReady={!!updateReady}
onClickUpdate={handleClickUpdate}
onDismissCurrentChangelog={readChangelog}
currentChangelogUnread={changelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
className={className}
style={style}
/>
);
}

View File

@@ -3,8 +3,6 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceMetadata } from '@affine/workspace'; import type { WorkspaceMetadata } from '@affine/workspace';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob';
import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Avatar } from '../../../ui/avatar'; import { Avatar } from '../../../ui/avatar';
@@ -72,6 +70,8 @@ export interface WorkspaceCardProps {
onClick: (metadata: WorkspaceMetadata) => void; onClick: (metadata: WorkspaceMetadata) => void;
onSettingClick: (metadata: WorkspaceMetadata) => void; onSettingClick: (metadata: WorkspaceMetadata) => void;
isOwner?: boolean; isOwner?: boolean;
avatar?: string;
name?: string;
} }
export const WorkspaceCardSkeleton = () => { export const WorkspaceCardSkeleton = () => {
@@ -96,11 +96,10 @@ export const WorkspaceCard = ({
currentWorkspaceId, currentWorkspaceId,
meta, meta,
isOwner = true, isOwner = true,
name,
avatar,
}: WorkspaceCardProps) => { }: WorkspaceCardProps) => {
const information = useWorkspaceInfo(meta); const displayName = name ?? UNTITLED_WORKSPACE_NAME;
const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
return ( return (
<StyledCard <StyledCard
data-testid="workspace-card" data-testid="workspace-card"
@@ -109,12 +108,10 @@ export const WorkspaceCard = ({
}, [onClick, meta])} }, [onClick, meta])}
active={meta.id === currentWorkspaceId} active={meta.id === currentWorkspaceId}
> >
<Avatar size={28} url={avatarUrl} name={name} colorfulFallback /> <Avatar size={28} url={avatar} name={name} colorfulFallback />
<StyledWorkspaceInfo> <StyledWorkspaceInfo>
<StyledWorkspaceTitleArea style={{ display: 'flex' }}> <StyledWorkspaceTitleArea style={{ display: 'flex' }}>
<StyledWorkspaceTitle> <StyledWorkspaceTitle>{displayName}</StyledWorkspaceTitle>
{information?.name ?? UNTITLED_WORKSPACE_NAME}
</StyledWorkspaceTitle>
<StyledSettingLink <StyledSettingLink
size="small" size="small"

View File

@@ -0,0 +1,69 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { renderHook } from '@testing-library/react';
import { useAtomValue } from 'jotai';
import { describe, expect, test, vi } from 'vitest';
import { beforeEach } from 'vitest';
import { useBlockSuitePagePreview } from '../use-block-suite-page-preview';
let blockSuiteWorkspace: BlockSuiteWorkspace;
const schema = new Schema();
schema.register(AffineSchemas).register(__unstableSchemas);
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
const initPage = async (page: Page) => {
await page.waitForLoaded();
expect(page).not.toBeNull();
assertExists(page);
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(''),
});
const frameId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
};
await initPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
});
describe('useBlockSuitePagePreview', () => {
test('basic', async () => {
const page = blockSuiteWorkspace.getPage('page0') as Page;
const id = page.addBlock(
'affine:paragraph',
{
text: new page.Text('Hello, world!'),
},
page.getBlockByFlavour('affine:note')[0].id
);
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
expect(hook.result.current).toBe('Hello, world!');
page.transact(() => {
page.getBlockById(id)!.text!.insert('Test', 0);
});
await new Promise(resolve => setTimeout(resolve, 100));
hook.rerender();
expect(hook.result.current).toBe('TestHello, world!');
// Insert before
page.addBlock(
'affine:paragraph',
{
text: new page.Text('First block!'),
},
page.getBlockByFlavour('affine:note')[0].id,
0
);
await new Promise(resolve => setTimeout(resolve, 100));
hook.rerender();
expect(hook.result.current).toBe('First block! TestHello, world!');
});
});

View File

@@ -1,9 +1,10 @@
import type { Workspace } from '@blocksuite/store'; import type { Workspace } from '@blocksuite/store';
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
interface PagePreviewInnerProps { interface PagePreviewInnerProps {
workspace: Workspace; workspace: Workspace;
pageId: string; pageId: string;

View File

@@ -0,0 +1,46 @@
import { DebugLogger } from '@affine/debug';
import { DisposableGroup } from '@blocksuite/global/utils';
import type { Page, Workspace } from '@blocksuite/store';
import { useEffect, useState } from 'react';
const logger = new DebugLogger('use-block-suite-workspace-page');
export function useBlockSuiteWorkspacePage(
blockSuiteWorkspace: Workspace,
pageId: string | null
): Page | null {
const [page, setPage] = useState(
pageId ? blockSuiteWorkspace.getPage(pageId) : null
);
useEffect(() => {
const group = new DisposableGroup();
group.add(
blockSuiteWorkspace.slots.pageAdded.on(id => {
if (pageId === id) {
setPage(blockSuiteWorkspace.getPage(id));
}
})
);
group.add(
blockSuiteWorkspace.slots.pageRemoved.on(id => {
if (pageId === id) {
setPage(null);
}
})
);
return () => {
group.dispose();
};
}, [blockSuiteWorkspace, pageId]);
useEffect(() => {
if (page && !page.loaded) {
page.load().catch(err => {
logger.error('Failed to load page', err);
});
}
}, [page]);
return page;
}

View File

@@ -24,21 +24,28 @@ export interface WorkspaceListProps {
disabled?: boolean; disabled?: boolean;
currentWorkspaceId?: string | null; currentWorkspaceId?: string | null;
items: WorkspaceMetadata[]; items: WorkspaceMetadata[];
onClick: (workspaceMetadata: WorkspaceMetadata) => void; onClick: (workspace: WorkspaceMetadata) => void;
onSettingClick: (workspaceMetadata: WorkspaceMetadata) => void; onSettingClick: (workspace: WorkspaceMetadata) => void;
onDragEnd: (event: DragEndEvent) => void; onDragEnd: (event: DragEndEvent) => void;
useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean; useIsWorkspaceOwner: (workspaceMetadata: WorkspaceMetadata) => boolean;
useWorkspaceAvatar: (
workspaceMetadata: WorkspaceMetadata
) => string | undefined;
useWorkspaceName: (
workspaceMetadata: WorkspaceMetadata
) => string | undefined;
} }
interface SortableWorkspaceItemProps extends Omit<WorkspaceListProps, 'items'> { interface SortableWorkspaceItemProps extends Omit<WorkspaceListProps, 'items'> {
item: WorkspaceMetadata; item: WorkspaceMetadata;
useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean;
} }
const SortableWorkspaceItem = ({ const SortableWorkspaceItem = ({
disabled, disabled,
item, item,
useIsWorkspaceOwner, useIsWorkspaceOwner,
useWorkspaceAvatar,
useWorkspaceName,
currentWorkspaceId, currentWorkspaceId,
onClick, onClick,
onSettingClick, onSettingClick,
@@ -59,6 +66,8 @@ const SortableWorkspaceItem = ({
[disabled, transform, transition] [disabled, transform, transition]
); );
const isOwner = useIsWorkspaceOwner?.(item); const isOwner = useIsWorkspaceOwner?.(item);
const avatar = useWorkspaceAvatar?.(item);
const name = useWorkspaceName?.(item);
return ( return (
<div <div
className={workspaceItemStyle} className={workspaceItemStyle}
@@ -74,6 +83,8 @@ const SortableWorkspaceItem = ({
onClick={onClick} onClick={onClick}
onSettingClick={onSettingClick} onSettingClick={onSettingClick}
isOwner={isOwner} isOwner={isOwner}
name={name}
avatar={avatar}
/> />
</div> </div>
); );

View File

@@ -107,7 +107,7 @@ export const UserWithWorkspaceList = ({
<SignInItem /> <SignInItem />
)} )}
<Divider size="thinner" /> <Divider size="thinner" />
<AFFiNEWorkspaceList workspaces={workspaces} onEventEnd={onEventEnd} /> <AFFiNEWorkspaceList onEventEnd={onEventEnd} />
{workspaces.length > 0 ? <Divider size="thinner" /> : null} {workspaces.length > 0 ? <Divider size="thinner" /> : null}
<AddWorkspace <AddWorkspace
onAddWorkspace={onAddWorkspace} onAddWorkspace={onAddWorkspace}

View File

@@ -4,8 +4,15 @@ import { WorkspaceList } from '@affine/component/workspace-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceMetadata } from '@affine/workspace'; import type { WorkspaceMetadata } from '@affine/workspace';
import { currentWorkspaceAtom } from '@affine/workspace/atom'; import {
currentWorkspaceAtom,
workspaceListAtom,
} from '@affine/workspace/atom';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import {
useWorkspaceAvatar,
useWorkspaceName,
} from '@toeverything/hooks/use-workspace-info';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@@ -54,6 +61,8 @@ const CloudWorkSpaceList = ({
onSettingClick={onClickWorkspaceSetting} onSettingClick={onClickWorkspaceSetting}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
useIsWorkspaceOwner={useIsWorkspaceOwner} useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName}
useWorkspaceAvatar={useWorkspaceAvatar}
/> />
</div> </div>
); );
@@ -83,18 +92,21 @@ const LocalWorkspaces = ({
onClick={onClickWorkspace} onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting} onSettingClick={onClickWorkspaceSetting}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName}
useWorkspaceAvatar={useWorkspaceAvatar}
/> />
</div> </div>
); );
}; };
export const AFFiNEWorkspaceList = ({ export const AFFiNEWorkspaceList = ({
workspaces,
onEventEnd, onEventEnd,
}: { }: {
workspaces: WorkspaceMetadata[];
onEventEnd?: () => void; onEventEnd?: () => void;
}) => { }) => {
const workspaces = useAtomValue(workspaceListAtom);
const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom);
const { jumpToSubPath } = useNavigateHelper(); const { jumpToSubPath } = useNavigateHelper();

View File

@@ -4,7 +4,6 @@ import {
AppDownloadButton, AppDownloadButton,
AppSidebar, AppSidebar,
appSidebarOpenAtom, appSidebarOpenAtom,
AppUpdaterButton,
CategoryDivider, CategoryDivider,
MenuItem, MenuItem,
MenuLinkItem, MenuLinkItem,
@@ -49,6 +48,7 @@ import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
import { UserWithWorkspaceList } from '../pure/workspace-slider-bar/user-with-workspace-list'; import { UserWithWorkspaceList } from '../pure/workspace-slider-bar/user-with-workspace-list';
import { WorkspaceCard } from '../pure/workspace-slider-bar/workspace-card'; import { WorkspaceCard } from '../pure/workspace-slider-bar/workspace-card';
import ImportPage from './import-page'; import ImportPage from './import-page';
import { UpdaterButton } from './updater-button';
export type RootAppSidebarProps = { export type RootAppSidebarProps = {
isPublicWorkspace: boolean; isPublicWorkspace: boolean;
@@ -299,7 +299,7 @@ export const RootAppSidebar = ({
)} )}
</SidebarScrollableContainer> </SidebarScrollableContainer>
<SidebarContainer> <SidebarContainer>
{environment.isDesktop ? <AppUpdaterButton /> : <AppDownloadButton />} {environment.isDesktop ? <UpdaterButton /> : <AppDownloadButton />}
<div style={{ height: '4px' }} /> <div style={{ height: '4px' }} />
<AddPageButton onClick={onClickNewPage} /> <AddPageButton onClick={onClickNewPage} />
</SidebarContainer> </SidebarContainer>

View File

@@ -0,0 +1,21 @@
import { AppUpdaterButton } from '@affine/component/app-sidebar/app-updater-button';
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
export const UpdaterButton = () => {
const appUpdater = useAppUpdater();
return (
<AppUpdaterButton
onQuitAndInstall={appUpdater.quitAndInstall}
onDownloadUpdate={appUpdater.downloadUpdate}
onDismissChangelog={appUpdater.dismissChangelog}
onOpenChangelog={appUpdater.openChangelog}
changelogUnread={appUpdater.changelogUnread}
updateReady={!!appUpdater.updateReady}
updateAvailable={appUpdater.updateAvailable}
autoDownload={appUpdater.autoDownload}
downloadProgress={appUpdater.downloadProgress}
appQuitting={appUpdater.appQuitting}
/>
);
};

View File

@@ -8,11 +8,9 @@ import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { useAtomValue } from 'jotai';
import { describe, expect, test, vi } from 'vitest'; import { describe, expect, test, vi } from 'vitest';
import { beforeEach } from 'vitest'; import { beforeEach } from 'vitest';
import { useBlockSuitePagePreview } from '../use-block-suite-page-preview';
import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title'; import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title';
let blockSuiteWorkspace: BlockSuiteWorkspace; let blockSuiteWorkspace: BlockSuiteWorkspace;
@@ -49,37 +47,3 @@ describe('useBlockSuiteWorkspacePageTitle', () => {
expect(pageTitleHook.result.current).toBe('1'); expect(pageTitleHook.result.current).toBe('1');
}); });
}); });
describe('useBlockSuitePagePreview', () => {
test('basic', async () => {
const page = blockSuiteWorkspace.getPage('page0') as Page;
const id = page.addBlock(
'affine:paragraph',
{
text: new page.Text('Hello, world!'),
},
page.getBlockByFlavour('affine:note')[0].id
);
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
expect(hook.result.current).toBe('Hello, world!');
page.transact(() => {
page.getBlockById(id)!.text!.insert('Test', 0);
});
await new Promise(resolve => setTimeout(resolve, 100));
hook.rerender();
expect(hook.result.current).toBe('TestHello, world!');
// Insert before
page.addBlock(
'affine:paragraph',
{
text: new page.Text('First block!'),
},
page.getBlockByFlavour('affine:note')[0].id,
0
);
await new Promise(resolve => setTimeout(resolve, 100));
hook.rerender();
expect(hook.result.current).toBe('First block! TestHello, world!');
});
});

View File

@@ -168,7 +168,12 @@ export const useAppUpdater = () => {
[setSetting] [setSetting]
); );
const readChangelog = useAsyncCallback(async () => { const openChangelog = useAsyncCallback(async () => {
window.open(runtimeConfig.changelogUrl, '_blank');
await setChangelogUnread(true);
}, [setChangelogUnread]);
const dismissChangelog = useAsyncCallback(async () => {
await setChangelogUnread(true); await setChangelogUnread(true);
}, [setChangelogUnread]); }, [setChangelogUnread]);
@@ -183,7 +188,8 @@ export const useAppUpdater = () => {
autoCheck: setting.autoCheckUpdate, autoCheck: setting.autoCheckUpdate,
autoDownload: setting.autoDownloadUpdate, autoDownload: setting.autoDownloadUpdate,
changelogUnread, changelogUnread,
readChangelog, openChangelog,
dismissChangelog,
updateReady, updateReady,
updateAvailable: useAtomValue(updateAvailableAtom), updateAvailable: useAtomValue(updateAvailableAtom),
downloadProgress, downloadProgress,

View File

@@ -1,112 +1,42 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; import { DisposableGroup } from '@blocksuite/global/utils';
import type { Page, Workspace } from '@blocksuite/store'; import type { Page, Workspace } from '@blocksuite/store';
import type { Atom } from 'jotai'; import { useEffect, useState } from 'react';
import { atom, useAtomValue } from 'jotai';
import PQueue from 'p-queue';
import { useEffect } from 'react';
const logger = new DebugLogger('use-block-suite-workspace-page'); const logger = new DebugLogger('use-block-suite-workspace-page');
const weakMap = new WeakMap<Workspace, Map<string, Atom<Page | null>>>();
const emptyAtom = atom<Page | null>(null);
function getAtom(w: Workspace, pageId: string | null): Atom<Page | null> {
if (!pageId) {
return emptyAtom;
}
if (!weakMap.has(w)) {
weakMap.set(w, new Map());
}
const map = weakMap.get(w);
assertExists(map);
if (!map.has(pageId)) {
const baseAtom = atom(w.getPage(pageId));
baseAtom.onMount = set => {
const group = new DisposableGroup();
group.add(
w.slots.pageAdded.on(id => {
if (pageId === id) {
set(w.getPage(id));
}
})
);
group.add(
w.slots.pageRemoved.on(id => {
if (pageId === id) {
set(null);
}
})
);
return () => {
group.dispose();
};
};
map.set(pageId, baseAtom);
return baseAtom;
} else {
return map.get(pageId) as Atom<Page | null>;
}
}
// concurrently load 3 pages at most
const CONCURRENT_JOBS = 3;
const loadPageQueue = new PQueue({
concurrency: CONCURRENT_JOBS,
});
const loadedPages = new WeakSet<Page>();
const awaitForIdle = () =>
new Promise(resolve =>
requestIdleCallback(resolve, {
timeout: 1000, // do not wait for too long
})
);
const awaitForTimeout = (timeout: number) =>
new Promise(resolve => setTimeout(resolve, timeout));
/**
* Load a page and wait for it to be loaded
* This page will be loaded in a queue so that it will not jam the network and browser CPU
*/
export function loadPage(page: Page, priority = 0) {
if (loadedPages.has(page)) {
return Promise.resolve();
}
loadedPages.add(page);
return loadPageQueue.add(
async () => {
if (!page.loaded) {
await awaitForIdle();
await page.waitForLoaded();
logger.debug('page loaded', page.id);
// we do not know how long it takes to load a page here
// so that we just use 300ms timeout as the default page processing time
await awaitForTimeout(300);
} else {
// do nothing if it is already loaded
}
},
{
priority,
}
);
}
export function useBlockSuiteWorkspacePage( export function useBlockSuiteWorkspacePage(
blockSuiteWorkspace: Workspace, blockSuiteWorkspace: Workspace,
pageId: string | null pageId: string | null
): Page | null { ): Page | null {
const pageAtom = getAtom(blockSuiteWorkspace, pageId); const [page, setPage] = useState(
assertExists(pageAtom); pageId ? blockSuiteWorkspace.getPage(pageId) : null
const page = useAtomValue(pageAtom); );
useEffect(() => {
const group = new DisposableGroup();
group.add(
blockSuiteWorkspace.slots.pageAdded.on(id => {
if (pageId === id) {
setPage(blockSuiteWorkspace.getPage(id));
}
})
);
group.add(
blockSuiteWorkspace.slots.pageRemoved.on(id => {
if (pageId === id) {
setPage(null);
}
})
);
return () => {
group.dispose();
};
}, [blockSuiteWorkspace, pageId]);
useEffect(() => { useEffect(() => {
if (page && !page.loaded) { if (page && !page.loaded) {
loadPage(page).catch(err => { page.load().catch(err => {
logger.error('Failed to load page', err); logger.error('Failed to load page', err);
}); });
} }

View File

@@ -1,12 +1,11 @@
import type { Workspace, WorkspaceMetadata } from '@affine/workspace'; import type { WorkspaceMetadata } from '@affine/workspace';
import { workspaceManagerAtom } from '@affine/workspace/atom'; import { workspaceManagerAtom } from '@affine/workspace/atom';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function useWorkspaceInfo( import { useWorkspaceBlobObjectUrl } from './use-workspace-blob';
meta: WorkspaceMetadata,
workspace?: Workspace export function useWorkspaceInfo(meta: WorkspaceMetadata) {
) {
const workspaceManager = useAtomValue(workspaceManagerAtom); const workspaceManager = useAtomValue(workspaceManagerAtom);
const [information, setInformation] = useState( const [information, setInformation] = useState(
@@ -20,7 +19,20 @@ export function useWorkspaceInfo(
return information.onUpdated.on(info => { return information.onUpdated.on(info => {
setInformation(info); setInformation(info);
}).dispose; }).dispose;
}, [meta, workspace, workspaceManager]); }, [meta, workspaceManager]);
return information; return information;
} }
export function useWorkspaceName(meta: WorkspaceMetadata) {
const information = useWorkspaceInfo(meta);
return information?.name;
}
export function useWorkspaceAvatar(meta: WorkspaceMetadata) {
const information = useWorkspaceInfo(meta);
const avatar = useWorkspaceBlobObjectUrl(meta, information?.avatar);
return avatar;
}

View File

@@ -1,17 +1,17 @@
import { import {
type AddPageButtonPureProps, type AddPageButtonProps,
AppUpdaterButtonPure, AppUpdaterButton,
} from '@affine/component/app-sidebar'; } from '@affine/component/app-sidebar';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
export default { export default {
title: 'AFFiNE/AppUpdaterButton', title: 'AFFiNE/AppUpdaterButton',
component: AppUpdaterButtonPure, component: AppUpdaterButton,
parameters: { parameters: {
chromatic: { disableSnapshot: true }, chromatic: { disableSnapshot: true },
}, },
} satisfies Meta<typeof AppUpdaterButtonPure>; } satisfies Meta<typeof AppUpdaterButton>;
const Container = ({ children }: PropsWithChildren) => ( const Container = ({ children }: PropsWithChildren) => (
<main <main
@@ -28,10 +28,10 @@ const Container = ({ children }: PropsWithChildren) => (
</main> </main>
); );
export const Default: StoryFn<AddPageButtonPureProps> = props => { export const Default: StoryFn<AddPageButtonProps> = props => {
return ( return (
<Container> <Container>
<AppUpdaterButtonPure {...props} /> <AppUpdaterButton {...props} />
</Container> </Container>
); );
}; };
@@ -44,17 +44,18 @@ Default.args = {
allowAutoUpdate: true, allowAutoUpdate: true,
}, },
downloadProgress: 42, downloadProgress: 42,
currentChangelogUnread: true, changelogUnread: true,
autoDownload: false,
}; };
export const Updated: StoryFn<AddPageButtonPureProps> = props => { export const Updated: StoryFn<AddPageButtonProps> = props => {
return ( return (
<Container> <Container>
<AppUpdaterButtonPure {...props} updateAvailable={null} /> <AppUpdaterButton {...props} updateAvailable={null} />
</Container> </Container>
); );
}; };
Updated.args = { Updated.args = {
currentChangelogUnread: true, changelogUnread: true,
}; };

View File

@@ -21,6 +21,9 @@ export const Default = () => {
onClick={() => {}} onClick={() => {}}
onSettingClick={() => {}} onSettingClick={() => {}}
onDragEnd={_ => {}} onDragEnd={_ => {}}
useWorkspaceAvatar={() => undefined}
useWorkspaceName={() => undefined}
useIsWorkspaceOwner={() => false}
/> />
); );
}; };

View File

@@ -253,8 +253,6 @@ __metadata:
"@storybook/test-runner": "npm:^0.16.0" "@storybook/test-runner": "npm:^0.16.0"
"@storybook/testing-library": "npm:^0.2.2" "@storybook/testing-library": "npm:^0.2.2"
"@testing-library/react": "npm:^14.0.0" "@testing-library/react": "npm:^14.0.0"
"@toeverything/hooks": "workspace:*"
"@toeverything/infra": "workspace:*"
"@toeverything/theme": "npm:^0.7.24" "@toeverything/theme": "npm:^0.7.24"
"@types/bytes": "npm:^3.1.3" "@types/bytes": "npm:^3.1.3"
"@types/react": "npm:^18.2.28" "@types/react": "npm:^18.2.28"
@@ -12807,7 +12805,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@toeverything/hooks@workspace:*, @toeverything/hooks@workspace:packages/frontend/hooks": "@toeverything/hooks@workspace:packages/frontend/hooks":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@toeverything/hooks@workspace:packages/frontend/hooks" resolution: "@toeverything/hooks@workspace:packages/frontend/hooks"
dependencies: dependencies: