fix(core): adjust share menu (#10164)

close AF-2218 AF-2215 AF-2221
This commit is contained in:
JimmFly
2025-02-14 10:32:12 +00:00
parent 36800f2d24
commit 9048b38069
20 changed files with 328 additions and 184 deletions

View File

@@ -1,4 +1,4 @@
import type { DocRole, GetPageGrantedUsersListQuery } from '@affine/graphql';
import { DocRole, type GetPageGrantedUsersListQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
@@ -123,20 +123,29 @@ export class DocGrantedUsersService extends Service {
}
async updateUserRole(userId: string, role: DocRole) {
await this.store.updateDocUserRole(
const res = await this.store.updateDocUserRole(
this.workspaceService.workspace.id,
this.docService.doc.id,
userId,
role
);
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (user.user.id === userId) {
return { ...user, role };
}
return user;
})
);
if (res) {
if (role === DocRole.Owner) {
this.reset();
this.loadMore();
return res;
}
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (user.user.id === userId) {
return { ...user, role };
}
return user;
})
);
}
return res;
}
async updateDocDefaultRole(role: DocRole) {

View File

@@ -2,7 +2,6 @@ import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const copyLinkContainerStyle = style({
padding: '4px',
display: 'flex',
alignItems: 'center',
width: '100%',
@@ -18,6 +17,7 @@ export const copyLinkButtonStyle = style({
flex: 1,
padding: '4px 12px',
paddingRight: '6px',
borderRadius: '4px',
borderRight: 'none',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
@@ -35,6 +35,7 @@ export const copyLinkButtonStyle = style({
export const copyLinkLabelContainerStyle = style({
width: '100%',
borderRight: 'none',
borderRadius: '4px',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
position: 'relative',
@@ -70,6 +71,7 @@ export const copyLinkShortcutStyle = style({
});
export const copyLinkTriggerStyle = style({
padding: '4px 12px 4px 8px',
borderRadius: '4px',
borderLeft: 'none',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',

View File

@@ -1,10 +1,12 @@
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
import { Menu, MenuItem, MenuTrigger, Tooltip } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocGrantedUsersService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { InformationIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { PlanTag } from '../plan-tag';
@@ -42,7 +44,8 @@ export const MembersPermission = ({
() => getRoleName(t, docDefaultRole),
[docDefaultRole, t]
);
const showTips =
docDefaultRole === DocRole.Reader || docDefaultRole === DocRole.Editor;
const changePermission = useCallback(
async (docRole: DocRole) => {
await docGrantedUsersService.updateDocDefaultRole(docRole);
@@ -76,12 +79,16 @@ export const MembersPermission = ({
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.permission.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
disabled ? null : (
{disabled ? (
<div className={clsx(styles.menuTriggerStyle, 'disable')}>
{showTips ? <Tips disable={disabled} /> : null} {currentRoleName}
</div>
) : (
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
onSelect={selectManage}
@@ -98,7 +105,7 @@ export const MembersPermission = ({
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
{hittingPaywall ? <PlanTag /> : null}
</div>
</div>
</MenuItem>
@@ -109,25 +116,39 @@ export const MembersPermission = ({
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
{hittingPaywall ? <PlanTag /> : null}
</div>
</div>
</MenuItem>
</>
)
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
variant="plain"
contentStyle={{
width: '100%',
}}
disabled={disabled}
}
>
{currentRoleName}
</MenuTrigger>
</Menu>
<MenuTrigger
className={styles.menuTriggerStyle}
variant="plain"
contentStyle={{
width: '100%',
}}
prefix={showTips ? <Tips /> : undefined}
>
{currentRoleName}
</MenuTrigger>
</Menu>
)}
</div>
);
};
export const Tips = ({ disable }: { disable?: boolean }) => {
const t = useI18n();
return (
<Tooltip content={t['com.affine.share-menu.option.permission.tips']()}>
<InformationIcon
className={clsx(styles.informationIcon, {
disable: disable,
})}
/>
</Tooltip>
);
};

View File

@@ -11,6 +11,7 @@ import {
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import { useEffect } from 'react';
import * as styles from './styles.css';
@@ -92,13 +93,18 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.link.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
disabled ? null : (
{disabled ? (
<div className={clsx(styles.menuTriggerStyle, 'disable')}>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</div>
) : (
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
prefixIcon={<LockIcon />}
@@ -122,23 +128,22 @@ export const PublicDoc = ({ disabled }: { disabled?: boolean }) => {
</div>
</MenuItem>
</>
)
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
data-testid="share-link-menu-trigger"
variant="plain"
contentStyle={{
width: '100%',
}}
disabled={disabled}
}
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</MenuTrigger>
</Menu>
<MenuTrigger
className={styles.menuTriggerStyle}
data-testid="share-link-menu-trigger"
variant="plain"
contentStyle={{
width: '100%',
}}
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</MenuTrigger>
</Menu>
)}
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const menuTriggerStyle = style({
@@ -8,6 +9,13 @@ export const menuTriggerStyle = style({
display: 'flex',
fontSize: cssVar('fontSm'),
fontWeight: 400,
selectors: {
'&.disable': {
alignItems: 'center',
gap: '4px',
color: cssVarV2('text/disable'),
},
},
});
export const rowContainerStyle = style({
@@ -36,3 +44,13 @@ export const tagContainerStyle = style({
alignItems: 'center',
gap: '8px',
});
export const informationIcon = style({
color: cssVarV2('icon/primary'),
fontSize: '20px',
selectors: {
'&.disable': {
color: cssVarV2('icon/disable'),
},
},
});

View File

@@ -1,14 +1,14 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const headerStyle = style({
export const tabList = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
padding: '0 4px',
gap: '4px',
gap: '12px',
height: '28px',
});
export const tab = style({
padding: '0px 4px 6px',
});
export const content = style({
display: 'flex',
@@ -17,7 +17,6 @@ export const content = style({
});
export const menuStyle = style({
width: '390px',
minHeight: '310px',
maxHeight: '562px',
padding: '12px',
});

View File

@@ -1,6 +1,7 @@
import { Input } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import * as styles from './styles.css';
@@ -9,11 +10,12 @@ export const InviteInput = ({ onFocus }: { onFocus: () => void }) => {
return (
<Input
preFix={<SearchIcon fontSize={20} />}
preFix={<SearchIcon className={styles.iconStyle} />}
className={styles.inputStyle}
onFocus={onFocus}
inputStyle={{
paddingLeft: '0',
fontSize: cssVar('fontSm'),
}}
placeholder={t['com.affine.share-menu.invite-editor.placeholder']()}
/>

View File

@@ -16,10 +16,11 @@ export const headerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
cursor: 'pointer',
gap: '4px',
padding: '4px 4px 6px',
padding: '0px 4px 6px',
height: '28px',
color: cssVarV2('text/secondary'),
});
export const iconStyle = style({
@@ -54,7 +55,7 @@ export const searchInput = style({
flexGrow: 1,
border: 'none',
outline: 'none',
fontSize: '14px',
fontSize: cssVar('fontSm'),
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
@@ -70,6 +71,7 @@ export const InputContainer = style({
padding: '4px',
flexWrap: 'wrap',
width: '100%',
margin: '6px 0px',
border: `1px solid ${cssVarV2('input/border/default')}`,
selectors: {
@@ -135,6 +137,12 @@ export const checkbox = style({
color: cssVarV2('icon/primary'),
});
export const resultContainer = style({
minHeight: '155px',
maxHeight: '394px',
overflow: 'hidden',
});
export const noFound = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),

View File

@@ -152,19 +152,23 @@ export const InviteMemberEditor = ({
const switchToMemberManagementTab = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'workspace:preference',
activeTab: 'workspace:members',
});
}, [workspaceDialogService]);
const handleClickMember = useCallback((member: Member) => {
setSelectedMembers(prev => {
if (prev.some(m => m.id === member.id)) {
// if the member is already in the list, just return
return prev;
}
return [...prev, member];
});
}, []);
const handleClickMember = useCallback(
(member: Member) => {
setSelectedMembers(prev => {
if (prev.some(m => m.id === member.id)) {
// if the member is already in the list, just return
return prev;
}
return [...prev, member];
});
focusInput();
},
[focusInput]
);
const handleRoleChange = useCallback((role: DocRole) => {
setInviteDocRoleType(role);
@@ -221,16 +225,20 @@ export const InviteMemberEditor = ({
/>
)}
</div>
<div className={styles.sentEmail}>
<Checkbox
className={styles.checkbox}
checked={false}
disabled // TODO(@JimmFly): implement this
/>
{t['com.affine.share-menu.invite-editor.sent-email']()}
{` (coming soon)`}
{selectedMembers.length ? (
<div className={styles.sentEmail}>
<Checkbox
className={styles.checkbox}
checked={false}
disabled // TODO(@JimmFly): implement this
/>
{t['com.affine.share-menu.invite-editor.sent-email']()}
{` (coming soon)`}
</div>
) : null}
<div className={styles.resultContainer}>
<Result onClickMember={handleClickMember} />
</div>
<Result onClickMember={handleClickMember} />
</div>
<div className={styles.footerStyle}>
<span
@@ -263,6 +271,7 @@ const Result = ({
onClickMember: (member: Member) => void;
}) => {
const memberSearchService = useService(MemberSearchService);
const searchText = useLiveData(memberSearchService.searchText$);
const result = useLiveData(memberSearchService.result$);
const isLoading = useLiveData(memberSearchService.isLoading$);
@@ -274,14 +283,7 @@ const Result = ({
const itemContentRenderer = useCallback(
(_index: number, data: Member) => {
const handleSelect = () => {
onClickMember(data);
};
return (
<div onClick={handleSelect}>
<MemberItem member={data} />
</div>
);
return <MemberItem member={data} onSelect={onClickMember} />;
},
[onClickMember]
);
@@ -292,6 +294,10 @@ const Result = ({
memberSearchService.loadMore();
}, [memberSearchService]);
if (!searchText) {
return null;
}
if (!activeMembers || activeMembers.length === 0) {
if (isLoading) {
return <Loading />;
@@ -303,7 +309,13 @@ const Result = ({
);
}
return (
return activeMembers.length < 8 ? (
<div>
{activeMembers.map(member => (
<MemberItem key={member.id} member={member} onSelect={onClickMember} />
))}
</div>
) : (
<Virtuoso
components={{
Scroller,
@@ -370,7 +382,7 @@ const RoleSelector = ({
>
<div className={styles.planTagContainer}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
{hittingPaywall ? <PlanTag /> : null}
</div>
</MenuItem>
<MenuItem
@@ -379,7 +391,7 @@ const RoleSelector = ({
>
<div className={styles.planTagContainer}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
{hittingPaywall ? <PlanTag /> : null}
</div>
</MenuItem>
</>

View File

@@ -62,7 +62,3 @@ export const memberRoleStyle = style({
},
},
});
export const tooltipContentStyle = style({
wordBreak: 'break-word',
});

View File

@@ -1,11 +1,21 @@
import { Avatar, Tooltip } from '@affine/component';
import { Avatar } from '@affine/component';
import type { Member } from '@affine/core/modules/permissions';
import { useCallback } from 'react';
import * as styles from './member-item.css';
export const MemberItem = ({ member }: { member: Member }) => {
export const MemberItem = ({
member,
onSelect,
}: {
member: Member;
onSelect: (item: Member) => void;
}) => {
const handleSelect = useCallback(() => {
onSelect(member);
}, [member, onSelect]);
return (
<div className={styles.memberItemStyle}>
<div className={styles.memberItemStyle} onClick={handleSelect}>
<div className={styles.memberContainerStyle}>
<Avatar
key={member.id}
@@ -14,24 +24,8 @@ export const MemberItem = ({ member }: { member: Member }) => {
size={36}
/>
<div className={styles.memberInfoStyle}>
<Tooltip
content={member.name}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberNameStyle}>{member.name}</div>
</Tooltip>
<Tooltip
content={member.email}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberEmailStyle}>{member.email}</div>
</Tooltip>
<div className={styles.memberNameStyle}>{member.name}</div>
<div className={styles.memberEmailStyle}>{member.email}</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,15 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const inputStyle = style({
marginTop: '6px',
padding: '4px',
gap: '4px',
borderRadius: '4px',
height: '30px',
});
export const iconStyle = style({
fontSize: '20px',
color: cssVarV2('icon/primary'),
});

View File

@@ -6,6 +6,7 @@ import {
MenuTrigger,
notify,
Tooltip,
useConfirmModal,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
@@ -14,12 +15,13 @@ import {
DocGrantedUsersService,
type GrantedUser,
GuardService,
WorkspacePermissionService,
} from '@affine/core/modules/permissions';
import { DocRole, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { PlanTag } from '../plan-tag';
import * as styles from './member-item.css';
@@ -130,65 +132,86 @@ const Options = ({
const docGrantedUsersService = useService(DocGrantedUsersService);
const docService = useService(DocService);
const guardService = useService(GuardService);
const canTransferOwner = useLiveData(
guardService.can$('Doc_TransferOwner', docService.doc.id)
const workspacePermissionService = useService(WorkspacePermissionService);
const isWorkspaceOwner = useLiveData(
workspacePermissionService.permission.isOwner$
);
const { openConfirmModal } = useConfirmModal();
const canTransferOwner =
useLiveData(guardService.can$('Doc_TransferOwner', docService.doc.id)) &&
!!isWorkspaceOwner;
const canManageUsers = useLiveData(
guardService.can$('Doc_Users_Manage', docService.doc.id)
);
const updateUserRole = useCallback(
async (userId: string, role: DocRole) => {
try {
const res = await docGrantedUsersService.updateUserRole(userId, role);
if (res) {
notify.success({
title:
t['com.affine.share-menu.member-management.update-success'](),
});
} else {
notify.error({
title: t['com.affine.share-menu.member-management.update-fail'](),
});
}
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
},
[docGrantedUsersService, t]
);
const changeToManager = useAsyncCallback(async () => {
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Manager);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, userId, t]);
await updateUserRole(userId, DocRole.Manager);
}, [updateUserRole, userId]);
const changeToEditor = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal();
return;
}
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Editor);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
await updateUserRole(userId, DocRole.Editor);
}, [hittingPaywall, updateUserRole, userId, openPaywallModal]);
const changeToReader = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal();
return;
}
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Reader);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
await updateUserRole(userId, DocRole.Reader);
}, [hittingPaywall, updateUserRole, userId, openPaywallModal]);
const changeToOwner = useAsyncCallback(async () => {
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Owner);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, userId, t]);
await updateUserRole(userId, DocRole.Owner);
}, [updateUserRole, userId]);
const openTransferOwnerModal = useCallback(() => {
openConfirmModal({
title:
t[
'com.affine.share-menu.member-management.set-as-owner.confirm.title'
](),
description:
t[
'com.affine.share-menu.member-management.set-as-owner.confirm.description'
](),
onConfirm: changeToOwner,
confirmText: t['Confirm'](),
confirmButtonOptions: {
variant: 'primary',
},
cancelText: t['Cancel'](),
});
}, [changeToOwner, openConfirmModal, t]);
const removeMember = useAsyncCallback(async () => {
try {
@@ -213,16 +236,16 @@ const Options = ({
label: t['com.affine.share-menu.option.permission.can-edit'](),
onClick: changeToEditor,
role: DocRole.Editor,
showPlanTag: true,
showPlanTag: hittingPaywall,
},
{
label: t['com.affine.share-menu.option.permission.can-read'](),
onClick: changeToReader,
role: DocRole.Reader,
showPlanTag: true,
showPlanTag: hittingPaywall,
},
];
}, [changeToEditor, changeToManager, changeToReader, t]);
}, [changeToEditor, changeToManager, changeToReader, hittingPaywall, t]);
return (
<>
@@ -238,7 +261,7 @@ const Options = ({
</div>
</MenuItem>
))}
<MenuItem onSelect={changeToOwner} disabled={!canTransferOwner}>
<MenuItem onSelect={openTransferOwnerModal} disabled={!canTransferOwner}>
{t['com.affine.share-menu.member-management.set-as-owner']()}
</MenuItem>
<MenuSeparator />

View File

@@ -16,10 +16,11 @@ export const headerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
cursor: 'pointer',
gap: '4px',
padding: '4px 4px 6px',
padding: '0px 4px 6px',
height: '28px',
color: cssVarV2('text/secondary'),
});
export const iconStyle = style({
@@ -46,7 +47,9 @@ export const memberListStyle = style({
flexDirection: 'column',
flex: 1,
paddingTop: '6px',
maxHeight: '455px',
height: '100%',
minHeight: '206px',
maxHeight: '394px',
});
export const scrollableRootStyle = style({

View File

@@ -111,14 +111,27 @@ const MemberList = ({
[hittingPaywall, openPaywallModal]
);
return (
<Virtuoso
components={{
Scroller,
}}
data={grantedUserList}
itemContent={itemContentRenderer}
totalCount={grantedUserCount}
endReached={loadMore}
/>
<div className={styles.memberListStyle}>
{grantedUserList.length < 8 ? (
grantedUserList.map(item => (
<MemberItem
key={item.user.id}
grantedUser={item}
openPaywallModal={openPaywallModal}
hittingPaywall={hittingPaywall}
/>
))
) : (
<Virtuoso
components={{
Scroller,
}}
data={grantedUserList}
itemContent={itemContentRenderer}
totalCount={grantedUserCount}
endReached={loadMore}
/>
)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
export const result = style({
minHeight: '200px',
minHeight: '342px',
maxHeight: '342px',
});

View File

@@ -149,12 +149,13 @@ export const ShareMenuContent = (props: ShareMenuProps) => {
value={currentTab}
onValueChange={onValueChange}
>
<Tabs.List>
<Tabs.Trigger value={ShareMenuTab.Share}>
<Tabs.List className={styles.tabList}>
<Tabs.Trigger value={ShareMenuTab.Share} className={styles.tab}>
{t['com.affine.share-menu.shareButton']()}
</Tabs.Trigger>
<Tabs.Trigger
value={ShareMenuTab.Export}
className={styles.tab}
style={{
display: BUILD_CONFIG.isMobileEdition ? 'none' : undefined,
}}