fix: global comments

This commit is contained in:
Kilu 2024-07-23 14:15:52 +08:00
parent 9d3a810847
commit 7280d072f1
69 changed files with 2181 additions and 106 deletions

View File

@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components" "coverage": "pnpm run test:unit && pnpm run test:components"
}, },
"dependencies": { "dependencies": {
"@appflowyinc/client-api-wasm": "0.1.3", "@appflowyinc/client-api-wasm": "0.1.4",
"@atlaskit/primitives": "^5.5.3", "@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2", "@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",

View File

@ -1,9 +1,13 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies: dependencies:
'@appflowyinc/client-api-wasm': '@appflowyinc/client-api-wasm':
specifier: 0.1.3 specifier: 0.1.4
version: 0.1.3 version: 0.1.4
'@atlaskit/primitives': '@atlaskit/primitives':
specifier: ^5.5.3 specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0) version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@ -447,8 +451,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5 '@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.1.3: /@appflowyinc/client-api-wasm@0.1.4:
resolution: {integrity: sha512-M603RIBocCjDlwDx5O53j4tH2M/y6uKZSdpnBq3nCMBPwTGEhTFKBDD3tMmjSIHo8nnGx1t8gsKei55LlhtoNQ==} resolution: {integrity: sha512-3uBpy3n+aIG0fapPAroMfL8JLdAPtqPAkpV+LOxlRnMW4Au2JQcW8TW0P3K1YAe16tDZ62ZIZPoG6Bi40RDRoQ==}
dev: false dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@ -11662,7 +11666,3 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
dev: true dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@ -0,0 +1,22 @@
export interface CommentUser {
uuid: string;
name: string;
avatarUrl: string | null;
}
export interface GlobalComment {
commentId: string;
user: CommentUser | null;
content: string;
createdAt: string;
lastUpdatedAt: string;
replyCommentId: string | null;
isDeleted: boolean;
canDeleted: boolean;
}
export interface Reaction {
reactionType: string;
reactUsers: CommentUser[];
commentId: string;
}

View File

@ -1,4 +1,5 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { import {
deleteView, deleteView,
getPublishView, getPublishView,
@ -14,10 +15,16 @@ import {
signInGithub, signInGithub,
signInDiscord, signInDiscord,
signInWithUrl, signInWithUrl,
createGlobalCommentOnPublishView,
deleteGlobalCommentOnPublishView,
getPublishViewComments,
getWorkspaces, getWorkspaces,
getWorkspaceFolder, getWorkspaceFolder,
getCurrentUser, getCurrentUser,
duplicatePublishView, duplicatePublishView,
getReactions,
addReaction,
removeReaction,
} from '@/application/services/js-services/wasm/client_api'; } from '@/application/services/js-services/wasm/client_api';
import { AFService, AFServiceConfig } from '@/application/services/services.type'; import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { emit, EventType } from '@/application/session'; import { emit, EventType } from '@/application/session';
@ -225,6 +232,7 @@ export class AFClientService implements AFService {
email: data.email, email: data.email,
name: data.name, name: data.name,
avatar: data.icon_url, avatar: data.icon_url,
uuid: data.uuid,
}; };
} }
@ -236,4 +244,28 @@ export class AFClientService implements AFService {
published_collab_type: params.collabType, published_collab_type: params.collabType,
}); });
} }
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
return createGlobalCommentOnPublishView(viewId, content, replyCommentId);
}
deleteCommentOnPublishView(viewId: string, commentId: string): Promise<void> {
return deleteGlobalCommentOnPublishView(viewId, commentId);
}
getPublishViewGlobalComments(viewId: string): Promise<GlobalComment[]> {
return getPublishViewComments(viewId);
}
getPublishViewReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
return getReactions(viewId, commentId);
}
addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return addReaction(viewId, commentId, reactionType);
}
removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
return removeReaction(viewId, commentId, reactionType);
}
} }

View File

@ -2,7 +2,8 @@ import { getToken, invalidToken, isTokenValid, refreshToken } from '@/applicatio
import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm'; import { ClientAPI, WorkspaceFolder, DuplicatePublishViewPayload } from '@appflowyinc/client-api-wasm';
import { AFCloudConfig } from '@/application/services/services.type'; import { AFCloudConfig } from '@/application/services/services.type';
import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type'; import { DatabaseId, PublishViewMetaData, RowId, ViewId, ViewLayout } from '@/application/collab.type';
import { FolderView, Workspace } from '@/application/types'; import { FolderView } from '@/application/types';
import { GlobalComment, Reaction } from '@/application/comment.type';
let client: ClientAPI; let client: ClientAPI;
@ -120,20 +121,13 @@ export async function signInDiscord(redirectTo: string) {
export async function getWorkspaces() { export async function getWorkspaces() {
try { try {
const { data } = await client.get_workspaces(); const { data } = await client.get_workspaces();
const res: Workspace[] = [];
for (const workspace of data) { return data.map((workspace) => ({
const members = await client.get_workspace_members(workspace.workspace_id); id: workspace.workspace_id,
name: workspace.workspace_name,
res.push({ icon: workspace.icon,
id: workspace.workspace_id, memberCount: workspace.member_count || 0,
name: workspace.workspace_name, }));
icon: workspace.icon,
memberCount: members.data.length,
});
}
return res;
} catch (e) { } catch (e) {
return Promise.reject(e); return Promise.reject(e);
} }
@ -171,3 +165,72 @@ export function getCurrentUser() {
export function duplicatePublishView(payload: DuplicatePublishViewPayload) { export function duplicatePublishView(payload: DuplicatePublishViewPayload) {
return client.duplicate_publish_view(payload); return client.duplicate_publish_view(payload);
} }
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
try {
const { comments } = await client.get_publish_view_comments(viewId);
return comments.map((comment) => {
return {
commentId: comment.comment_id,
user: {
uuid: comment.user?.uuid || '',
name: comment.user?.name || '',
avatarUrl: comment.user?.avatar_url || null,
},
content: comment.content,
createdAt: comment.created_at,
lastUpdatedAt: comment.last_updated_at,
replyCommentId: comment.reply_comment_id,
isDeleted: comment.is_deleted,
canDeleted: comment.can_be_deleted,
};
});
} catch (e) {
return Promise.reject(e);
}
}
export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) {
return client.create_comment_on_publish_view(viewId, content, replyCommentId);
}
export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) {
return client.delete_comment_on_publish_view(viewId, commentId);
}
export async function getReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
try {
const { reactions } = await client.get_reactions(viewId, commentId);
const reactionsMap: Record<string, Reaction[]> = {};
for (const reaction of reactions) {
if (!reactionsMap[reaction.comment_id]) {
reactionsMap[reaction.comment_id] = [];
}
reactionsMap[reaction.comment_id].push({
reactionType: reaction.reaction_type,
commentId: reaction.comment_id,
reactUsers: reaction.react_users.map((user) => ({
uuid: user.uuid,
name: user.name,
avatarUrl: user.avatar_url,
})),
});
}
return reactionsMap;
} catch (e) {
return Promise.reject(e);
}
}
export async function addReaction(viewId: string, commentId: string, reactionType: string) {
return client.create_reaction(viewId, commentId, reactionType);
}
export async function removeReaction(viewId: string, commentId: string, reactionType: string) {
return client.delete_reaction(viewId, commentId, reactionType);
}

View File

@ -1,4 +1,5 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { ViewMeta } from '@/application/db/tables/view_metas'; import { ViewMeta } from '@/application/db/tables/view_metas';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
@ -28,6 +29,12 @@ export interface PublishService {
rows: Y.Map<YDoc>; rows: Y.Map<YDoc>;
destroy: () => void; destroy: () => void;
}>; }>;
getPublishViewGlobalComments: (viewId: string) => Promise<GlobalComment[]>;
createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise<void>;
deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise<void>;
getPublishViewReactions: (viewId: string, commentId?: string) => Promise<Record<string, Reaction[]>>;
addPublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
removePublishViewReaction: (viewId: string, commentId: string, reactionType: string) => Promise<void>;
loginAuth: (url: string) => Promise<void>; loginAuth: (url: string) => Promise<void>;
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>; signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;

View File

