Skip to content

Commit c605869

Browse files
authored
Feat: v2 comment improvements (#4031)
feat: comments v2 scroll into view feat: filter deleted with no replies and deleted replies feat: allow replies to deleted comments when there are already other replies
1 parent ab1ac50 commit c605869

File tree

5 files changed

+153
-55
lines changed

5 files changed

+153
-55
lines changed

src/models/comment.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export class Comment {
9393
sourceType: string
9494
parentId: number | null
9595
deleted: boolean | null
96+
highlighted?: boolean
9697
replies?: Reply[]
9798

9899
constructor(obj: Comment) {

src/pages/common/CommentsV2/CommentItemV2.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createRef, useEffect, useMemo, useState } from 'react'
1+
import { createRef, useEffect, useMemo, useRef, useState } from 'react'
22
import { compareDesc } from 'date-fns'
33
import { observer } from 'mobx-react'
44
import {
@@ -43,11 +43,14 @@ export const CommentItemV2 = observer(
4343
onDeleteReply,
4444
}: ICommentItemProps) => {
4545
const textRef = createRef<any>()
46+
const commentRef = useRef<HTMLDivElement>()
4647
const [showEditModal, setShowEditModal] = useState(false)
4748
const [showDeleteModal, setShowDeleteModal] = useState(false)
4849
const [textHeight, setTextHeight] = useState(0)
4950
const [isShowMore, setShowMore] = useState(false)
50-
const [showReplies, setShowReplies] = useState(false)
51+
const [showReplies, setShowReplies] = useState(
52+
() => !!comment.replies?.some((x) => x.highlighted),
53+
)
5154
const { userStore } = useCommonStores().stores
5255

5356
const maxHeight = isShowMore ? 'max-content' : '128px'
@@ -71,24 +74,43 @@ export const CommentItemV2 = observer(
7174
}, [textRef])
7275

7376
const showMore = () => {
74-
setShowMore(!isShowMore)
77+
setShowMore((prev) => !prev)
7578
}
7679

80+
useEffect(() => {
81+
if (comment.highlighted) {
82+
commentRef.current?.scrollIntoView({
83+
behavior: 'smooth',
84+
block: 'center',
85+
})
86+
}
87+
}, [comment.highlighted])
88+
7789
return (
7890
<Flex
7991
id={`comment:${comment.id}`}
8092
data-cy={isEditable ? `Own${item}` : item}
8193
sx={{ flexDirection: 'column' }}
8294
>
83-
<Flex sx={{ gap: 2, flexDirection: 'column' }}>
84-
{comment.deleted && (
85-
<Box sx={{ marginBottom: 2 }} data-cy="deletedComment">
95+
<Flex sx={{ gap: 2, flexDirection: 'column' }} ref={commentRef as any}>
96+
{comment.deleted ? (
97+
<Box
98+
sx={{
99+
marginBottom: 2,
100+
border: `${comment.highlighted ? '2px dashed black' : 'none'}`,
101+
}}
102+
data-cy="deletedComment"
103+
>
86104
<Text sx={{ color: 'grey' }}>[{DELETED_COMMENT}]</Text>
87105
</Box>
88-
)}
89-
90-
{!comment.deleted && (
91-
<Flex sx={{ gap: 2, flexGrow: 1 }}>
106+
) : (
107+
<Flex
108+
sx={{
109+
gap: 2,
110+
flexGrow: 1,
111+
border: `${comment.highlighted ? '2px dashed black' : 'none'}`,
112+
}}
113+
>
92114
<Box data-cy="commentAvatar" data-testid="commentAvatar">
93115
<CommentAvatar
94116
name={comment.createdBy?.name}
@@ -219,12 +241,10 @@ export const CommentItemV2 = observer(
219241
/>
220242
))}
221243

222-
{!comment.deleted && (
223-
<CreateCommentV2
224-
onSubmit={(comment) => onReply(comment)}
225-
buttonLabel="Leave a reply"
226-
/>
227-
)}
244+
<CreateCommentV2
245+
onSubmit={(comment) => onReply(comment)}
246+
buttonLabel="Leave a reply"
247+
/>
228248
</>
229249
)}
230250
<ButtonShowReplies

src/pages/common/CommentsV2/CommentReply.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createRef, useEffect, useMemo, useState } from 'react'
1+
import { createRef, useEffect, useMemo, useRef, useState } from 'react'
22
import { compareDesc } from 'date-fns'
33
import { observer } from 'mobx-react'
44
import {
@@ -30,6 +30,7 @@ export interface ICommentItemProps {
3030
export const CommentReply = observer(
3131
({ comment, onEdit, onDelete }: ICommentItemProps) => {
3232
const textRef = createRef<any>()
33+
const commentRef = useRef<HTMLDivElement>()
3334
const [showEditModal, setShowEditModal] = useState(false)
3435
const [showDeleteModal, setShowDeleteModal] = useState(false)
3536
const [textHeight, setTextHeight] = useState(0)
@@ -60,6 +61,15 @@ export const CommentReply = observer(
6061
setShowMore(!isShowMore)
6162
}
6263

64+
useEffect(() => {
65+
if (comment.highlighted) {
66+
commentRef.current?.scrollIntoView({
67+
behavior: 'smooth',
68+
block: 'center',
69+
})
70+
}
71+
}, [comment.highlighted])
72+
6373
return (
6474
<Flex>
6575
<Box
@@ -75,15 +85,25 @@ export const CommentReply = observer(
7585
data-cy={isEditable ? `Own${item}` : item}
7686
sx={{ flexDirection: 'column', width: '100%' }}
7787
>
78-
<Flex sx={{ gap: 2 }}>
79-
{comment.deleted && (
80-
<Box sx={{ marginBottom: 2 }} data-cy="deletedComment">
88+
<Flex sx={{ gap: 2 }} ref={commentRef as any}>
89+
{comment.deleted ? (
90+
<Box
91+
sx={{
92+
marginBottom: 2,
93+
border: `${comment.highlighted ? '2px dashed black' : 'none'}`,
94+
}}
95+
data-cy="deletedComment"
96+
>
8197
<Text sx={{ color: 'grey' }}>[{DELETED_COMMENT}]</Text>
8298
</Box>
83-
)}
84-
85-
{!comment.deleted && (
86-
<Flex sx={{ gap: 2, flexGrow: 1 }}>
99+
) : (
100+
<Flex
101+
sx={{
102+
gap: 2,
103+
flexGrow: 1,
104+
border: `${comment.highlighted ? '2px dashed black' : 'none'}`,
105+
}}
106+
>
87107
<Box data-cy="commentAvatar" data-testid="commentAvatar">
88108
<CommentAvatar
89109
name={comment.createdBy?.name}

src/pages/common/CommentsV2/CommentSectionV2.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,28 @@ const CommentSectionV2 = ({ sourceId }: CommentsV2Props) => {
4040
const fetchComments = async () => {
4141
try {
4242
const result = await fetch(`/api/discussions/${sourceId}/comments`)
43-
const { comments } = await result.json()
43+
const { comments } = (await result.json()) as { comments: Comment[] }
44+
45+
const highlightedCommentId = location.hash.replace('#comment:', '')
46+
47+
if (highlightedCommentId) {
48+
const highlightedComment = comments.find(
49+
(x) => x.id === +highlightedCommentId,
50+
)
51+
if (highlightedComment) {
52+
highlightedComment.highlighted = true
53+
} else {
54+
// find in replies and set highlighted
55+
const highlightedReply = comments
56+
.flatMap((x) => x.replies)
57+
.find((x) => x?.id === +highlightedCommentId)
58+
59+
if (highlightedReply) {
60+
highlightedReply.highlighted = true
61+
}
62+
}
63+
}
64+
4465
setComments(comments || [])
4566
} catch (err) {
4667
console.error(err)

src/routes/api.discussions.$sourceId.comments.ts

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { json, type LoaderFunctionArgs } from '@remix-run/node'
21
import { verifyFirebaseToken } from 'src/firestore/firestoreAdmin.server'
32
import { Comment, DBComment } from 'src/models/comment.model'
43
import { createSupabaseServerClient } from 'src/repository/supabase.server'
54
import { notificationsService } from 'src/services/notificationsService.server'
65

6+
import type { LoaderFunctionArgs } from '@remix-run/node'
7+
import type { Params } from '@remix-run/react'
78
import type { DBCommentAuthor, Reply } from 'src/models/comment.model'
89
import type { DBProfile } from 'src/models/profile.model'
910

1011
export async function loader({ params, request }: LoaderFunctionArgs) {
1112
if (!params.sourceId) {
12-
return json({}, { status: 400, statusText: 'sourceId is required' })
13+
return Response.json(
14+
{},
15+
{ status: 400, statusText: 'sourceId is required' },
16+
)
1317
}
1418

1519
const { client, headers } = createSupabaseServerClient(request)
@@ -40,7 +44,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
4044
if (result.error) {
4145
console.error(result.error)
4246

43-
return json({}, { headers, status: 500 })
47+
return Response.json({}, { headers, status: 500 })
4448
}
4549

4650
const dbComments = result.data.map(
@@ -67,55 +71,53 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
6771
)
6872
return Comment.fromDB(
6973
mainComment,
70-
replies.sort((a, b) => a.id - b.id),
74+
replies.filter((x) => !x.deleted).sort((a, b) => a.id - b.id),
7175
)
7276
},
7377
)
7478

75-
return json({ comments: commentWithReplies }, { headers })
79+
// remove deleted comments that don't have replies
80+
const deletedFilter = commentWithReplies.filter(
81+
(comment: Comment) =>
82+
!comment.deleted || (comment.replies?.length || 0) > 0,
83+
)
84+
85+
return Response.json({ comments: deletedFilter }, { headers })
7686
}
7787

7888
export async function action({ params, request }: LoaderFunctionArgs) {
79-
const { valid, user_id } = await verifyFirebaseToken(
89+
const tokenValidation = await verifyFirebaseToken(
8090
request.headers.get('firebaseToken')!,
8191
)
8292

83-
if (!valid) {
84-
return json({}, { status: 401, statusText: 'unauthorized' })
85-
}
86-
87-
if (!user_id) {
88-
return json({}, { status: 400, statusText: 'user not found' })
89-
}
90-
91-
if (!params.sourceId) {
92-
return json({}, { status: 400, statusText: 'sourceId is required' })
93-
}
94-
95-
if (request.method !== 'POST') {
96-
return json({}, { status: 405, statusText: 'method not allowed' })
97-
}
98-
93+
const userId = tokenValidation.user_id
9994
const data = await request.json()
10095

101-
if (!data.comment) {
102-
return json({}, { status: 400, statusText: 'comment is required' })
103-
}
96+
const { valid, status, statusText } = await validateRequest(
97+
params,
98+
request,
99+
tokenValidation.valid,
100+
userId,
101+
data,
102+
)
104103

105-
if (!data.sourceType) {
106-
return json({}, { status: 400, statusText: 'sourceType is required' })
104+
if (!valid) {
105+
return Response.json({}, { status, statusText })
107106
}
108107

109108
const { client, headers } = createSupabaseServerClient(request)
110109

111110
const currentUser = await client
112111
.from('profiles')
113112
.select()
114-
.eq('firebase_auth_id', user_id)
113+
.eq('firebase_auth_id', userId)
115114
.single()
116115

117116
if (currentUser.error || !currentUser.data) {
118-
return json({}, { status: 400, statusText: 'profile not found ' + user_id })
117+
return Response.json(
118+
{},
119+
{ status: 400, statusText: 'profile not found ' + userId },
120+
)
119121
}
120122

121123
const newComment = {
@@ -158,7 +160,7 @@ export async function action({ params, request }: LoaderFunctionArgs) {
158160
)
159161
}
160162

161-
return json(
163+
return Response.json(
162164
new DBComment({
163165
...(commentResult.data as DBComment),
164166
profile: (commentResult.data as any).profiles as DBCommentAuthor,
@@ -169,3 +171,37 @@ export async function action({ params, request }: LoaderFunctionArgs) {
169171
},
170172
)
171173
}
174+
175+
async function validateRequest(
176+
params: Params<string>,
177+
request: Request,
178+
isTokenValid: boolean,
179+
userId: string,
180+
data: any,
181+
) {
182+
if (!isTokenValid) {
183+
return { status: 401, statusText: 'unauthorized' }
184+
}
185+
186+
if (!userId) {
187+
return { status: 400, statusText: 'user not found' }
188+
}
189+
190+
if (!params.sourceId) {
191+
return { status: 400, statusText: 'sourceId is required' }
192+
}
193+
194+
if (request.method !== 'POST') {
195+
return { status: 405, statusText: 'method not allowed' }
196+
}
197+
198+
if (!data.comment) {
199+
return { status: 400, statusText: 'comment is required' }
200+
}
201+
202+
if (!data.sourceType) {
203+
return { status: 400, statusText: 'sourceType is required' }
204+
}
205+
206+
return { valid: true }
207+
}

0 commit comments

Comments
 (0)