Skip to content

Commit bfcf889

Browse files
committed
feat(comment): sync upstream thread comment module
1 parent 91508fa commit bfcf889

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+4050
-939
lines changed

apps/web/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,19 @@
2424
"start": "npm run dev"
2525
},
2626
"dependencies": {
27+
"@base-ui/react": "1.3.0",
2728
"@excalidraw/excalidraw": "0.17.0",
2829
"@ferrucc-io/emoji-picker": "0.0.48",
2930
"@floating-ui/react-dom": "2.1.6",
31+
"@haklex/rich-editor": "0.0.85",
3032
"@haklex/rich-kit-shiro": "0.0.74",
31-
"@mx-space/api-client": "2.1.1",
33+
"@lexical/code": "^0.41.0",
34+
"@lexical/link": "^0.41.0",
35+
"@lexical/list": "^0.41.0",
36+
"@lexical/markdown": "^0.41.0",
37+
"@lexical/react": "^0.41.0",
38+
"@lexical/rich-text": "^0.41.0",
39+
"@mx-space/api-client": "2.3.0",
3240
"@radix-ui/react-avatar": "1.1.11",
3341
"@radix-ui/react-checkbox": "^1.3.3",
3442
"@radix-ui/react-dialog": "1.1.15",
@@ -151,6 +159,7 @@
151159
"taze": "19.9.2",
152160
"tw-animate-css": "^1.4.0",
153161
"typescript": "5.9.3",
162+
"vitest": "^4.1.0",
154163
"zx": "8.8.5"
155164
},
156165
"nextBundleAnalysis": {

apps/web/src/components/modules/comment/Comment.tsx

Lines changed: 130 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import './Comment.css'
22

33
import type { CommentModel } from '@mx-space/api-client'
4+
import { useMutation, useQueryClient } from '@tanstack/react-query'
45
import clsx from 'clsx'
56
import { atom, useAtomValue } from 'jotai'
67
import { m } from 'motion/react'
8+
import { useTranslations } from 'next-intl'
79
import type { FC, PropsWithChildren } from 'react'
810
import {
911
createContext,
@@ -13,6 +15,7 @@ import {
1315
useLayoutEffect,
1416
useMemo,
1517
useRef,
18+
useState,
1619
} from 'react'
1720
import { createPortal } from 'react-dom'
1821

@@ -26,9 +29,12 @@ import {
2629
} from '~/components/ui/user/UserAuthStrategyIcon'
2730
import { softSpringPreset } from '~/constants/spring'
2831
import type { AuthSocialProviders } from '~/lib/authjs'
32+
import { apiClient } from '~/lib/request'
2933
import { jotaiStore } from '~/lib/store'
34+
import { buildCommentsQueryKey } from '~/queries/keys'
3035

3136
import { CommentActionButtonGroup } from './CommentActionButtonGroup'
37+
import { useCommentBoxRefIdValue } from './CommentBox/hooks'
3238
import { CommentMarkdown } from './CommentMarkdown'
3339
import { CommentPinButton, OcticonGistSecret } from './CommentPinButton'
3440
import {
@@ -38,6 +44,12 @@ import {
3844
useCommentMarkdownContainerRef,
3945
useCommentReader,
4046
} from './CommentProvider'
47+
import {
48+
type CommentThreadInfiniteData,
49+
type CommentThreadViewItem,
50+
mergeThreadRepliesIntoPages,
51+
} from './thread'
52+
import type { CommentAnchor } from './types'
4153

4254
export const Comment: Component<{
4355
commentId: string
@@ -49,12 +61,13 @@ export const Comment: Component<{
4961
if (!comment) return null
5062
// FIXME 兜一下后端给的脏数据
5163
if (typeof comment === 'string') return null
52-
return <CommentRender comment={comment} className={className} />
64+
return <CommentRender className={className} comment={comment} />
5365
})
5466
const CommentRender: Component<{
55-
comment: CommentModel & { new?: boolean }
67+
comment: CommentThreadViewItem
5668
}> = (props) => {
5769
const { comment, className } = props
70+
const t = useTranslations('comment')
5871

5972
const elAtom = useMemo(() => atom<HTMLDivElement | null>(null), [])
6073
const isSingleLinkContent = useMemo(() => {
@@ -71,7 +84,6 @@ const CommentRender: Component<{
7184
id: cid,
7285

7386
text,
74-
key,
7587
location,
7688
isWhispers,
7789
url,
@@ -80,8 +92,8 @@ const CommentRender: Component<{
8092

8193
const avatar = reader?.image || comment.avatar
8294
const author = reader?.name || comment.author
83-
const parentId =
84-
typeof comment.parent === 'string' ? comment.parent : comment.parent?.id
95+
const parentId = comment.parentCommentId ?? null
96+
const displayText = comment.isDeleted ? t('deleted_placeholder') : text
8597

8698
const authorUrl = useMemo(() => {
8799
if (url) return url
@@ -93,31 +105,46 @@ const CommentRender: Component<{
93105

94106
const authorElement = authorUrl ? (
95107
<a
96-
href={authorUrl}
97108
className="max-w-full shrink-0 break-all"
98-
target="_blank"
109+
href={authorUrl}
99110
rel="noreferrer"
111+
target="_blank"
100112
>
101113
{author}
102114
</a>
103115
) : (
104116
<span className="max-w-full shrink-0 break-all">{author}</span>
105117
)
106118

119+
const { anchor } = comment as CommentModel & { anchor?: CommentAnchor }
120+
107121
const CommentNormalContent = (
108122
<div
109123
className={clsx(
110124
'comment__message',
111-
'relative inline-block rounded-xl text-zinc-800 dark:text-zinc-200',
112-
'bg-zinc-600/5 dark:bg-zinc-500/20',
125+
'relative inline-block rounded-xl text-neutral-9',
126+
'bg-neutral-600/5 dark:bg-neutral-500/20',
113127
'max-w-[calc(100%-3rem)]',
114128
'rounded-tl-sm md:rounded-bl-sm md:rounded-tl-xl',
115129
'ml-4 px-3 py-2 md:ml-0',
116-
// 'prose-ol:list-inside prose-ul:list-inside',
117130
)}
118131
>
132+
{anchor?.mode === 'range' && (
133+
<div className="mb-1.5 border-l-2 border-accent/40 pl-2 text-xs italic text-neutral-400 dark:text-neutral-500">
134+
{anchor.quote}
135+
</div>
136+
)}
137+
{anchor?.mode === 'block' && (
138+
<div className="mb-1.5 text-xs text-neutral-400 dark:text-neutral-500">
139+
<span>
140+
{t('commented_on_block', {
141+
text: `${anchor.snapshotText.slice(0, 40)}${anchor.snapshotText.length > 40 ? '…' : ''}`,
142+
})}
143+
</span>
144+
</div>
145+
)}
119146
<CommentMarkdownContainerRefContext>
120-
<CommentMarkdown>{text}</CommentMarkdown>
147+
<CommentMarkdown>{displayText}</CommentMarkdown>
121148

122149
<EditedCommentFooter commentId={comment.id} />
123150
</CommentMarkdownContainerRefContext>
@@ -129,6 +156,16 @@ const CommentRender: Component<{
129156
<>
130157
<CommentHolderContext value={elAtom}>
131158
<m.li
159+
className={clsx('relative my-2', className)}
160+
data-comment-id={cid}
161+
data-parent-id={parentId}
162+
data-reader-id={comment.readerId}
163+
transition={softSpringPreset}
164+
animate={{
165+
opacity: 1,
166+
y: 0,
167+
scale: 1,
168+
}}
132169
initial={
133170
comment['new']
134171
? {
@@ -138,16 +175,6 @@ const CommentRender: Component<{
138175
}
139176
: true
140177
}
141-
transition={softSpringPreset}
142-
animate={{
143-
opacity: 1,
144-
y: 0,
145-
scale: 1,
146-
}}
147-
data-comment-id={cid}
148-
data-reader-id={comment.readerId}
149-
data-parent-id={parentId}
150-
className={clsx('relative my-2', className)}
151178
>
152179
<div className="group flex w-full items-stretch gap-4">
153180
<div
@@ -157,17 +184,17 @@ const CommentRender: Component<{
157184
)}
158185
>
159186
<Avatar
160-
shadow={false}
187+
alt={t('avatar_alt', { author })}
188+
className="size-6 select-none rounded-full bg-neutral-200 ring-2 ring-neutral-200 dark:bg-neutral-800 dark:ring-neutral-800 md:size-9"
161189
imageUrl={avatar}
162-
alt={`${author}'s avatar`}
163-
className="size-6 select-none rounded-full bg-zinc-200 ring-2 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-800 md:size-9"
190+
shadow={false}
164191
/>
165192
{source &&
166193
!!getStrategyIconComponent(source as AuthSocialProviders) && (
167-
<div className="center absolute -right-1.5 bottom-1 flex size-3.5 rounded-full bg-white ring-[1.5px] ring-zinc-200 dark:bg-zinc-800 dark:ring-black">
194+
<div className="center absolute -right-1.5 bottom-1 flex size-3.5 rounded-full bg-neutral-50 ring-[1.5px] ring-neutral-200 dark:bg-neutral-800 dark:ring-neutral-950">
168195
<UserAuthStrategyIcon
169-
strategy={source as AuthSocialProviders}
170196
className="size-3"
197+
strategy={source as AuthSocialProviders}
171198
/>
172199
</div>
173200
)}
@@ -182,7 +209,7 @@ const CommentRender: Component<{
182209
>
183210
<span
184211
className={clsx(
185-
'flex items-center gap-2 font-semibold text-zinc-800 dark:text-zinc-200',
212+
'flex items-center gap-2 font-semibold text-neutral-9',
186213
'relative w-full min-w-0 justify-center',
187214
'mb-2 pl-7 md:pl-0',
188215
)}
@@ -193,12 +220,9 @@ const CommentRender: Component<{
193220
<span className="inline-flex shrink-0 text-[0.71rem] font-medium opacity-40">
194221
<RelativeTime date={comment.created} />
195222
</span>
196-
<span className="break-all text-[0.71rem] opacity-30">
197-
{key}
198-
</span>
199223
{!!location && (
200224
<span className="min-w-0 max-w-full truncate break-all text-[0.71rem] opacity-35">
201-
来自:{location}
225+
{t('from_location', { location })}
202226
</span>
203227
)}
204228
{!!isWhispers && <OcticonGistSecret />}
@@ -214,14 +238,14 @@ const CommentRender: Component<{
214238
{isSingleLinkContent ? (
215239
<div className="relative inline-block">
216240
<BlockLinkRenderer
241+
fallback={CommentNormalContent}
217242
href={text}
218243
accessory={
219244
<CommentActionButtonGroup
220-
commentId={comment.id}
221245
className="bottom-4"
246+
commentId={comment.id}
222247
/>
223248
}
224-
fallback={CommentNormalContent}
225249
/>
226250
</div>
227251
) : (
@@ -233,17 +257,85 @@ const CommentRender: Component<{
233257

234258
<CommentBoxHolderProvider />
235259
</CommentHolderContext>
236-
{comment.children && comment.children.length > 0 && (
260+
{comment.replyWindow?.hasHidden && (
261+
<LoadMoreRepliesButton comment={comment} />
262+
)}
263+
{comment.children.length > 0 && (
237264
<ul className="my-2 space-y-2">
238265
{comment.children.map((child) => (
239-
<Comment key={child.id} commentId={child.id} className="ml-9" />
266+
<Comment className="ml-9" commentId={child.id} key={child.id} />
240267
))}
241268
</ul>
242269
)}
243270
</>
244271
)
245272
}
246273

274+
const LoadMoreRepliesButton: FC<{
275+
comment: CommentThreadViewItem
276+
}> = ({ comment }) => {
277+
const t = useTranslations('comment')
278+
const queryClient = useQueryClient()
279+
const refId = useCommentBoxRefIdValue()
280+
const [remaining, setRemaining] = useState(
281+
comment.replyWindow?.hiddenCount ?? 0,
282+
)
283+
284+
const { mutateAsync, isPending } = useMutation({
285+
mutationFn: async () => {
286+
return apiClient.comment.getThreadReplies(comment.id, {
287+
cursor: comment.replyWindow?.nextCursor,
288+
size: 10,
289+
})
290+
},
291+
onSuccess: (result) => {
292+
const replyWindow = {
293+
total:
294+
comment.replyWindow?.total ??
295+
comment.replyCount ??
296+
result.replies.length,
297+
returned: (comment.replies?.length ?? 0) + result.replies.length,
298+
threshold: comment.replyWindow?.threshold ?? 20,
299+
hasHidden: !result.done,
300+
hiddenCount: result.remaining,
301+
nextCursor: result.nextCursor,
302+
}
303+
304+
queryClient.setQueryData<CommentThreadInfiniteData>(
305+
buildCommentsQueryKey(refId),
306+
(oldData) => {
307+
if (!oldData) return oldData
308+
return mergeThreadRepliesIntoPages(oldData, {
309+
rootCommentId: comment.id,
310+
replies: result.replies,
311+
replyWindow,
312+
})
313+
},
314+
)
315+
setRemaining(result.remaining)
316+
},
317+
})
318+
319+
if (!comment.replyWindow?.hasHidden) return null
320+
321+
return (
322+
<div className="ml-13 mt-2">
323+
<button
324+
className="cursor-pointer rounded-full border border-neutral-200 px-3 py-1 text-xs text-neutral-500 transition-colors hover:border-neutral-300 hover:text-neutral-800 dark:border-neutral-700 dark:text-neutral-400 dark:hover:border-neutral-600 dark:hover:text-neutral-200"
325+
disabled={isPending}
326+
type="button"
327+
onClick={() => mutateAsync()}
328+
>
329+
{isPending
330+
? t('loading_more_replies')
331+
: t('load_more_replies', {
332+
count: remaining || comment.replyWindow.hiddenCount,
333+
})}
334+
</button>
335+
</div>
336+
)
337+
}
338+
247339
const CommentHolderContext = createContext(atom(null as null | HTMLDivElement))
248340

249341
const CommentBoxHolderProvider = () => {
@@ -270,6 +362,7 @@ export const CommentBoxHolderPortal = (props: PropsWithChildren) => {
270362
const EditedCommentFooter: FC<{
271363
commentId: string
272364
}> = ({ commentId }) => {
365+
const t = useTranslations('common')
273366
const editedAt = useCommentByIdSelector(
274367
commentId,
275368
useCallback((comment) => comment?.editedAt, []),
@@ -291,12 +384,11 @@ const EditedCommentFooter: FC<{
291384
<FloatPopover
292385
type="tooltip"
293386
triggerElement={
294-
<span className="ml-2 text-xs text-zinc-500">(已编辑)</span>
387+
<span className="ml-2 text-xs text-neutral-500">{t('edited')}</span>
295388
}
296389
>
297390
<div>
298-
<span>编辑于</span>
299-
<RelativeTime date={editedAt} />
391+
<span>{t('edited_at')}</span> <RelativeTime date={editedAt} />
300392
</div>
301393
</FloatPopover>
302394
)

0 commit comments

Comments
 (0)