@ -29,6 +29,7 @@ export interface User {
name: string | null; name: string | null;
uid: string; uid: string;
avatar: string | null; avatar: string | null;
uuid: string;
} }
export interface DuplicatePublishView { export interface DuplicatePublishView {

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10C6 9.17 6.67 8.5 7.5 8.5C8.33 8.5 9 9.17 9 10C9 10.83 8.33 11.5 7.5 11.5C6.67 11.5 6 10.83 6 10ZM11 18C13.33 18 15.31 16.54 16.11 14.5H5.89C6.69 16.54 8.67 18 11 18ZM14.5 11.5C15.33 11.5 16 10.83 16 10C16 9.17 15.33 8.5 14.5 8.5C13.67 8.5 13 9.17 13 10C13 10.83 13.67 11.5 14.5 11.5ZM21 1.5H19V3.5H17V5.5H19V7.5H21V5.5H23V3.5H21V1.5ZM19 12.5C19 16.92 15.42 20.5 11 20.5C6.58 20.5 3 16.92 3 12.5C3 8.08 6.58 4.5 11 4.5C12.46 4.5 13.82 4.9 15 5.58V3.34C13.77 2.8 12.42 2.5 10.99 2.5C5.47 2.5 1 6.98 1 12.5C1 18.02 5.47 22.5 10.99 22.5C16.52 22.5 21 18.02 21 12.5C21 11.45 20.83 10.45 20.53 9.5H18.4C18.78 10.43 19 11.44 19 12.5Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -0,0 +1,3 @@
<svg width="32" height="16" viewBox="0 0 32 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8774 3.10363H15.9L28 3.0908C28.2761 3.0908 28.5 3.31796 28.5 3.5941C28.5 3.87024 28.2761 4.0908 28 4.0908L15.9 4.10363C14.7716 4.10363 13.9554 4.10402 13.3135 4.15647C12.6774 4.20844 12.2566 4.30902 11.911 4.48511C11.2525 4.82066 10.717 5.3561 10.3815 6.01466C10.2054 6.36025 10.1048 6.78107 10.0528 7.41715C10.0004 8.05908 10 8.87527 10 10.0036V11.6036C10 11.8797 9.77614 12.1036 9.5 12.1036C9.22386 12.1036 9 11.8797 9 11.6036V10.0036V9.981C9 8.88004 9 8.02311 9.05616 7.33572C9.11318 6.63779 9.23058 6.07073 9.49047 5.56067C9.9219 4.71394 10.6103 4.02553 11.457 3.5941C11.9671 3.33421 12.5342 3.21681 13.2321 3.15979C13.9195 3.10363 14.7764 3.10363 15.8774 3.10363Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 15H13V17H11V15ZM11 7H13V13H11V7ZM11.99 2C6.47 2 2 6.48 2 12C2 17.52 6.47 22 11.99 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 11.99 2ZM12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8.5V4.5L3 11.5L10 18.5V14.4C15 14.4 18.5 16 21 19.5C20 14.5 17 9.5 10 8.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.59 9.17L5.41 4L4 5.41L9.17 10.58L10.59 9.17ZM14.5 4L16.54 6.04L4 18.59L5.41 20L17.96 7.46L20 9.5V4H14.5ZM14.83 13.41L13.42 14.82L16.55 17.95L14.5 20H20V14.5L17.96 16.54L14.83 13.41Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@ -0,0 +1,14 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<path d="M16.5 4.48535C14.0025 4.23785 11.49 4.11035 8.985 4.11035C7.5 4.11035 6.015 4.18535 4.53 4.33535L3 4.48535"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.125 3.7275L7.29 2.745C7.41 2.0325 7.5 1.5 8.7675 1.5H10.7325C12 1.5 12.0975 2.0625 12.21 2.7525L12.375 3.7275"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.8844 6.85449L14.3969 14.407C14.3144 15.5845 14.2469 16.4995 12.1544 16.4995H7.33937C5.24687 16.4995 5.17938 15.5845 5.09688 14.407L4.60938 6.85449"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.49609 12.375H10.9936" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
stroke-linejoin="round"/>
<path d="M7.875 9.375H11.625" stroke="currentColor" stroke-width="1.125" stroke-linecap="round"
stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,175 @@
import { MAX_FREQUENTLY_ROW_COUNT, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { loadEmojiData } from '@/utils/emoji';
import { EmojiMartData } from '@emoji-mart/data';
import { PopoverProps } from '@mui/material/Popover';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { FrequentlyUsed, getEmojiDataFromNative, init, Store } from 'emoji-mart';
import chunk from 'lodash-es/chunk';
import React, { useCallback, useEffect, useState } from 'react';
export interface EmojiCategory {
id: string;
emojis: Emoji[];
}
interface Emoji {
id: string;
name: string;
native: string;
}
export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
const [searchValue, setSearchValue] = useState('');
const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
const [skin, setSkin] = useState<number>(() => {
return Number(Store.get('skin')) || 0;
});
const onSkinChange = useCallback((val: number) => {
setSkin(val);
Store.set('skin', String(val));
}, []);
const searchEmojiData = useCallback(
async (searchVal?: string) => {
const emojiData = await loadEmojiData();
const { emojis, categories } = emojiData as EmojiMartData;
const filteredCategories = categories
.map((category) => {
const { id, emojis: categoryEmojis } = category;
return {
id,
emojis: categoryEmojis
.filter((emojiId) => {
const emoji = emojis[emojiId];
if (!searchVal) return true;
return filterSearchValue(emoji, searchVal);
})
.map((emojiId) => {
const emoji = emojis[emojiId];
const { name, skins } = emoji;
return {
id: emojiId,
name,
native: skins[skin] ? skins[skin].native : skins[0].native,
};
}),
};
})
.filter((category) => category.emojis.length > 0);
setEmojiCategories(filteredCategories);
},
[skin]
);
useEffect(() => {
void (async () => {
await init({
maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT,
perLine: PER_ROW_EMOJI_COUNT,
});
await searchEmojiData();
})();
}, [searchEmojiData]);
useEffect(() => {
void searchEmojiData(searchValue);
}, [searchEmojiData, searchValue]);
const onSelect = useCallback(
async (native: string) => {
onEmojiSelect(native);
if (!native) {
return;
}
try {
const data = await getEmojiDataFromNative(native);
FrequentlyUsed.add(data);
} catch (e) {
// do nothing
}
},
[onEmojiSelect]
);
return {
emojiCategories,
setSearchValue,
searchValue,
onSelect,
onSkinChange,
skin,
};
}
export function useSelectSkinPopoverProps(): PopoverProps & {
onOpen: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClose: () => void;
} {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | undefined>(undefined);
const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const onClose = useCallback(() => {
setAnchorEl(undefined);
}, []);
const open = Boolean(anchorEl);
const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin;
const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin;
return {
anchorEl,
onOpen,
onClose,
open,
anchorOrigin,
transformOrigin,
};
}
function filterSearchValue(
emoji: {
name: string;
keywords?: string[];
},
searchValue: string
) {
const { name, keywords } = emoji;
const searchValueLowerCase = searchValue.toLowerCase();
return (
name.toLowerCase().includes(searchValueLowerCase) ||
(keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase)))
);
}
export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) {
const rows: {
id: string;
type: 'category' | 'emojis';
emojis?: Emoji[];
}[] = [];
emojiCategories.forEach((category) => {
rows.push({
id: category.id,
type: 'category',
});
chunk(category.emojis, rowSize).forEach((chunk, index) => {
rows.push({
type: 'emojis',
emojis: chunk,
id: `${category.id}-${index}`,
});
});
});
return rows;
}

View File

@ -0,0 +1,37 @@
import React from 'react';
import { useLoadEmojiData } from './EmojiPicker.hooks';
import EmojiPickerHeader from './EmojiPickerHeader';
import EmojiPickerCategories from './EmojiPickerCategories';
interface Props {
onEmojiSelect: (emoji: string) => void;
onEscape?: () => void;
defaultEmoji?: string;
hideRemove?: boolean;
}
export function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) {
const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
return (
<div tabIndex={0} className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
<EmojiPickerHeader
onEmojiSelect={onSelect}
skin={skin}
hideRemove={props.hideRemove}
onSkinSelect={onSkinChange}
searchValue={searchValue}
onSearchChange={setSearchValue}
/>
<EmojiPickerCategories
defaultEmoji={defaultEmoji}
onEscape={onEscape}
onEmojiSelect={onSelect}
emojiCategories={emojiCategories}
/>
</div>
);
}
export default EmojiPicker;

View File

