11import './Comment.css'
22
33import type { CommentModel } from '@mx-space/api-client'
4+ import { useMutation , useQueryClient } from '@tanstack/react-query'
45import clsx from 'clsx'
56import { atom , useAtomValue } from 'jotai'
67import { m } from 'motion/react'
8+ import { useTranslations } from 'next-intl'
79import type { FC , PropsWithChildren } from 'react'
810import {
911 createContext ,
@@ -13,6 +15,7 @@ import {
1315 useLayoutEffect ,
1416 useMemo ,
1517 useRef ,
18+ useState ,
1619} from 'react'
1720import { createPortal } from 'react-dom'
1821
@@ -26,9 +29,12 @@ import {
2629} from '~/components/ui/user/UserAuthStrategyIcon'
2730import { softSpringPreset } from '~/constants/spring'
2831import type { AuthSocialProviders } from '~/lib/authjs'
32+ import { apiClient } from '~/lib/request'
2933import { jotaiStore } from '~/lib/store'
34+ import { buildCommentsQueryKey } from '~/queries/keys'
3035
3136import { CommentActionButtonGroup } from './CommentActionButtonGroup'
37+ import { useCommentBoxRefIdValue } from './CommentBox/hooks'
3238import { CommentMarkdown } from './CommentMarkdown'
3339import { CommentPinButton , OcticonGistSecret } from './CommentPinButton'
3440import {
@@ -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
4254export 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} )
5466const 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+
247339const CommentHolderContext = createContext ( atom ( null as null | HTMLDivElement ) )
248340
249341const CommentBoxHolderProvider = ( ) => {
@@ -270,6 +362,7 @@ export const CommentBoxHolderPortal = (props: PropsWithChildren) => {
270362const 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