mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adaee0ef5f | |||
| baf1aad412 | |||
| 231956fd39 | |||
| 73c7815a6d | |||
| 6850871bfb | |||
| 18cb4199fa | |||
| 24c382d3aa | |||
| 8bea31698e | |||
| 94d5a42355 |
@@ -216,6 +216,8 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
const { borderRadius } = edgeless.style;
|
||||
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
|
||||
|
||||
const { tool } = this.gfx;
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const height = bound.h / scale;
|
||||
|
||||
@@ -280,7 +282,9 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
.editing=${this._editing}
|
||||
></edgeless-note-mask>
|
||||
|
||||
${isCollapsable && (!this.model.isPageBlock() || !hasHeader)
|
||||
${isCollapsable &&
|
||||
tool.currentToolName$.value !== 'frameNavigator' &&
|
||||
(!this.model.isPageBlock() || !hasHeader)
|
||||
? html`<div
|
||||
class="${classMap({
|
||||
[styles.collapseButton]: true,
|
||||
|
||||
@@ -94,6 +94,7 @@ export function Recording() {
|
||||
let id: number | undefined;
|
||||
try {
|
||||
const result = await apis?.recording?.getCurrentRecording();
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -471,11 +471,11 @@ export function setupRecordingFeature() {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
}
|
||||
// reset all states
|
||||
recordingStatus$.next(null);
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
// reset all states
|
||||
recordingStatus$.next(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('failed to setup recording feature', error);
|
||||
@@ -499,10 +499,6 @@ function normalizeAppGroupInfo(
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
if (!shareableContent) {
|
||||
return null; // likely called on unsupported platform
|
||||
}
|
||||
|
||||
return recordingStateMachine.dispatch({
|
||||
type: 'NEW_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
|
||||
@@ -64,6 +64,10 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
|
||||
}
|
||||
if (nativeIcon) {
|
||||
nativeIcon = nativeIcon.resize({ width: 20, height: 20 });
|
||||
// string icon should be template image
|
||||
if (typeof icon === 'string') {
|
||||
nativeIcon.setTemplateImage(true);
|
||||
}
|
||||
}
|
||||
const submenuConfig = submenu ? buildMenuConfig(submenu) : undefined;
|
||||
menuConfig.push({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Sortable
|
||||
|
||||
Migrated from https://github.com/clauderic/dnd-kit
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ElementDragType } from '@atlaskit/pragmatic-drag-and-drop/types';
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { rectSortingStrategy } from './strategies';
|
||||
import type {
|
||||
ClientRect,
|
||||
Disabled,
|
||||
SortingStrategy,
|
||||
UniqueIdentifier,
|
||||
} from './types';
|
||||
import {
|
||||
getSortedRects,
|
||||
itemsEqual,
|
||||
normalizeDisabled,
|
||||
useUniqueId,
|
||||
} from './utilities';
|
||||
|
||||
export interface Props {
|
||||
children: React.ReactNode;
|
||||
items: (UniqueIdentifier | { id: UniqueIdentifier })[];
|
||||
strategy?: SortingStrategy;
|
||||
disabled?: boolean | Disabled;
|
||||
}
|
||||
|
||||
const ID_PREFIX = 'Sortable';
|
||||
|
||||
interface ContextDescriptor {
|
||||
activeIndex: number;
|
||||
containerId: string;
|
||||
disableTransforms: boolean;
|
||||
items: {
|
||||
id: UniqueIdentifier;
|
||||
}[];
|
||||
overIndex: number;
|
||||
sortedRects: ClientRect[];
|
||||
strategy: SortingStrategy;
|
||||
disabled: Disabled;
|
||||
}
|
||||
|
||||
export const Context = React.createContext<ContextDescriptor>({
|
||||
activeIndex: -1,
|
||||
containerId: ID_PREFIX,
|
||||
disableTransforms: false,
|
||||
items: [],
|
||||
overIndex: -1,
|
||||
sortedRects: [],
|
||||
strategy: rectSortingStrategy,
|
||||
disabled: {
|
||||
draggable: false,
|
||||
droppable: false,
|
||||
},
|
||||
});
|
||||
|
||||
export function SortableContext({
|
||||
children,
|
||||
items: userDefinedItems,
|
||||
strategy = rectSortingStrategy,
|
||||
disabled: disabledProp = false,
|
||||
}: Props) {
|
||||
const [active, setActive] = useState<ElementDragType | null>(null);
|
||||
|
||||
const { active, droppableRects, over, measureDroppableContainers } =
|
||||
useDndContext();
|
||||
const containerId = useUniqueId(ID_PREFIX, id);
|
||||
const items = useMemo<UniqueIdentifier[]>(
|
||||
() =>
|
||||
userDefinedItems.map(item =>
|
||||
typeof item === 'object' && 'id' in item ? item.id : item
|
||||
),
|
||||
[userDefinedItems]
|
||||
);
|
||||
const isDragging = active != null;
|
||||
const activeIndex = active ? items.indexOf(active.id) : -1;
|
||||
const overIndex = over ? items.indexOf(over.id) : -1;
|
||||
const previousItemsRef = useRef(items);
|
||||
const itemsHaveChanged = !itemsEqual(items, previousItemsRef.current);
|
||||
const disableTransforms =
|
||||
(overIndex !== -1 && activeIndex === -1) || itemsHaveChanged;
|
||||
const disabled = normalizeDisabled(disabledProp);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemsHaveChanged && isDragging) {
|
||||
measureDroppableContainers(items);
|
||||
}
|
||||
}, [itemsHaveChanged, items, isDragging, measureDroppableContainers]);
|
||||
|
||||
useEffect(() => {
|
||||
previousItemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
(): ContextDescriptor => ({
|
||||
activeIndex,
|
||||
containerId,
|
||||
disabled,
|
||||
disableTransforms,
|
||||
items,
|
||||
overIndex,
|
||||
sortedRects: getSortedRects(items, droppableRects),
|
||||
strategy,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
activeIndex,
|
||||
containerId,
|
||||
disabled.draggable,
|
||||
disabled.droppable,
|
||||
disableTransforms,
|
||||
items,
|
||||
overIndex,
|
||||
droppableRects,
|
||||
strategy,
|
||||
]
|
||||
);
|
||||
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SortingStrategy } from './types';
|
||||
import { arrayMove } from './utilities';
|
||||
|
||||
export const rectSortingStrategy: SortingStrategy = ({
|
||||
rects,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
index,
|
||||
}) => {
|
||||
const newRects = arrayMove(rects, overIndex, activeIndex);
|
||||
|
||||
const oldRect = rects[index];
|
||||
const newRect = newRects[index];
|
||||
|
||||
if (!newRect || !oldRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: newRect.left - oldRect.left,
|
||||
y: newRect.top - oldRect.top,
|
||||
scaleX: newRect.width / oldRect.width,
|
||||
scaleY: newRect.height / oldRect.height,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Transform = {
|
||||
x: number;
|
||||
y: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
|
||||
export interface ClientRect {
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export type SortingStrategy = (args: {
|
||||
activeNodeRect: ClientRect | null;
|
||||
activeIndex: number;
|
||||
index: number;
|
||||
rects: ClientRect[];
|
||||
overIndex: number;
|
||||
}) => Transform | null;
|
||||
|
||||
export type UniqueIdentifier = string | number;
|
||||
|
||||
export type RectMap = Map<UniqueIdentifier, ClientRect>;
|
||||
|
||||
export interface Disabled {
|
||||
draggable?: boolean;
|
||||
droppable?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { ClientRect, Disabled, RectMap, UniqueIdentifier } from './types';
|
||||
|
||||
let ids: Record<string, number> = {};
|
||||
|
||||
export function useUniqueId(prefix: string, value?: string) {
|
||||
return useMemo(() => {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const id = ids[prefix] == null ? 0 : ids[prefix] + 1;
|
||||
ids[prefix] = id;
|
||||
|
||||
return `${prefix}-${id}`;
|
||||
}, [prefix, value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an array item to a different position. Returns a new array with the item moved to the new position.
|
||||
*/
|
||||
export function arrayMove<T>(array: T[], from: number, to: number): T[] {
|
||||
const newArray = array.slice();
|
||||
newArray.splice(
|
||||
to < 0 ? newArray.length + to : to,
|
||||
0,
|
||||
newArray.splice(from, 1)[0]
|
||||
);
|
||||
|
||||
return newArray;
|
||||
}
|
||||
|
||||
export function getSortedRects(items: UniqueIdentifier[], rects: RectMap) {
|
||||
return items.reduce<ClientRect[]>(
|
||||
(accumulator, id, index) => {
|
||||
const rect = rects.get(id);
|
||||
|
||||
if (rect) {
|
||||
accumulator[index] = rect;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
Array.from({ length: items.length })
|
||||
);
|
||||
}
|
||||
|
||||
export function itemsEqual(a: UniqueIdentifier[], b: UniqueIdentifier[]) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeDisabled(disabled: boolean | Disabled): Disabled {
|
||||
if (typeof disabled === 'boolean') {
|
||||
return {
|
||||
draggable: disabled,
|
||||
droppable: disabled,
|
||||
};
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const containerScrollViewport = style({
|
||||
maxHeight: '272px',
|
||||
@@ -14,10 +14,39 @@ export const itemList = style({
|
||||
});
|
||||
|
||||
export const listEmpty = style({
|
||||
color: cssVarV2('text/placeholder'),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
height: '184px',
|
||||
padding: '16px 45px',
|
||||
});
|
||||
|
||||
export const listEmptyIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
marginBottom: '14px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const listEmptyTitle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
padding: '4px 2px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const listEmptyDescription = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const error = style({
|
||||
@@ -36,12 +65,37 @@ export const itemContainer = style({
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
[`&:hover:not([data-disabled="true"])`]: {
|
||||
[`&:hover:not([data-disabled="true"],:has(button:hover))`]: {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const itemSkeletonContainer = style({
|
||||
opacity: 0,
|
||||
animation: `${keyframes({
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
})} 500ms ease forwards 1s`,
|
||||
});
|
||||
|
||||
export const itemDeleteButton = style({
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
bottom: '8px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: cssVarV2('button/iconButtonSolid'),
|
||||
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${itemContainer}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const itemMain = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -62,29 +116,10 @@ export const itemNotSupported = style({
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const itemDeleteButton = style({
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
bottom: '8px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: cssVarV2('button/iconButtonSolid'),
|
||||
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${itemContainer}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const itemNameLabel = style({
|
||||
fontWeight: 'bold',
|
||||
fontWeight: '500',
|
||||
color: cssVarV2('text/primary'),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
display: 'inline',
|
||||
verticalAlign: 'top',
|
||||
selectors: {
|
||||
[`&[data-inactived="true"]`]: {
|
||||
@@ -95,4 +130,11 @@ export const itemNameLabel = style({
|
||||
|
||||
export const itemActionButton = style({
|
||||
width: 'fit-content',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
|
||||
export const itemNameLabelIcon = style({
|
||||
verticalAlign: 'top',
|
||||
marginRight: '4px',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NotificationType,
|
||||
} from '@affine/core/modules/notification';
|
||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||
import { extractEmojiIcon } from '@affine/core/utils';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import type {
|
||||
InvitationAcceptedNotificationBodyType,
|
||||
@@ -24,15 +25,22 @@ import type {
|
||||
MentionNotificationBodyType,
|
||||
} from '@affine/graphql';
|
||||
import { i18nTime, Trans, useI18n } from '@affine/i18n';
|
||||
import { CollaborationIcon, DeleteIcon } from '@blocksuite/icons/rc';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
CollaborationIcon,
|
||||
DeleteIcon,
|
||||
EdgelessIcon,
|
||||
NotificationIcon,
|
||||
PageIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import * as styles from './list.style.css';
|
||||
|
||||
export const NotificationList = () => {
|
||||
const t = useI18n();
|
||||
const notificationListService = useService(NotificationListService);
|
||||
const notifications = useLiveData(notificationListService.notifications$);
|
||||
const isLoading = useLiveData(notificationListService.isLoading$);
|
||||
@@ -84,9 +92,7 @@ export const NotificationList = () => {
|
||||
) : userFriendlyError ? (
|
||||
<div className={styles.error}>{userFriendlyError.message}</div>
|
||||
) : (
|
||||
<div className={styles.listEmpty}>
|
||||
{t['com.affine.notification.empty']()}
|
||||
</div>
|
||||
<NotificationListEmpty />
|
||||
)}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
@@ -94,10 +100,31 @@ export const NotificationList = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationListEmpty = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.listEmpty}>
|
||||
<div className={styles.listEmptyIconContainer}>
|
||||
<NotificationIcon width={24} height={24} />
|
||||
</div>
|
||||
<div className={styles.listEmptyTitle}>
|
||||
{t['com.affine.notification.empty']()}
|
||||
</div>
|
||||
<div className={styles.listEmptyDescription}>
|
||||
{t['com.affine.notification.empty.description']()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationItemSkeleton = () => {
|
||||
return Array.from({ length: 3 }).map((_, i) => (
|
||||
// oxlint-disable-next-line no-array-index-key
|
||||
<div key={i} className={styles.itemContainer} data-disabled="true">
|
||||
<div
|
||||
// oxlint-disable-next-line no-array-index-key
|
||||
key={i}
|
||||
className={clsx(styles.itemContainer, styles.itemSkeletonContainer)}
|
||||
data-disabled="true"
|
||||
>
|
||||
<Skeleton variant="circular" width={22} height={22} />
|
||||
<div className={styles.itemMain}>
|
||||
<Skeleton variant="text" width={150} />
|
||||
@@ -150,6 +177,10 @@ const MentionNotificationItem = ({
|
||||
const memberInactived = !body.createdByUser;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'read',
|
||||
});
|
||||
if (!body.workspace?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -163,7 +194,7 @@ const MentionNotificationItem = ({
|
||||
body.doc.blockId ? [body.doc.blockId] : undefined,
|
||||
body.doc.elementId ? [body.doc.elementId] : undefined
|
||||
);
|
||||
}, [body, jumpToPageBlock, notificationListService, notification.id]);
|
||||
}, [body, jumpToPageBlock, notificationListService, notification]);
|
||||
|
||||
return (
|
||||
<div className={styles.itemContainer} onClick={handleClick}>
|
||||
@@ -183,7 +214,7 @@ const MentionNotificationItem = ({
|
||||
data-inactived={memberInactived}
|
||||
/>
|
||||
),
|
||||
2: <b className={styles.itemNameLabel} />,
|
||||
2: <DocNameWithIcon mode={body.doc.mode} />,
|
||||
}}
|
||||
values={{
|
||||
username:
|
||||
@@ -215,6 +246,10 @@ const InvitationReviewRequestNotificationItem = ({
|
||||
const memberInactived = !body.createdByUser;
|
||||
const workspaceInactived = !body.workspace;
|
||||
const handleClick = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'read',
|
||||
});
|
||||
notificationListService.readNotification(notification.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -327,6 +362,11 @@ const InvitationReviewApprovedNotificationItem = ({
|
||||
const workspaceInactived = !body.workspace;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'button',
|
||||
button: 'open',
|
||||
});
|
||||
notificationListService.readNotification(notification.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -398,6 +438,10 @@ const InvitationAcceptedNotificationItem = ({
|
||||
const memberInactived = !body.createdByUser;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'read',
|
||||
});
|
||||
notificationListService.readNotification(notification.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -419,12 +463,7 @@ const InvitationAcceptedNotificationItem = ({
|
||||
<Trans
|
||||
i18nKey={'com.affine.notification.invitation-accepted'}
|
||||
components={{
|
||||
1: (
|
||||
<b
|
||||
className={styles.itemNameLabel}
|
||||
data-inactived={memberInactived}
|
||||
/>
|
||||
),
|
||||
1: <WorkspaceNameWithIcon data-inactived={memberInactived} />,
|
||||
}}
|
||||
values={{
|
||||
username:
|
||||
@@ -455,6 +494,10 @@ const InvitationBlockedNotificationItem = ({
|
||||
const workspaceInactived = !body.workspace;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'read',
|
||||
});
|
||||
notificationListService.readNotification(notification.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -523,6 +566,11 @@ const InvitationNotificationItem = ({
|
||||
}, [body, jumpToPage, notification.id, notificationListService]);
|
||||
|
||||
const handleAcceptInvite = useCallback(() => {
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'button',
|
||||
button: 'accept',
|
||||
});
|
||||
setIsAccepting(true);
|
||||
invitationService
|
||||
.acceptInvite(inviteId)
|
||||
@@ -633,12 +681,17 @@ const DeleteButton = ({
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // prevent trigger the click event of the parent element
|
||||
|
||||
track.$.sidebar.notifications.clickNotification({
|
||||
type: notification.type,
|
||||
item: 'dismiss',
|
||||
});
|
||||
|
||||
notificationListService.readNotification(notification.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
onClick?.();
|
||||
},
|
||||
[notificationListService, notification.id, onClick]
|
||||
[notificationListService, notification, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -657,8 +710,50 @@ const WorkspaceNameWithIcon = ({
|
||||
}: React.PropsWithChildren<React.HTMLAttributes<HTMLSpanElement>>) => {
|
||||
return (
|
||||
<b className={styles.itemNameLabel} {...props}>
|
||||
<CollaborationIcon width={20} height={20} />
|
||||
<CollaborationIcon
|
||||
className={styles.itemNameLabelIcon}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
{children}
|
||||
</b>
|
||||
);
|
||||
};
|
||||
|
||||
const DocNameWithIcon = ({
|
||||
children,
|
||||
mode,
|
||||
...props
|
||||
}: React.PropsWithChildren<
|
||||
React.HTMLAttributes<HTMLSpanElement> & { mode: 'page' | 'edgeless' }
|
||||
>) => {
|
||||
const { emoji, rest: titleWithoutEmoji } = useMemo(() => {
|
||||
if (typeof children === 'string') {
|
||||
return extractEmojiIcon(children);
|
||||
}
|
||||
if (
|
||||
children instanceof Array &&
|
||||
children.length === 1 &&
|
||||
typeof children[0] === 'string'
|
||||
) {
|
||||
return extractEmojiIcon(children[0]);
|
||||
}
|
||||
return { rest: children, emoji: null };
|
||||
}, [children]);
|
||||
return (
|
||||
<b className={styles.itemNameLabel} {...props}>
|
||||
{emoji ? (
|
||||
<span className={styles.itemNameLabelIcon}>{emoji}</span>
|
||||
) : mode === 'page' ? (
|
||||
<PageIcon className={styles.itemNameLabelIcon} width={20} height={20} />
|
||||
) : (
|
||||
<EdgelessIcon
|
||||
className={styles.itemNameLabelIcon}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
{titleWithoutEmoji}
|
||||
</b>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Menu } from '@affine/component';
|
||||
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
|
||||
import { NotificationCountService } from '@affine/core/modules/notification';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { NotificationIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -28,9 +29,17 @@ export const NotificationButton = () => {
|
||||
|
||||
const [notificationListOpen, setNotificationListOpen] = useState(false);
|
||||
|
||||
const handleNotificationListOpenChange = useCallback((open: boolean) => {
|
||||
setNotificationListOpen(open);
|
||||
}, []);
|
||||
const handleNotificationListOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
track.$.sidebar.notifications.openInbox({
|
||||
unreadCount: notificationCountService.count$.value,
|
||||
});
|
||||
}
|
||||
setNotificationListOpen(open);
|
||||
},
|
||||
[notificationCountService.count$.value]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export const NotificationSettings = () => {
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.setting.notifications.header.title']()}
|
||||
subtitle={t['com.affine.setting.notifications.header.description']()}
|
||||
/>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.setting.notifications.email.title']()}
|
||||
|
||||
+42
-26
@@ -2,12 +2,12 @@ import { notify } from '@affine/component';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import type { Workspace } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { universalId } from '@affine/nbstore';
|
||||
import track from '@affine/track';
|
||||
import { ExportIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -18,14 +18,11 @@ interface ExportPanelProps {
|
||||
export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
const t = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isOnline = useSystemOnline();
|
||||
const desktopApi = useService(DesktopApiService);
|
||||
const isLocalWorkspace = workspace.flavour === 'local';
|
||||
|
||||
const [fullSyncing, setFullSyncing] = useState(false);
|
||||
const [fullSynced, setFullSynced] = useState(false);
|
||||
|
||||
const shouldWaitForFullSync = !isLocalWorkspace && isOnline && !fullSynced;
|
||||
const [fullSynced, setFullSynced] = useState(isLocalWorkspace);
|
||||
|
||||
const fullSync = useAsyncCallback(async () => {
|
||||
setFullSyncing(true);
|
||||
@@ -65,36 +62,55 @@ export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
}
|
||||
}, [desktopApi, saving, t, workspace]);
|
||||
|
||||
if (shouldWaitForFullSync) {
|
||||
if (fullSynced) {
|
||||
return (
|
||||
<SettingRow name={t['Export']()} desc={t['Full Sync Description']()}>
|
||||
<SettingRow
|
||||
name={t['Full Backup']()}
|
||||
desc={t['Full Backup Description']()}
|
||||
>
|
||||
<Button
|
||||
data-testid="export-affine-full-sync"
|
||||
onClick={fullSync}
|
||||
loading={fullSyncing}
|
||||
variant="primary"
|
||||
data-testid="export-affine-backup"
|
||||
onClick={onExport}
|
||||
disabled={saving}
|
||||
>
|
||||
{t['Full Sync']()}
|
||||
{t['Full Backup']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
const button =
|
||||
isLocalWorkspace || isOnline ? t['Export']() : t['Export(Offline)']();
|
||||
const desc =
|
||||
isLocalWorkspace || isOnline
|
||||
? t['Export Description']()
|
||||
: t['Export Description(Offline)']();
|
||||
|
||||
return (
|
||||
<SettingRow name={t['Export']()} desc={desc}>
|
||||
<Button
|
||||
data-testid="export-affine-backup"
|
||||
onClick={onExport}
|
||||
disabled={saving}
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['Full Backup']()}
|
||||
desc={
|
||||
fullSynced ? t['Full Backup Description']() : t['Full Backup Hint']()
|
||||
}
|
||||
>
|
||||
{button}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="export-affine-full-sync"
|
||||
onClick={fullSync}
|
||||
loading={fullSyncing}
|
||||
disabled={fullSyncing}
|
||||
prefix={<ExportIcon />}
|
||||
>
|
||||
{t['Full Backup']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['Quick Export']()}
|
||||
desc={t['Quick Export Description']()}
|
||||
>
|
||||
<Button
|
||||
data-testid="export-affine-backup"
|
||||
onClick={onExport}
|
||||
disabled={saving}
|
||||
>
|
||||
{t['Quick Export']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export const AFFINE_FLAGS = {
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-callout.description',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
enable_embed_iframe_block: {
|
||||
category: 'blocksuite',
|
||||
@@ -280,7 +280,7 @@ export const AFFINE_FLAGS = {
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.description',
|
||||
configurable: !isMobile && environment.isMacOs,
|
||||
configurable: !isMobile && environment.isMacOs && BUILD_CONFIG.isElectron,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
@@ -192,29 +192,25 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["Enable cloud hint"](): string;
|
||||
/**
|
||||
* `Export`
|
||||
* `Full Backup`
|
||||
*/
|
||||
Export(): string;
|
||||
["Full Backup"](): string;
|
||||
/**
|
||||
* `Export (Offline)`
|
||||
* `Export a complete workspace backup`
|
||||
*/
|
||||
["Export(Offline)"](): string;
|
||||
["Full Backup Description"](): string;
|
||||
/**
|
||||
* `Full Sync`
|
||||
* `Sync all cloud data and export a complete workspace backup`
|
||||
*/
|
||||
["Full Sync"](): string;
|
||||
["Full Backup Hint"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported.`
|
||||
* `Quick Export`
|
||||
*/
|
||||
["Export Description"](): string;
|
||||
["Quick Export"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported. But you are offline now which will cause the exported data not up to date.`
|
||||
* `Skip cloud synchronization and quickly export current data(some attachments or docs may be missing)`
|
||||
*/
|
||||
["Export Description(Offline)"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported. But you must sync all cloud data first to keep your exported data up to date.`
|
||||
*/
|
||||
["Full Sync Description"](): string;
|
||||
["Quick Export Description"](): string;
|
||||
/**
|
||||
* `Export failed`
|
||||
*/
|
||||
@@ -4667,6 +4663,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Notifications`
|
||||
*/
|
||||
["com.affine.setting.notifications.header.title"](): string;
|
||||
/**
|
||||
* `Choose the types of updates you want to receive and where to get them.`
|
||||
*/
|
||||
["com.affine.setting.notifications.header.description"](): string;
|
||||
/**
|
||||
* `Email notifications`
|
||||
*/
|
||||
@@ -7207,6 +7207,10 @@ export function useAFFiNEI18N(): {
|
||||
* `No new notifications`
|
||||
*/
|
||||
["com.affine.notification.empty"](): string;
|
||||
/**
|
||||
* `You'll be notified here for @mentions and workspace invites.`
|
||||
*/
|
||||
["com.affine.notification.empty.description"](): string;
|
||||
/**
|
||||
* `Open workspace`
|
||||
*/
|
||||
|
||||
@@ -38,12 +38,11 @@
|
||||
"Enable AFFiNE Cloud": "Enable AFFiNE Cloud",
|
||||
"Enable AFFiNE Cloud Description": "If enabled, the data in this workspace will be backed up and synchronised via AFFiNE Cloud.",
|
||||
"Enable cloud hint": "The following functions rely on AFFiNE Cloud. All data is stored on the current device. You can enable AFFiNE Cloud for this workspace to keep data in sync with the cloud.",
|
||||
"Export": "Export",
|
||||
"Export(Offline)": "Export (Offline)",
|
||||
"Full Sync": "Full Sync",
|
||||
"Export Description": "You can export the entire Workspace data for backup, and the exported data can be re-imported.",
|
||||
"Export Description(Offline)": "You can export the entire Workspace data for backup, and the exported data can be re-imported. But you are offline now which will cause the exported data not up to date.",
|
||||
"Full Sync Description": "You can export the entire Workspace data for backup, and the exported data can be re-imported. But you must sync all cloud data first to keep your exported data up to date.",
|
||||
"Full Backup": "Full Backup",
|
||||
"Full Backup Description": "Export a complete workspace backup",
|
||||
"Full Backup Hint": "Sync all cloud data and export a complete workspace backup",
|
||||
"Quick Export": "Quick Export",
|
||||
"Quick Export Description": "Skip cloud synchronization and quickly export current data(some attachments or docs may be missing)",
|
||||
"Export failed": "Export failed",
|
||||
"Export success": "Export success",
|
||||
"Export to HTML": "Export to HTML",
|
||||
@@ -1160,6 +1159,7 @@
|
||||
"com.affine.selector-tag.search.placeholder": "Search tags...",
|
||||
"com.affine.setting.notifications": "Notifications",
|
||||
"com.affine.setting.notifications.header.title": "Notifications",
|
||||
"com.affine.setting.notifications.header.description": "Choose the types of updates you want to receive and where to get them.",
|
||||
"com.affine.setting.notifications.email.title": "Email notifications",
|
||||
"com.affine.setting.notifications.email.mention.title": "Mention",
|
||||
"com.affine.setting.notifications.email.mention.subtitle": "You will be notified through email when other members of the workspace @ you.",
|
||||
@@ -1792,6 +1792,7 @@
|
||||
"com.affine.notification.unsupported": "Unsupported message",
|
||||
"com.affine.notification.mention": "<1>{{username}}</1> mentioned you in <2>{{docTitle}}</2>",
|
||||
"com.affine.notification.empty": "No new notifications",
|
||||
"com.affine.notification.empty.description": "You'll be notified here for @mentions and workspace invites.",
|
||||
"com.affine.notification.invitation-accepted": "<1>{{username}}</1> has accept your invitation",
|
||||
"com.affine.notification.invitation-review-request": "<1>{{username}}</1> has requested to join <2>{{workspaceName}}</2>",
|
||||
"com.affine.notification.invitation-review-declined": "<1>{{username}}</1> has declined your request to join <2>{{workspaceName}}</2>",
|
||||
|
||||
@@ -145,6 +145,10 @@ type AttachmentEvents =
|
||||
type TemplateEvents = 'openTemplateListMenu';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: notification
|
||||
type NotificationEvents = 'openInbox' | 'clickNotification';
|
||||
// END SECTION
|
||||
|
||||
type UserEvents =
|
||||
| GeneralEvents
|
||||
| AppEvents
|
||||
@@ -162,7 +166,9 @@ type UserEvents =
|
||||
| PaymentEvents
|
||||
| DNDEvents
|
||||
| AttachmentEvents
|
||||
| TemplateEvents;
|
||||
| TemplateEvents
|
||||
| NotificationEvents;
|
||||
|
||||
interface PageDivision {
|
||||
[page: string]: {
|
||||
[segment: string]: {
|
||||
@@ -346,6 +352,7 @@ const PageEvents = {
|
||||
sidebar: {
|
||||
newDoc: ['quickStart'],
|
||||
template: ['openTemplateListMenu', 'quickStart'],
|
||||
notifications: ['openInbox', 'clickNotification'],
|
||||
},
|
||||
splitViewIndicator: {
|
||||
$: ['splitViewAction', 'openInSplitView', 'openInPeekView'],
|
||||
@@ -543,6 +550,12 @@ export type EventArgs = {
|
||||
inviteUserDocRole: {
|
||||
control: 'member list';
|
||||
};
|
||||
openInbox: { unreadCount: number };
|
||||
clickNotification: {
|
||||
type: string;
|
||||
item: 'read' | 'button' | 'dismiss';
|
||||
button?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// for type checking
|
||||
|
||||
@@ -6,18 +6,17 @@ import {
|
||||
undoByKeyboard,
|
||||
} from '@affine-test/kit/utils/keyboard';
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import { type } from '@affine-test/kit/utils/page-logic';
|
||||
import {
|
||||
clickNewPageButton,
|
||||
type,
|
||||
waitForEmptyEditor,
|
||||
} from '@affine-test/kit/utils/page-logic';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openHomePage(page);
|
||||
await page.getByTestId('settings-modal-trigger').click();
|
||||
await page.getByText('Experimental features').click();
|
||||
await page.getByText('I am aware of the risks, and').click();
|
||||
await page.getByTestId('experimental-confirm-button').click();
|
||||
await page.getByTestId('enable_callout').locator('span').click();
|
||||
await page.getByTestId('modal-close-button').click();
|
||||
await page.getByTestId('sidebar-new-page-button').click();
|
||||
await clickNewPageButton(page);
|
||||
await waitForEmptyEditor(page);
|
||||
await page.locator('affine-paragraph v-line div').click();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
edgelessCommonSetup,
|
||||
enterPresentationMode,
|
||||
locatorPresentationToolbarButton,
|
||||
resizeElementByHandle,
|
||||
selectNoteInEdgeless,
|
||||
setEdgelessTool,
|
||||
Shape,
|
||||
switchEditorMode,
|
||||
toggleFramePanel,
|
||||
} from '../utils/actions/edgeless.js';
|
||||
import {
|
||||
@@ -19,7 +22,11 @@ import {
|
||||
pressEscape,
|
||||
selectAllBlocksByKeyboard,
|
||||
} from '../utils/actions/keyboard.js';
|
||||
import { waitNextFrame } from '../utils/actions/misc.js';
|
||||
import {
|
||||
enterPlaygroundRoom,
|
||||
initEmptyEdgelessState,
|
||||
waitNextFrame,
|
||||
} from '../utils/actions/misc.js';
|
||||
import { test } from '../utils/playwright.js';
|
||||
|
||||
test.describe('presentation', () => {
|
||||
@@ -246,4 +253,22 @@ test.describe('presentation', () => {
|
||||
await expect(frameItems.nth(6)).toHaveText('Frame 3');
|
||||
await expect(frameItems.nth(7)).toHaveText('Frame 4');
|
||||
});
|
||||
|
||||
test('note should hide the collapse button when enter presentation mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterPlaygroundRoom(page);
|
||||
const { noteId } = await initEmptyEdgelessState(page);
|
||||
await switchEditorMode(page);
|
||||
|
||||
await selectNoteInEdgeless(page, noteId);
|
||||
await resizeElementByHandle(page, { x: 0, y: 70 }, 'bottom-right');
|
||||
|
||||
await createFrame(page, [100, 100], [100, 200]);
|
||||
|
||||
await enterPresentationMode(page);
|
||||
|
||||
const collapseButton = page.getByTestId('edgeless-note-collapse-button');
|
||||
await expect(collapseButton).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,9 @@ export function getAllPage(page: Page) {
|
||||
|
||||
async function clickNewPageButton() {
|
||||
const newPageButton = page.getByTestId('new-page-button-trigger');
|
||||
return await newPageButton.click();
|
||||
return await newPageButton.click({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function clickNewEdgelessDropdown() {
|
||||
|
||||
Reference in New Issue
Block a user