@ -0,0 +1,354 @@
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { AFScroller } from '@/components/_shared/scroller';
import { getDistanceEdge, inView } from '@/utils/position';
import { Tooltip } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { EmojiCategory, getRowsWithCategories } from './EmojiPicker.hooks';
function EmojiPickerCategories({
emojiCategories,
onEmojiSelect,
onEscape,
defaultEmoji,
}: {
emojiCategories: EmojiCategory[];
onEmojiSelect: (emoji: string) => void;
onEscape?: () => void;
defaultEmoji?: string;
}) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [selectCell, setSelectCell] = React.useState({
row: 1,
column: 0,
});
const rows = useMemo(() => {
return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
}, [emojiCategories]);
const mouseY = useRef<number | null>(null);
const mouseX = useRef<number | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
const getCategoryName = useCallback(
(id: string) => {
const i18nName: Record<string, string> = {
frequent: t('emoji.categories.frequentlyUsed'),
people: t('emoji.categories.people'),
nature: t('emoji.categories.nature'),
foods: t('emoji.categories.food'),
activity: t('emoji.categories.activities'),
places: t('emoji.categories.places'),
objects: t('emoji.categories.objects'),
symbols: t('emoji.categories.symbols'),
flags: t('emoji.categories.flags'),
};
return i18nName[id];
},
[t]
);
useEffect(() => {
scrollRef.current?.scrollTo({
top: 0,
});
setSelectCell({
row: 1,
column: 0,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const renderRow = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const item = rows[index];
return (
<div style={style} data-index={index}>
{item.type === 'category' ? (
<div className={'pt-2 text-xs font-medium text-text-caption'}>{getCategoryName(item.id)}</div>
) : null}
<div className={'flex'}>
{item.emojis?.map((emoji, columnIndex) => {
const isSelected = selectCell.row === index && selectCell.column === columnIndex;
const isDefaultEmoji = defaultEmoji === emoji.native;
return (
<Tooltip key={emoji.id} title={emoji.name} placement={'top'} enterDelay={500}>
<div
data-key={emoji.id}
style={{
width: EMOJI_SIZE,
height: EMOJI_SIZE,
}}
onClick={() => {
onEmojiSelect(emoji.native);
}}
onMouseMove={(e) => {
mouseY.current = e.clientY;
mouseX.current = e.clientX;
}}
onMouseEnter={(e) => {
if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) {
setSelectCell({
row: index,
column: columnIndex,
});
}
mouseX.current = e.clientX;
mouseY.current = e.clientY;
}}
className={`icon flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${
isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent'
} ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`}
>
{emoji.native}
</div>
</Tooltip>
);
})}
</div>
</div>
);
},
[defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row]
);
const getNewColumnIndex = useCallback(
(rowIndex: number, columnIndex: number): number => {
const row = rows[rowIndex];
const length = row.emojis?.length;
let newColumnIndex = columnIndex;
if (length && length <= columnIndex) {
newColumnIndex = length - 1 || 0;
}
return newColumnIndex;
},
[rows]
);
const findNextRow = useCallback(
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
const rowLength = rows.length;
let nextRowIndex = rowIndex + 1;
if (nextRowIndex >= rowLength - 1) {
nextRowIndex = rowLength - 1;
} else if (rows[nextRowIndex].type === 'category') {
nextRowIndex = findNextRow(nextRowIndex, columnIndex).row;
}
const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex);
return {
row: nextRowIndex,
column: newColumnIndex,
};
},
[getNewColumnIndex, rows]
);
const findPrevRow = useCallback(
(rowIndex: number, columnIndex: number): { row: number; column: number } => {
let prevRowIndex = rowIndex - 1;
if (prevRowIndex < 1) {
prevRowIndex = 1;
} else if (rows[prevRowIndex].type === 'category') {
prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row;
}
const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex);
return {
row: prevRowIndex,
column: newColumnIndex,
};
},
[getNewColumnIndex, rows]
);
const findPrevCell = useCallback(
(row: number, column: number): { row: number; column: number } => {
const prevColumn = column - 1;
if (prevColumn < 0) {
const prevRow = findPrevRow(row, column).row;
if (prevRow === row) return { row, column };
const length = rows[prevRow].emojis?.length || 0;
return {
row: prevRow,
column: length > 0 ? length - 1 : 0,
};
}
return {
row,
column: prevColumn,
};
},
[findPrevRow, rows]
);
const findNextCell = useCallback(
(row: number, column: number): { row: number; column: number } => {
const nextColumn = column + 1;
const rowLength = rows[row].emojis?.length || 0;
if (nextColumn >= rowLength) {
const nextRow = findNextRow(row, column).row;
if (nextRow === row) return { row, column };
return {
row: nextRow,
column: 0,
};
}
return {
row,
column: nextColumn,
};
},
[findNextRow, rows]
);
useEffect(() => {
if (!selectCell || !scrollRef.current) return;
const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id;
const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`);
if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) {
const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement);
scrollRef.current?.scrollTo({
top: scrollRef.current?.scrollTop + distance,
});
}
}, [selectCell, rows]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
e.stopPropagation();
switch (e.key) {
case 'Escape':
e.preventDefault();
onEscape?.();
break;
case 'ArrowUp': {
e.preventDefault();
setSelectCell(findPrevRow(selectCell.row, selectCell.column));
break;
}
case 'ArrowDown': {
e.preventDefault();
setSelectCell(findNextRow(selectCell.row, selectCell.column));
break;
}
case 'ArrowLeft': {
e.preventDefault();
const prevCell = findPrevCell(selectCell.row, selectCell.column);
setSelectCell(prevCell);
break;
}
case 'ArrowRight': {
e.preventDefault();
const nextCell = findNextCell(selectCell.row, selectCell.column);
setSelectCell(nextCell);
break;
}
case 'Enter': {
e.preventDefault();
const currentRow = rows[selectCell.row];
const emoji = currentRow.emojis?.[selectCell.column];
if (emoji) {
onEmojiSelect(emoji.native);
}
break;
}
default:
break;
}
},
[
findNextCell,
findPrevCell,
findPrevRow,
findNextRow,
onEmojiSelect,
onEscape,
rows,
selectCell.column,
selectCell.row,
]
);
useEffect(() => {
const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement;
const parentElement = ref.current?.parentElement;
focusElement?.addEventListener('keydown', handleKeyDown);
parentElement?.addEventListener('keydown', handleKeyDown);
return () => {
focusElement?.removeEventListener('keydown', handleKeyDown);
parentElement?.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return (
<div
ref={ref}
className={`mt-2 w-[${
EMOJI_SIZE * PER_ROW_EMOJI_COUNT
}px] flex-1 transform items-center justify-center overflow-y-auto overflow-x-hidden`}
>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<FixedSizeList
overscanCount={10}
height={height}
width={width}
outerRef={scrollRef}
itemCount={rows.length}
itemSize={EMOJI_SIZE}
itemData={rows}
outerElementType={AFScroller}
>
{renderRow}
</FixedSizeList>
)}
</AutoSizer>
</div>
);
}
export default EmojiPickerCategories;

View File

@ -0,0 +1,139 @@
import { useSelectSkinPopoverProps } from './EmojiPicker.hooks';
import React from 'react';
import { Box, IconButton } from '@mui/material';
import { Circle } from '@mui/icons-material';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import { randomEmoji } from '@/utils/emoji';
import { ReactComponent as ShuffleIcon } from '@/assets/shuffle.svg';
import Popover from '@mui/material/Popover';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteOutlineRounded } from '@/assets/trash.svg';
import { ReactComponent as SearchOutlined } from '@/assets/search.svg';
const skinTones = [
{
value: 0,
color: '#ffc93a',
},
{
color: '#ffdab7',
value: 1,
},
{
color: '#e7b98f',
value: 2,
},
{
color: '#c88c61',
value: 3,
},
{
color: '#a46134',
value: 4,
},
{
color: '#5d4437',
value: 5,
},
];
interface Props {
onEmojiSelect: (emoji: string) => void;
skin: number;
onSkinSelect: (skin: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
hideRemove?: boolean;
}
function EmojiPickerHeader({ hideRemove, onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) {
const { onOpen, ...popoverProps } = useSelectSkinPopoverProps();
const { t } = useTranslation();
return (
<div className={'px-0.5 py-2'}>
<div className={'search-input flex items-end justify-between gap-2'}>
<Box className={'mr-1 flex flex-1 items-center gap-2'}>
<SearchOutlined className={'h-5 h-5'} />
<TextField
value={searchValue}
onChange={(e) => {
onSearchChange(e.target.value);
}}
autoFocus={true}
fullWidth={true}
autoCorrect={'off'}
autoComplete={'off'}
spellCheck={false}
className={'search-emoji-input'}
placeholder={t('search.label')}
variant='standard'
/>
</Box>
<div className={'flex gap-1'}>
<Tooltip title={t('emoji.random')}>
<IconButton
size={'small'}
onClick={async () => {
const emoji = await randomEmoji();
onEmojiSelect(emoji);
}}
>
<ShuffleIcon className={'h-5 h-5'} />
</IconButton>
</Tooltip>
<Tooltip title={t('emoji.selectSkinTone')}>
<IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
<Circle
style={{
fill: skinTones[skin].color,
}}
className={'h-5 h-5'}
/>
</IconButton>
</Tooltip>
{hideRemove ? null : (
<Tooltip title={t('emoji.remove')}>
<IconButton
size={'small'}
onClick={() => {
onEmojiSelect('');
}}
>
<DeleteOutlineRounded className={'h-5 h-5'} />
</IconButton>
</Tooltip>
)}
</div>
</div>
<Popover {...popoverProps}>
<div className={'flex items-center p-2'}>
{skinTones.map((skinTone) => (
<div className={'mx-0.5'} key={skinTone.value}>
<IconButton
style={{
backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
}}
size={'small'}
onClick={() => {
onSkinSelect(skinTone.value);
popoverProps.onClose?.();
}}
>
<Circle
style={{
fill: skinTone.color,
}}
/>
</IconButton>
</div>
))}
</div>
</Popover>
</div>
);
}
export default EmojiPickerHeader;

View File

@ -0,0 +1,3 @@
export const EMOJI_SIZE = 32;
export const PER_ROW_EMOJI_COUNT = 13;
export const MAX_FREQUENTLY_ROW_COUNT = 2;

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const EmojiPicker = lazy(() => import('./EmojiPicker'));

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const KatexMath = lazy(() => import('./KatexMath'));

View File

@ -38,7 +38,14 @@ export function NormalModal({
const buttonColor = danger ? 'var(--function-error)' : undefined; const buttonColor = danger ? 'var(--function-error)' : undefined;
return ( return (
<Dialog {...dialogProps}> <Dialog
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose?.();
}
}}
{...dialogProps}
>
<div className={'relative flex flex-col gap-4 p-5'}> <div className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}> <div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-center '}>{title}</div> <div className={'flex-1 text-center '}>{title}</div>

View File

@ -15,32 +15,30 @@ export interface InfoProps {
export type InfoSnackbarProps = InfoProps & CustomContentProps; export type InfoSnackbarProps = InfoProps & CustomContentProps;
export const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>( const InfoSnackbar = forwardRef<HTMLDivElement, InfoSnackbarProps>(({ onOk, okText, title, message, onClose }, ref) => {
({ onOk, okText, title, message, onClose }, ref) => { const { t } = useTranslation();
const { t } = useTranslation();
return ( return (
<SnackbarContent ref={ref}> <SnackbarContent ref={ref}>
<Paper className={'relative flex flex-col gap-4 p-5'}> <Paper className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}> <div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-left '}>{title}</div> <div className={'flex-1 text-left '}>{title}</div>
<div className={'relative -right-1.5'}> <div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose}> <IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose}>
<CloseIcon className={'h-4 w-4'} /> <CloseIcon className={'h-4 w-4'} />
</IconButton> </IconButton>
</div>
</div> </div>
</div>
<div className={'flex-1'}>{message}</div> <div className={'flex-1'}>{message}</div>
<div className={'flex w-full justify-end gap-4'}> <div className={'flex w-full justify-end gap-4'}>
<Button color={'primary'} variant={'contained'} onClick={onOk}> <Button color={'primary'} variant={'contained'} onClick={onOk}>
{okText || t('button.ok')} {okText || t('button.ok')}
</Button> </Button>
</div> </div>
</Paper> </Paper>
</SnackbarContent> </SnackbarContent>
); );
} });
);
export default InfoSnackbar; export default InfoSnackbar;

View File

@ -1,4 +1,7 @@
import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; import { InfoProps } from '@/components/_shared/notify/InfoSnackbar';
import { lazy } from 'react';
export const InfoSnackbar = lazy(() => import('./InfoSnackbar'));
export const notify = { export const notify = {
success: (message: string) => { success: (message: string) => {

View File

@ -10,10 +10,12 @@ const defaultProps: Partial<PopoverComponentProps> = {
}, },
}; };
export function Popover({ children, ...props }: PopoverComponentProps) { function Popover({ children, ...props }: PopoverComponentProps) {
return ( return (
<PopoverComponent {...defaultProps} {...props}> <PopoverComponent {...defaultProps} {...props}>
{children} {children}
</PopoverComponent> </PopoverComponent>
); );
} }
export default Popover;

View File

@ -1,2 +1,4 @@
export * from './Popover'; import { lazy } from 'react';
export * from './RichTooltip';
export const RichTooltip = lazy(() => import('./RichTooltip'));
export const Popover = lazy(() => import('./Popover'));

View File

@ -29,8 +29,6 @@ export const AFScroller = React.forwardRef(
ref.current = scrollEl; ref.current = scrollEl;
} }
}} }}
renderTrackHorizontal={(props) => <div {...props} className='appflowy-scrollbar-track-horizontal' />}
renderTrackVertical={(props) => <div {...props} className='appflowy-scrollbar-track-vertical' />}
renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />} renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />}
renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />} renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />}
{...(overflowXHidden && { {...(overflowXHidden && {

View File

@ -140,6 +140,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
color: 'var(--text-caption)', color: 'var(--text-caption)',
WebkitTextFillColor: 'var(--text-caption) !important', WebkitTextFillColor: 'var(--text-caption) !important',
}, },
borderRadius: '8px',
}, },
}, },
styleOverrides: { styleOverrides: {

View File

@ -5,7 +5,7 @@ import AppConfig from '@/components/app/AppConfig';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { SnackbarProvider } from 'notistack'; import { SnackbarProvider } from 'notistack';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import InfoSnackbar from '../_shared/notify/InfoSnackbar'; import { InfoSnackbar } from '../_shared/notify';
const StyledSnackbarProvider = styled(SnackbarProvider)` const StyledSnackbarProvider = styled(SnackbarProvider)`
&.notistack-MuiContent-default { &.notistack-MuiContent-default {

View File

@ -8,7 +8,7 @@ export function Calendar() {
const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup(); const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup();
return ( return (
<div className={'database-calendar h-full max-h-[960px] pb-4 pt-4 text-sm'}> <div className={'database-calendar h-fit max-h-[960px] pb-4 pt-4 text-sm'}>
<BigCalendar <BigCalendar
components={{ components={{
toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />, toolbar: (props) => <Toolbar {...props} emptyEvents={emptyEvents} />,

View File

@ -29,7 +29,6 @@ $today-highlight-bg: transparent;
.rbc-month-view { .rbc-month-view {
border: none; border: none;
@apply h-full overflow-auto;
.rbc-month-row { .rbc-month-row {
border: 1px solid var(--line-divider); border: 1px solid var(--line-divider);
@ -79,10 +78,6 @@ $today-highlight-bg: transparent;
height: fit-content; height: fit-content;
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
&:last-child {
margin-bottom: 150px;
}
} }
.event-properties { .event-properties {

View File

@ -79,7 +79,6 @@ export const Column = memo(
return ( return (
<VariableSizeList <VariableSizeList
ref={ref} ref={ref}
className={'pb-[150px]'}
height={height} height={height}
itemCount={rowCount} itemCount={rowCount}
itemSize={getItemSize} itemSize={getItemSize}

View File

@ -126,7 +126,7 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
columnCount={columns.length} columnCount={columns.length}
columnWidth={(index) => columnWidth(index, width)} columnWidth={(index) => columnWidth(index, width)}
rowHeight={rowHeight} rowHeight={rowHeight}
className={'grid-table pb-[150px]'} className={'grid-table'}
overscanRowCount={5} overscanRowCount={5}
overscanColumnCount={5} overscanColumnCount={5}
style={{ style={{

View File

@ -18,7 +18,7 @@ function DatabaseHeader({
return ( return (
<div <div
className={ className={
'my-10 flex w-full items-center gap-4 overflow-hidden whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]' 'my-10 flex w-full items-center gap-4 whitespace-pre-wrap break-words break-all text-[2.25rem] font-bold leading-[1.5em] max-sm:text-[7vw]'
} }
> >
<div className={'relative'}> <div className={'relative'}>

View File

@ -5,7 +5,7 @@ import { copyTextToClipboard } from '@/utils/copy';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { ErrorOutline } from '@mui/icons-material'; import { ReactComponent as ErrorOutline } from '@/assets/error.svg';
const MIN_WIDTH = 100; const MIN_WIDTH = 100;
@ -35,7 +35,11 @@ function ImageRender({
}, [hasError, initialWidth, loading]); }, [hasError, initialWidth, loading]);
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => { const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
return { return {
style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 }, style: {
width: loading || hasError ? '0' : newWidth ?? '100%',
opacity: selected ? 0.8 : 1,
height: hasError ? 0 : 'auto',
},
className: 'object-cover', className: 'object-cover',
ref: imgRef, ref: imgRef,
src: url, src: url,
@ -54,7 +58,9 @@ function ImageRender({
const renderErrorNode = useCallback(() => { const renderErrorNode = useCallback(() => {
return ( return (
<div <div
className={'flex h-full w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'} className={
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
}
> >
<ErrorOutline className={'text-function-error'} /> <ErrorOutline className={'text-function-error'} />
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div> <div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
@ -68,7 +74,7 @@ function ImageRender({
<div <div
style={{ style={{
minWidth: MIN_WIDTH, minWidth: MIN_WIDTH,
width: 'fit-content', width: loading || hasError ? '100%' : 'fit-content',
}} }}
className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`} className={`image-render relative min-h-[48px] ${hasError ? 'w-full' : ''}`}
> >

View File

@ -1,4 +1,4 @@
import KatexMath from '@/components/_shared/katex-math/KatexMath'; import { KatexMath } from '@/components/_shared/katex-math';
import { notify } from '@/components/_shared/notify'; import { notify } from '@/components/_shared/notify';
import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar'; import RightTopActionsToolbar from '@/components/editor/components/block-actions/RightTopActionsToolbar';
import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type'; import { EditorElementProps, MathEquationNode } from '@/components/editor/editor.type';

View File

@ -1,4 +1,4 @@
import KatexMath from '@/components/_shared/katex-math/KatexMath'; import { KatexMath } from '@/components/_shared/katex-math';
import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type'; import { EditorElementProps, FormulaNode } from '@/components/editor/editor.type';
import React, { memo, forwardRef } from 'react'; import React, { memo, forwardRef } from 'react';
import { useSelected } from 'slate-react'; import { useSelected } from 'slate-react';

View File

@ -0,0 +1,181 @@
import { PublishContext } from '@/application/publish';
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import { LoginModal } from '@/components/login';
import { Button, TextareaAutosize } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React, { memo, useCallback, useContext, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
function AddComment() {
const { reload, replyCommentId, replyComment: setReplyCommentId } = useGlobalCommentContext();
const { t } = useTranslation();
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated;
const createCommentOnPublishView = useContext(AFConfigContext)?.service?.createCommentOnPublishView;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const [content, setContent] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [loginOpen, setLoginOpen] = React.useState(false);
const [focus, setFocus] = React.useState(false);
const url = window.location.href + '#addComment';
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleOnFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setFocus(true);
if (!isAuthenticated) {
e.preventDefault();
setLoginOpen(true);
}
};
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const hashHasComment = window.location.hash.includes('#addComment');
const duration = hashHasComment ? 1000 : 200;
if (hashHasComment || replyCommentId) {
setTimeout(() => {
void (async () => {
window.location.hash = '';
await smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'nearest',
});
inputRef.current?.focus();
})();
}, duration);
}
}, [replyCommentId]);
const handleSubmit = useCallback(async () => {
if (!createCommentOnPublishView || !viewId || loading) {
return;
}
setLoading(true);
try {
await createCommentOnPublishView(viewId, content, replyCommentId || undefined);
await reload();
setContent('');
setReplyCommentId(null);
} catch (_e) {
notify.error('Failed to create comment');
} finally {
setLoading(false);
}
}, [loading, content, createCommentOnPublishView, viewId, replyCommentId, reload, setReplyCommentId]);
return (
<div className={'my-2 flex flex-col gap-2'}>
<div
style={{
backgroundColor: replyCommentId ? 'var(--fill-list-hover)' : undefined,
}}
className={'flex flex-col rounded-[8px]'}
>
{replyCommentId && (
<div className={'relative flex items-center gap-2 py-2 pl-2 pr-6'}>
<span className={'text-sm text-text-caption'}>{t('globalComment.replyingTo')}</span>
<div className={'flex-1 overflow-hidden'}> {<ReplyComment commentId={replyCommentId} />}</div>
<div className={'absolute right-2 top-2 cursor-pointer rounded-full p-1 hover:bg-fill-list-hover'}>
<CloseIcon className={'h-3 w-3 '} onClick={() => setReplyCommentId(null)} />
</div>
</div>
)}
<div
ref={ref}
id={'addComment'}
style={{
borderColor: focus ? 'var(--content-blue-400)' : undefined,
borderWidth: '1.5px',
scrollMarginTop: '100px',
borderTopLeftRadius: replyCommentId ? 0 : undefined,
borderTopRightRadius: replyCommentId ? 0 : undefined,
}}
className={
'flex w-full w-full transform flex-col gap-4 rounded-[8px] border border-line-divider bg-bg-body px-3 py-1.5 '
}
>
<TextareaAutosize
minRows={1}
ref={inputRef}
autoComplete={'off'}
spellCheck={false}
onFocus={handleOnFocus}
onBlur={() => setFocus(false)}
value={content}
className={'w-full resize-none'}
onChange={(e) => {
setContent(e.target.value);
}}
placeholder={t('globalComment.addComment')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
if (!content) return;
e.preventDefault();
void handleSubmit();
}
if (e.key === 'Escape') {
setContent('');
setReplyCommentId(null);
}
}}
/>
</div>
</div>
{!!content && (
<div className={'flex items-center justify-end gap-2'}>
<Button
onClick={() => {
inputRef.current?.blur();
setContent('');
setReplyCommentId(null);
}}
className={'h-7'}
size={'small'}
color={'inherit'}
variant={'outlined'}
>
{t('button.cancel')}
</Button>
<Button
className={'h-7'}
size={'small'}
disabled={!content || loading}
onClick={handleSubmit}
variant={'contained'}
>
{loading ? <CircularProgress color={'inherit'} size={20} /> : t('button.save')}
</Button>
</div>
)}
<LoginModal
redirectTo={url}
open={loginOpen}
onClose={() => {
setLoginOpen(false);
}}
/>
</div>
);
}
export default memo(AddComment);

View File

@ -0,0 +1,38 @@
import { CommentWrap } from '@/components/global-comment/comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import React, { memo } from 'react';
function CommentList() {
const { comments } = useGlobalCommentContext();
const isEmpty = !comments || comments.length === 0;
const [hoverId, setHoverId] = React.useState<string | null>(null);
if (isEmpty) {
return null;
}
return (
<div
onMouseLeave={() => {
setHoverId(null);
}}
className={'flex w-full flex-col gap-2'}
>
{comments?.map((comment) => (
<CommentWrap
isHovered={comment.commentId === hoverId}
onHovered={() => {
setHoverId(comment.commentId);
}}
key={comment.commentId}
commentId={comment.commentId}
/>
))}
</div>
);
}
export default memo(CommentList);

View File

@ -0,0 +1,43 @@
import GlobalComment from '@/components/global-comment/GlobalComment';
import {
GlobalCommentContext,
useLoadComments,
useLoadReactions,
} from '@/components/global-comment/GlobalComment.hooks';
import React, { useCallback, useState } from 'react';
export function GlobalCommentProvider() {
const { comments, loading, reload } = useLoadComments();
const { reactions, toggleReaction } = useLoadReactions();
const [replyCommentId, setReplyCommentId] = useState<string | null>(null);
const getComment = useCallback(
(commentId: string) => {
return comments?.find((comment) => comment.commentId === commentId);
},
[comments]
);
const replyComment = useCallback((commentId: string | null) => {
setReplyCommentId(commentId);
}, []);
return (
<GlobalCommentContext.Provider
value={{
reactions,
replyCommentId,
reload,
getComment,
loading,
comments,
replyComment,
toggleReaction,
}}
>
<GlobalComment />
</GlobalCommentContext.Provider>
);
}
export default GlobalCommentProvider;

View File

@ -0,0 +1,242 @@
import { CommentUser, GlobalComment, Reaction } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { AFConfigContext } from '@/components/app/AppConfig';
import { stringAvatar } from '@/utils/color';
import dayjs from 'dayjs';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const GlobalCommentContext = React.createContext<{
reload: () => Promise<void>;
getComment: (commentId: string) => GlobalComment | undefined;
loading: boolean;
comments: GlobalComment[] | null;
replyComment: (commentId: string | null) => void;
replyCommentId: string | null;
reactions: Record<string, Reaction[]> | null;
toggleReaction: (commentId: string, reactionType: string) => void;
}>({
reload: () => Promise.resolve(),
getComment: () => undefined,
loading: false,
comments: null,
replyComment: () => undefined,
replyCommentId: null,
reactions: null,
toggleReaction: () => undefined,
});
export function useGlobalCommentContext() {
return useContext(GlobalCommentContext);
}
export function useLoadReactions() {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const currentUser = useContext(AFConfigContext)?.currentUser;
const [reactions, setReactions] = useState<Record<string, Reaction[]> | null>(null);
const fetchReactions = useCallback(async () => {
if (!viewId || !service) return;
try {
const reactions = await service.getPublishViewReactions(viewId);
setReactions(reactions);
} catch (e) {
console.error(e);
}
}, [service, viewId]);
useEffect(() => {
void fetchReactions();
}, [fetchReactions]);
const toggleReaction = useCallback(
async (commentId: string, reactionType: string) => {
try {
if (!service || !viewId) return;
let isAdded = true;
setReactions((prev) => {
const commentReactions = prev?.[commentId] || [];
const reaction = commentReactions.find((reaction) => reaction.reactionType === reactionType);
const reactUsers = reaction?.reactUsers || [];
const hasReacted = reactUsers.some((user) => user.uuid === currentUser?.uuid);
let newReaction: Reaction | null = null;
let index = -1;
const reactUser = {
uuid: currentUser?.uuid || '',
name: currentUser?.name || '',
avatarUrl: currentUser?.avatar || null,
};
// If the reaction does not exist, create a new reaction.
if (!reaction) {
index = commentReactions.length;
newReaction = {
reactionType,
reactUsers: [reactUser],
commentId,
};
} else {
let newReactUsers: CommentUser[] = [];
// If the user has not reacted, add the user to the reaction.
if (!hasReacted) {
newReactUsers = [...reactUsers, reactUser];
// If the user has reacted, remove the user from the reaction.
} else {
isAdded = false;
newReactUsers = reactUsers.filter((user) => user.uuid !== currentUser?.uuid);
}
newReaction = {
reactionType,
reactUsers: newReactUsers,
commentId,
};
index = commentReactions.findIndex((reaction) => reaction.reactionType === reactionType);
}
const newReactions = [...commentReactions];
if (!newReaction) return prev;
// If the reaction does not exist, add the reaction to the list.
if (index === -1) {
newReactions.push(newReaction);
// If the reaction exists, update the reaction.
} else {
newReactions.splice(index, 1, newReaction);
}
return {
...prev,
[commentId]: newReactions,
};
});
try {
if (isAdded) {
await service.addPublishViewReaction(viewId, commentId, reactionType);
} else {
await service.removePublishViewReaction(viewId, commentId, reactionType);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
},
[currentUser, service, viewId]
);
return { reactions, toggleReaction };
}
export function useLoadComments() {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const [comments, setComments] = useState<GlobalComment[] | null>(null);
const [loading, setLoading] = useState(false);
const fetchComments = useCallback(async () => {
if (!viewId || !service) return;
setLoading(true);
try {
const comments = await service.getPublishViewGlobalComments(viewId);
setComments(comments);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [viewId, service]);
useEffect(() => {
void fetchComments();
}, [fetchComments]);
return { comments, loading, reload: fetchComments };
}
export function getAvatar(comment: GlobalComment) {
if (comment.user?.avatarUrl) {
return {
children: <span>{comment.user.avatarUrl}</span>,
};
}
return stringAvatar(comment.user?.name || '');
}
export function useCommentRender(comment: GlobalComment) {
const { t } = useTranslation();
const avatar = useMemo(() => {
return getAvatar(comment);
}, [comment]);
const timeFormat = useMemo(() => {
const time = dayjs.unix(Number(comment.lastUpdatedAt));
return time.format('YYYY-MM-DD HH:mm:ss');
}, [comment.lastUpdatedAt]);
const time = useMemo(() => {
if (!comment.lastUpdatedAt) return '';
const now = dayjs();
const past = dayjs.unix(Number(comment.lastUpdatedAt));
const diffSec = now.diff(past, 'second');
const diffMin = now.diff(past, 'minute');
const diffHour = now.diff(past, 'hour');
const diffDay = now.diff(past, 'day');
const diffMonth = now.diff(past, 'month');
const diffYear = now.diff(past, 'year');
if (diffSec < 5) {
return t('globalComment.showSeconds', {
count: 0,
});
}
if (diffMin < 1) {
return t('globalComment.showSeconds', {
count: diffSec,
});
}
if (diffHour < 1) {
return t('globalComment.showMinutes', {
count: diffMin,
});
}
if (diffDay < 1) {
return t('globalComment.showHours', {
count: diffHour,
});
}
if (diffMonth < 1) {
return t('globalComment.showDays', {
count: diffDay,
});
}
if (diffYear < 1) {
return t('globalComment.showMonths', {
count: diffMonth,
});
}
return t('globalComment.showYears', {
count: diffYear,
});
}, [t, comment]);
return { avatar, time, timeFormat };
}

View File

@ -0,0 +1,35 @@
import AddComment from '@/components/global-comment/AddComment';
import CommentList from '@/components/global-comment/CommentList';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Divider } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import React from 'react';
import { useTranslation } from 'react-i18next';
function GlobalComment() {
const { t } = useTranslation();
const { loading, comments } = useGlobalCommentContext();
return (
<div className={'mb-[100px] mt-16 flex h-fit w-full justify-center'}>
<div
className={
'flex w-[964px] min-w-0 max-w-full transform flex-col gap-2 px-16 transition-all duration-300 ease-in-out max-sm:px-4'
}
>
<div className={'text-[24px]'}>{t('globalComment.comments')}</div>
<Divider />
<AddComment />
{loading && !comments?.length ? (
<div className={'flex h-[200px] w-full items-center justify-center'}>
<CircularProgress />
</div>
) : (
<CommentList />
)}
</div>
</div>
);
}
export default GlobalComment;

View File

@ -0,0 +1,30 @@
import { getAvatar, useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Avatar } from '@mui/material';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function ReplyComment({ commentId }: { commentId?: string | null }) {
const { getComment } = useGlobalCommentContext();
const { t } = useTranslation();
const replyComment = useMemo(() => {
if (!commentId) return;
return getComment(commentId);
}, [commentId, getComment]);
if (!replyComment) return null;
return (
<div className={'flex items-center gap-1 text-sm text-text-caption'}>
<Avatar {...getAvatar(replyComment)} className={'h-4 w-4 text-xs'} />
<div className={'text-xs font-medium'}>@{replyComment.user?.name}</div>
<div className={'truncate px-1'}>
{replyComment.isDeleted ? (
<span className={'text-xs'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
replyComment.content
)}
</div>
</div>
);
}
export default ReplyComment;

View File

@ -0,0 +1,17 @@
import { GlobalComment } from '@/application/comment.type';
import MoreActions from '@/components/global-comment/actions/MoreActions';
import ReactAction from '@/components/global-comment/actions/ReactAction';
import ReplyAction from '@/components/global-comment/actions/ReplyAction';
import React, { memo } from 'react';
function CommentActions({ comment }: { comment: GlobalComment }) {
return (
<div className={'flex gap-2'}>
<ReactAction comment={comment} />
<ReplyAction comment={comment} />
<MoreActions comment={comment} />
</div>
);
}
export default memo(CommentActions);

View File

@ -0,0 +1,152 @@
import { GlobalComment } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { RichTooltip } from '@/components/_shared/popover';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Button, IconButton, Tooltip, TooltipProps } from '@mui/material';
import { debounce } from 'lodash-es';
import React, { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as TrashIcon } from '@/assets/trash.svg';
interface Item {
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
label: string;
disabled: boolean;
onClick: () => void;
danger?: boolean;
tooltip?: TooltipProps;
}
function MoreActions({ comment }: { comment: GlobalComment }) {
const { reload } = useGlobalCommentContext();
const canDeleted = comment.canDeleted;
const [open, setOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const debounceClose = useMemo(() => {
return debounce(handleClose, 200);
}, [handleClose]);
const handleOpen = () => {
debounceClose.cancel();
setOpen(true);
};
const { t } = useTranslation();
const service = useContext(AFConfigContext)?.service;
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const handleDeleteAction = useCallback(async () => {
if (!viewId || !service) return;
try {
await service?.deleteCommentOnPublishView(viewId, comment.commentId);
await reload();
} catch (e) {
console.error(e);
notify.error('Failed to delete comment');
} finally {
setDeleteModalOpen(false);
}
}, [comment.commentId, reload, service, viewId]);
const actions = useMemo(() => {
return [
{
Icon: TrashIcon,
label: t('button.delete'),
disabled: !canDeleted,
tooltip: canDeleted
? undefined
: {
title: <div className={'text-center'}>{t('globalComment.noAccessDeleteComment')}</div>,
placement: 'top',
},
onClick: () => {
setDeleteModalOpen(true);
},
danger: true,
},
] as Item[];
}, [t, canDeleted]);
const renderItem = useCallback((action: Item) => {
return (
<Button
size={'small'}
color={'inherit'}
disabled={action.disabled}
onClick={action.onClick}
className={`w-full items-center justify-start gap-2 p-1 ${action.danger ? 'hover:text-function-error' : ''}`}
>
<action.Icon className={'h-4 w-4'} />
<div>{action.label}</div>
</Button>
);
}, []);
return (
<>
<RichTooltip
content={
<div
className={'flex min-w-[150px] flex-col items-start p-2'}
onMouseEnter={handleOpen}
onMouseLeave={debounceClose}
>
{actions.map((action, index) => {
if (action.tooltip) {
return (
<Tooltip key={index} {...action.tooltip}>
<div className={'w-full'}>{renderItem(action)}</div>
</Tooltip>
);
}
return (
<div key={index} className={'w-full'}>
{renderItem(action)}
</div>
);
})}
</div>
}
open={open}
onClose={handleClose}
>
<IconButton size={'small'} onMouseEnter={handleOpen} onMouseLeave={debounceClose}>
<MoreIcon className={'h-4 w-4'} />
</IconButton>
</RichTooltip>
<NormalModal
PaperProps={{
sx: {
maxWidth: 420,
},
}}
okText={t('button.delete')}
danger={true}
onOk={handleDeleteAction}
onCancel={() => {
setDeleteModalOpen(false);
}}
onClose={() => setDeleteModalOpen(false)}
open={deleteModalOpen}
title={<div className={'text-left'}>{t('globalComment.deleteComment')}</div>}
>
<div className={'w-full whitespace-pre-wrap break-words pb-1 text-text-caption'}>
{t('globalComment.confirmDeleteDescription')}
</div>
</NormalModal>
</>
);
}
export default memo(MoreActions);

View File

@ -0,0 +1,60 @@
import { GlobalComment } from '@/application/comment.type';
import { EmojiPicker } from '@/components/_shared/emoji-picker';
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { Popover } from '@/components/_shared/popover';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as AddReactionRounded } from '@/assets/add_reaction.svg';
import { IconButton, Tooltip } from '@mui/material';
import React, { memo, Suspense, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function ReactAction({ comment }: { comment: GlobalComment }) {
const { toggleReaction } = useGlobalCommentContext();
const { t } = useTranslation();
const ref = React.useRef<HTMLButtonElement>(null);
const [open, setOpen] = React.useState(false);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const handleOpen = () => {
setOpen(true);
};
const handlePickEmoji = useCallback(
(emoji: string) => {
toggleReaction(comment.commentId, emoji);
handleClose();
},
[comment.commentId, handleClose, toggleReaction]
);
return (
<>
<Tooltip title={t('globalComment.addReaction')}>
<IconButton ref={ref} onClick={handleOpen} size='small'>
<AddReactionRounded className={'h-4 w-4'} />
</IconButton>
</Tooltip>
{open && (
<Popover
anchorEl={ref.current}
open={open}
onClose={handleClose}
sx={{
'& .MuiPopover-paper': {
width: PER_ROW_EMOJI_COUNT * EMOJI_SIZE,
},
}}
>
<Suspense fallback={<ComponentLoading />}>
<EmojiPicker hideRemove onEscape={handleClose} onEmojiSelect={handlePickEmoji} />
</Suspense>
</Popover>
)}
</>
);
}
export default memo(ReactAction);

View File

@ -0,0 +1,27 @@
import { GlobalComment } from '@/application/comment.type';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as ReplyOutlined } from '@/assets/reply.svg';
import { Tooltip } from '@mui/material';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import IconButton from '@mui/material/IconButton';
function ReplyAction({ comment }: { comment: GlobalComment }) {
const { t } = useTranslation();
const replyComment = useGlobalCommentContext().replyComment;
return (
<Tooltip title={t('globalComment.reply')}>
<IconButton
onClick={() => {
replyComment(comment.commentId);
}}
size='small'
>
<ReplyOutlined className={'h-4 w-4'} />
</IconButton>
</Tooltip>
);
}
export default memo(ReplyAction);

View File

@ -0,0 +1,39 @@
import { GlobalComment } from '@/application/comment.type';
import { useCommentRender } from '@/components/global-comment/GlobalComment.hooks';
import { Reactions } from '@/components/global-comment/reactions';
import { Avatar, Tooltip } from '@mui/material';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface CommentProps {
comment: GlobalComment;
}
function Comment({ comment }: CommentProps) {
const { avatar, time, timeFormat } = useCommentRender(comment);
const { t } = useTranslation();
return (
<div className={' flex gap-4 '}>
<Avatar {...avatar} />
<div className={'flex flex-col gap-1'}>
<div className={'flex items-center gap-2'}>
<div className={'font-semibold'}>{comment.user?.name}</div>
<Tooltip title={timeFormat} enterNextDelay={500} enterDelay={1000} placement={'top-start'}>
<div className={'text-sm text-text-caption'}>{time}</div>
</Tooltip>
</div>
<div className={'whitespace-pre-wrap break-words'}>
{comment.isDeleted ? (
<span className={'text-text-caption'}>{`[${t('globalComment.hasBeenDeleted')}]`}</span>
) : (
comment.content
)}
</div>
{!comment.isDeleted && <Reactions comment={comment} />}
</div>
</div>
);
}
export default memo(Comment);

View File

@ -0,0 +1,83 @@
import CommentActions from '@/components/global-comment/actions/CommentActions';
import Comment from './Comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';
import React, { useCallback, useEffect, useMemo } from 'react';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
export interface CommentWrapProps {
commentId: string;
isHovered: boolean;
onHovered: () => void;
}
export function CommentWrap({ commentId, isHovered, onHovered }: CommentWrapProps) {
const { getComment } = useGlobalCommentContext();
const comment = useMemo(() => getComment(commentId), [commentId, getComment]);
const ref = React.useRef<HTMLDivElement>(null);
const [highLight, setHighLight] = React.useState(false);
useEffect(() => {
const hashHasComment = window.location.hash.includes(`#comment-${commentId}`);
if (!hashHasComment) return;
const element = ref.current;
if (!element) return;
let timeout: NodeJS.Timeout | null = null;
void (async () => {
window.location.hash = '';
await smoothScrollIntoViewIfNeeded(element, {
behavior: 'smooth',
block: 'center',
});
setHighLight(true);
timeout = setTimeout(() => {
setHighLight(false);
}, 10000);
})();
return () => {
timeout && clearTimeout(timeout);
};
}, [commentId]);
const renderReplyComment = useCallback((replyCommentId: string) => {
return (
<div className={'relative flex w-full items-center gap-2'}>
<div className={'reply-line relative top-2 ml-[1.75em] min-w-[28px]'} />
<div className={'flex-1 overflow-hidden '}> {<ReplyComment commentId={replyCommentId} />}</div>
</div>
);
}, []);
if (!comment) {
return null;
}
return (
<div ref={ref} className={'flex flex-col gap-2'} data-comment-id={comment.commentId}>
{comment.replyCommentId && renderReplyComment(comment.replyCommentId)}
<div
className={`relative rounded-[8px] p-2 hover:bg-fill-list-hover ${highLight ? 'blink' : ''}`}
{...(comment.isDeleted ? { style: { opacity: 0.5 } } : {})}
onMouseEnter={() => {
onHovered();
setHighLight(false);
}}
>
<Comment comment={comment} />
{isHovered && !comment.isDeleted && (
<div className={'absolute right-2 top-2'}>
<CommentActions comment={comment} />
</div>
)}
</div>
</div>
);
}
export default CommentWrap;

View File

@ -0,0 +1 @@
export * from './CommentWrap';

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const GlobalCommentProvider = lazy(() => import('./GlobaclCommentProvider'));

View File

@ -0,0 +1,81 @@
import { Reaction as ReactionType } from '@/application/comment.type';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Tooltip } from '@mui/material';
import React, { memo, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function Reaction({ reaction, onClick }: { reaction: ReactionType; onClick: (reaction: ReactionType) => void }) {
const { t } = useTranslation();
const reactCount = useMemo(() => {
return reaction.reactUsers.length;
}, [reaction.reactUsers]);
const userNames = useMemo(() => {
let sliceOffset = reactCount;
let suffix = '';
if (reactCount > 20) {
sliceOffset = 20;
suffix = ` ${t('globalComment.reactedByMore', { count: reactCount - 20 })}`;
}
return (
reaction.reactUsers
.slice(0, sliceOffset)
.map((user) => user.name)
.join(', ') + suffix
);
}, [reaction.reactUsers, t, reactCount]);
const currentUser = useContext(AFConfigContext)?.currentUser;
const currentUid = currentUser?.uuid;
const isCurrentUserReacted = useMemo(() => {
return reaction.reactUsers.some((user) => user.uuid === currentUid);
}, [currentUid, reaction.reactUsers]);
const [hover, setHover] = React.useState(false);
const style = useMemo(() => {
const styleProperties: React.CSSProperties = {};
if (hover) {
Object.assign(styleProperties, {
borderColor: 'var(--line-border)',
backgroundColor: 'var(--bg-body)',
});
} else if (isCurrentUserReacted) {
Object.assign(styleProperties, {
borderColor: 'var(--content-blue-400)',
backgroundColor: 'var(--content-blue-100)',
});
}
return styleProperties;
}, [hover, isCurrentUserReacted]);
return (
<Tooltip
title={
<div className={'break-word overflow-hidden whitespace-pre-wrap text-xs'}>
{t('globalComment.reactedBy')}
{` `}
{userNames}
</div>
}
>
<div
style={style}
onClick={() => onClick(reaction)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={
'flex cursor-pointer items-center gap-1 rounded-full border border-transparent bg-fill-list-hover px-1 py-0.5 text-sm'
}
>
<span className={'icon'}>{reaction.reactionType}</span>
{<div className={'text-xs font-medium'}>{reactCount}</div>}
</div>
</Tooltip>
);
}
export default memo(Reaction);

View File

@ -0,0 +1,28 @@
import { GlobalComment, Reaction as ReactionType } from '@/application/comment.type';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import Reaction from '@/components/global-comment/reactions/Reaction';
import React, { memo, useCallback, useMemo } from 'react';
export function Reactions({ comment }: { comment: GlobalComment }) {
const { reactions, toggleReaction } = useGlobalCommentContext();
const commentReactions = useMemo(() => {
return reactions?.[comment.commentId]?.filter((reaction) => reaction.reactUsers.length > 0) || [];
}, [reactions, comment.commentId]);
const handleReactionClick = useCallback(
(reaction: ReactionType) => {
toggleReaction(comment.commentId, reaction.reactionType);
},
[comment.commentId, toggleReaction]
);
return (
<div className={'flex w-full flex-wrap items-center gap-2 overflow-hidden'}>
{commentReactions.map((reaction) => {
return <Reaction reaction={reaction} onClick={handleReactionClick} key={reaction.reactionType} />;
})}
</div>
);
}
export default memo(Reactions);

View File

@ -0,0 +1 @@
export * from './Reactions';

View File

@ -3,7 +3,7 @@ import { Dialog, IconButton } from '@mui/material';
import { Login } from './Login'; import { Login } from './Login';
import { ReactComponent as CloseIcon } from '@/assets/close.svg'; import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) { function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose}>
<div className={'relative px-6'}> <div className={'relative px-6'}>
@ -17,3 +17,5 @@ export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string;
</Dialog> </Dialog>
); );
} }
export default LoginModal;

View File

@ -3,7 +3,7 @@ import { AFConfigContext } from '@/components/app/AppConfig';
import { Button, CircularProgress, OutlinedInput } from '@mui/material'; import { Button, CircularProgress, OutlinedInput } from '@mui/material';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import validator from 'validator'; import isEmail from 'validator/lib/isEmail';
function MagicLink({ redirectTo }: { redirectTo: string }) { function MagicLink({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -11,7 +11,7 @@ function MagicLink({ redirectTo }: { redirectTo: string }) {
const [loading, setLoading] = React.useState<boolean>(false); const [loading, setLoading] = React.useState<boolean>(false);
const service = useContext(AFConfigContext)?.service; const service = useContext(AFConfigContext)?.service;
const handleSubmit = async () => { const handleSubmit = async () => {
const isValidEmail = validator.isEmail(email); const isValidEmail = isEmail(email);
if (!isValidEmail) { if (!isValidEmail) {
notify.error(t('signIn.invalidEmail')); notify.error(t('signIn.invalidEmail'));

View File

@ -1,2 +1,5 @@
export * from './Login'; import { lazy } from 'react';
export * from './LoginModal';
export const Login = lazy(() => import('./Login'));
export const LoginModal = lazy(() => import('./LoginModal'));

View File

@ -1,12 +1,14 @@
import { YDoc } from '@/application/collab.type'; import { YDoc } from '@/application/collab.type';
import { PublishProvider } from '@/application/publish'; import { PublishProvider } from '@/application/publish';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { AFScroller } from '@/components/_shared/scroller'; import { AFScroller } from '@/components/_shared/scroller';
import { AFConfigContext } from '@/components/app/AppConfig'; import { AFConfigContext } from '@/components/app/AppConfig';
import { GlobalCommentProvider } from '@/components/global-comment';
import CollabView from '@/components/publish/CollabView'; import CollabView from '@/components/publish/CollabView';
import OutlineDrawer from '@/components/publish/outline/OutlineDrawer'; import { OutlineDrawer } from '@/components/publish/outline';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
import { PublishViewHeader } from 'src/components/publish/header'; import { PublishViewHeader } from '@/components/publish/header';
import NotFound from '@/components/error/NotFound'; import NotFound from '@/components/error/NotFound';
export interface PublishViewProps { export interface PublishViewProps {
@ -84,8 +86,13 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
/> />
<CollabView doc={doc} /> <CollabView doc={doc} />
<Suspense fallback={<ComponentLoading />}>
<GlobalCommentProvider />
</Suspense>
</AFScroller> </AFScroller>
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />} <Suspense fallback={null}>
{open && <OutlineDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />}
</Suspense>
</div> </div>
</PublishProvider> </PublishProvider>
); );

View File

@ -2,14 +2,14 @@ import { usePublishContext } from '@/application/publish';
import { openOrDownload } from '@/components/publish/header/utils'; import { openOrDownload } from '@/components/publish/header/utils';
import { Divider, IconButton, Tooltip } from '@mui/material'; import { Divider, IconButton, Tooltip } from '@mui/material';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import React, { useCallback, useMemo } from 'react'; import React, { Suspense, useCallback, useMemo } from 'react';
import OutlinePopover from '@/components/publish/outline/OutlinePopover'; import { OutlinePopover } from '@/components/publish/outline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Breadcrumb from './Breadcrumb'; import Breadcrumb from './Breadcrumb';
import { ReactComponent as Logo } from '@/assets/logo.svg'; import { ReactComponent as Logo } from '@/assets/logo.svg';
import MoreActions from './MoreActions'; import MoreActions from './MoreActions';
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg'; import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
import Duplicate from './duplicate/Duplicate'; import { Duplicate } from './duplicate';
export const HEADER_HEIGHT = 48; export const HEADER_HEIGHT = 48;
@ -66,26 +66,28 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'} className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
> >
<div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}> <div className={'flex w-full items-center justify-between gap-2 overflow-hidden'}>
{!openDrawer && ( <Suspense fallback={null}>
<OutlinePopover {!openDrawer && (
onMouseEnter={handleOpenPopover} <OutlinePopover
onMouseLeave={debounceClosePopover}
open={openPopover}
onClose={debounceClosePopover}
>
<IconButton
className={'hidden'}
onClick={() => {
setOpenPopover(false);
onOpenDrawer();
}}
onMouseEnter={handleOpenPopover} onMouseEnter={handleOpenPopover}
onMouseLeave={debounceClosePopover} onMouseLeave={debounceClosePopover}
open={openPopover}
onClose={debounceClosePopover}
> >
<SideOutlined className={'h-4 w-4'} /> <IconButton
</IconButton> className={'hidden'}
</OutlinePopover> onClick={() => {
)} setOpenPopover(false);
onOpenDrawer();
}}
onMouseEnter={handleOpenPopover}
onMouseLeave={debounceClosePopover}
>
<SideOutlined className={'h-4 w-4'} />
</IconButton>
</OutlinePopover>
)}
</Suspense>
<div className={'h-full flex-1 overflow-hidden'}> <div className={'h-full flex-1 overflow-hidden'}>
<Breadcrumb crumbs={crumbs} /> <Breadcrumb crumbs={crumbs} />
@ -93,7 +95,9 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
<div className={'flex items-center gap-2'}> <div className={'flex items-center gap-2'}>
<MoreActions /> <MoreActions />
<Duplicate /> <Suspense fallback={null}>
<Duplicate />
</Suspense>
<Divider orientation={'vertical'} className={'mx-2'} flexItem /> <Divider orientation={'vertical'} className={'mx-2'} flexItem />
<Tooltip title={t('publish.downloadApp')}> <Tooltip title={t('publish.downloadApp')}>
<button onClick={openOrDownload}> <button onClick={openOrDownload}>

View File

@ -1,7 +1,7 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LoginModal } from '@/components/login/LoginModal'; import { LoginModal } from '@/components/login';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate'; import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal'; import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
@ -23,7 +23,7 @@ function Duplicate() {
{t('publish.saveThisPage')} {t('publish.saveThisPage')}
</Button> </Button>
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} /> <LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
<DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} /> {duplicateOpen && <DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />}
</> </>
); );
} }

View File

@ -0,0 +1,3 @@
import { lazy } from 'react';
export const Duplicate = lazy(() => import('./Duplicate'));

View File

@ -2,7 +2,7 @@ import { usePublishContext } from '@/application/publish';
import Outline from '@/components/publish/outline/Outline'; import Outline from '@/components/publish/outline/Outline';
import { Divider, PopperPlacementType } from '@mui/material'; import { Divider, PopperPlacementType } from '@mui/material';
import React, { ReactElement, useMemo } from 'react'; import React, { ReactElement, useMemo } from 'react';
import RichTooltip from 'src/components/_shared/popover/RichTooltip'; import { RichTooltip } from '@/components/_shared/popover';
import { ReactComponent as Logo } from '@/assets/logo.svg'; import { ReactComponent as Logo } from '@/assets/logo.svg';
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg'; import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';

View File

@ -1 +1,4 @@
export * from './OutlinePopover'; import { lazy } from 'react';
export const OutlineDrawer = lazy(() => import('./OutlineDrawer'));
export const OutlinePopover = lazy(() => import('./OutlinePopover'));

View File

@ -123,3 +123,25 @@ body {
@apply flex-wrap py-2; @apply flex-wrap py-2;
} }
} }
@keyframes blink {
0%, 100% {
background-color: var(--content-blue-100);
}
50% {
background-color: unset;
}
}
.blink {
animation: blink 2s linear infinite;
}
.reply-line {
width: 28px;
height: 16px;
border-left: 2px solid;
border-top: 2px solid;
border-color: var(--line-border);
border-top-left-radius: 6px;
}

View File

@ -73,6 +73,7 @@ export function renderColor(color: string) {
return argbToRgba(color); return argbToRgba(color);
} }
export function stringToColor(string: string) { export function stringToColor(string: string) {
let hash = 0; let hash = 0;
let i; let i;
@ -93,3 +94,12 @@ export function stringToColor(string: string) {
return color; return color;
} }
export function stringAvatar(name: string) {
return {
sx: {
bgcolor: stringToColor(name),
},
children: `${name.split('')[0]}`,
};
}

View File

@ -0,0 +1,14 @@
import { EmojiMartData } from '@emoji-mart/data';
export async function randomEmoji(skin = 0) {
const emojiData = await loadEmojiData();
const emojis = (emojiData as EmojiMartData).emojis;
const keys = Object.keys(emojis);
const randomKey = keys[Math.floor(Math.random() * keys.length)];
return emojis[randomKey].skins[skin].native;
}
export async function loadEmojiData() {
return import('@emoji-mart/data/sets/15/all.json');
}

View File

@ -0,0 +1,20 @@
export function inView(dom: HTMLElement, container: HTMLElement) {
const domRect = dom.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (!domRect || !containerRect) return true;
return domRect?.bottom <= containerRect?.bottom && domRect?.top >= containerRect?.top;
}
export function getDistanceEdge(dom: HTMLElement, container: HTMLElement) {
const domRect = dom.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (!domRect || !containerRect) return 0;
const distanceTop = domRect?.top - containerRect?.top;
const distanceBottom = domRect?.bottom - containerRect?.bottom;
return Math.abs(distanceTop) < Math.abs(distanceBottom) ? distanceTop : distanceBottom;
}

View File

@ -112,6 +112,7 @@ export default defineConfig({
id.includes('/react-is@') || id.includes('/react-is@') ||
id.includes('/yjs@') || id.includes('/yjs@') ||
id.includes('/y-indexeddb@') || id.includes('/y-indexeddb@') ||
id.includes('/dexie') ||
id.includes('/redux') || id.includes('/redux') ||
id.includes('/react-custom-scrollbars') id.includes('/react-custom-scrollbars')
) { ) {
@ -140,9 +141,7 @@ export default defineConfig({
include: [ include: [
'react', 'react',
'react-dom', 'react-dom',
'@mui/icons-material/ErrorOutline', '@mui/icons-material/Circle',
'@mui/icons-material/CheckCircleOutline',
'@mui/icons-material/FunctionsOutlined',
'react-katex', 'react-katex',
// 'react-custom-scrollbars-2', // 'react-custom-scrollbars-2',
// 'react-window', // 'react-window',

View File

@ -2163,5 +2163,50 @@
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
"signInError": "Sign in error", "signInError": "Sign in error",
"login": "Sign up or log in" "login": "Sign up or log in"
},
"globalComment": {
"comments": "Comments",
"addComment": "Add a comment",
"reactedBy": "reacted by",
"addReaction": "Add reaction",
"reactedByMore": "and {count} others",
"showSeconds": {
"one": "1 second ago",
"other": "{count} seconds ago",
"zero": "Just now",
"many": "{count} seconds ago"
},
"showMinutes": {
"one": "1 minute ago",
"other": "{count} minutes ago",
"many": "{count} minutes ago"
},
"showHours": {
"one": "1 hour ago",
"other": "{count} hours ago",
"many": "{count} hours ago"
},
"showDays": {
"one": "1 day ago",
"other": "{count} days ago",
"many": "{count} days ago"
},
"showMonths": {
"one": "1 month ago",
"other": "{count} months ago",
"many": "{count} months ago"
},
"showYears": {
"one": "1 year ago",
"other": "{count} years ago",
"many": "{count} years ago"
},
"reply": "Reply",
"deleteComment": "Delete comment",
"youAreNotOwner": "You are not the owner of this comment",
"confirmDeleteDescription": "Are you sure you want to delete this comment?",
"hasBeenDeleted": "Deleted",
"replyingTo": "Replying to",
"noAccessDeleteComment": "You're not allowed to delete this comment"
} }
} }