diff --git a/src/components/repo/IssueComments.tsx b/src/components/repo/IssueComments.tsx new file mode 100644 index 0000000..73c45a3 --- /dev/null +++ b/src/components/repo/IssueComments.tsx @@ -0,0 +1,132 @@ +import { parseResourceUri } from '@atcute/lexicons' +import { isDid } from '@atcute/lexicons/syntax' +import { IconMessageCircle } from '@tabler/icons-react' +import { Link } from 'react-router-dom' +import { useIssueComments } from '../../hooks/useIssueComments' +import { useRecordActors } from '../../hooks/useRecordActors' +import type { ResolvedActor } from '../../lib/actor' +import type { CommentRecord } from '../../lib/tangled/feed' +import { ProfileAvatar } from '../profile/ProfileAvatar' +import { MarkdownContent } from '../shared/MarkdownContent' +import { SurfaceCard } from '../shared/SurfaceCard' + +const EMPTY_COMMENTS: readonly CommentRecord[] = [] +const COMMENT_SKELETON_COUNT = 3 + +type IssueCommentsProps = { + issueUri: CommentRecord['value']['subject']['uri'] +} + +export function IssueComments({ issueUri }: IssueCommentsProps) { + const { comments, error } = useIssueComments(issueUri) + const { actors, error: actorsError } = useRecordActors(comments?.items ?? EMPTY_COMMENTS) + + if (error) { + return

Could not load comments: {error.message}

+ } + + if (comments === null) { + return + } + + if (comments.items.length === 0) { + return

No comments yet.

+ } + + return ( +
+

+

+ {actorsError && ( +

Some commenter profiles could not be resolved.

+ )} +
    + {comments.items.map((comment) => { + const authorIdentifier = parseResourceUri(comment.uri).repo + const author = isDid(authorIdentifier) ? actors?.get(authorIdentifier) : undefined + + return ( +
  1. + +
  2. + ) + })} +
+
+ ) +} + +function IssueCommentsSkeleton() { + return ( +
+

+

+

+ Loading comments... +

+ +
+ ) +} + +type CommentViewProps = { + author?: ResolvedActor + comment: CommentRecord +} + +function CommentView({ author, comment }: CommentViewProps) { + const authorDid = parseResourceUri(comment.uri).repo + const authorLabel = author?.miniDoc.handle ?? authorDid + + return ( + +
+ {author === undefined ? ( + {authorLabel} + ) : ( + + + {authorLabel} + + )} + +
+ + {comment.value.body.text} + +
+ ) +} diff --git a/src/hooks/useIssueComments.ts b/src/hooks/useIssueComments.ts new file mode 100644 index 0000000..55cbe6d --- /dev/null +++ b/src/hooks/useIssueComments.ts @@ -0,0 +1,36 @@ +import type { ResourceUri } from '@atcute/lexicons' +import { useEffect, useState } from 'react' +import { listComments, type CommentList } from '../lib/tangled/feed' + +const INITIAL_COMMENT_LIMIT = 50 + +export function useIssueComments(issue: ResourceUri) { + const [comments, setComments] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let isCancelled = false + + async function loadComments() { + setComments(null) + setError(null) + + try { + const result = await listComments(issue, { limit: INITIAL_COMMENT_LIMIT, order: 'asc' }) + if (!isCancelled) setComments(result) + } catch (caught) { + if (!isCancelled) { + setError(caught instanceof Error ? caught : new Error('Unable to load comments.')) + } + } + } + + void loadComments() + + return () => { + isCancelled = true + } + }, [issue]) + + return { comments, error } +} diff --git a/src/hooks/useRecordActors.ts b/src/hooks/useRecordActors.ts new file mode 100644 index 0000000..91f771d --- /dev/null +++ b/src/hooks/useRecordActors.ts @@ -0,0 +1,76 @@ +import { parseResourceUri, type Did, type ResourceUri } from '@atcute/lexicons' +import { isDid } from '@atcute/lexicons/syntax' +import { useEffect, useMemo, useState } from 'react' +import { resolveActor, type ResolvedActor } from '../lib/actor' + +const MAX_CONCURRENT_ACTOR_REQUESTS = 3 + +type RecordWithUri = { + uri: ResourceUri +} + +type ActorsState = { + actors: Map + error: Error | null + key: string +} + +export function useRecordActors(records: readonly RecordWithUri[]) { + const actorDids = useMemo( + () => + [...new Set(records.map(({ uri }) => parseResourceUri(uri).repo))].flatMap((identifier) => + isDid(identifier) ? [identifier] : [], + ), + [records], + ) + const actorsKey = [...actorDids].sort().join(',') + const [state, setState] = useState(null) + + useEffect(() => { + let isCancelled = false + + void resolveActors(actorDids).then(({ actors, hasFailures }) => { + if (isCancelled) return + + setState({ + actors, + error: hasFailures ? new Error('Some record authors could not be resolved') : null, + key: actorsKey, + }) + }) + + return () => { + isCancelled = true + } + }, [actorDids, actorsKey]) + + const isCurrentList = state?.key === actorsKey + return { + actors: isCurrentList ? (state.actors ?? null) : null, + error: isCurrentList ? (state.error ?? null) : null, + } +} + +async function resolveActors(actorDids: Did[]) { + const actors = new Map() + let hasFailures = false + let nextActorIndex = 0 + const workerCount = Math.min(MAX_CONCURRENT_ACTOR_REQUESTS, actorDids.length) + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextActorIndex < actorDids.length) { + const actorDid = actorDids[nextActorIndex] + nextActorIndex += 1 + + try { + actors.set(actorDid, await resolveActor(actorDid)) + } catch { + hasFailures = true + } + } + }), + ) + + return { actors, hasFailures } +} diff --git a/src/lib/tangled/feed/index.ts b/src/lib/tangled/feed/index.ts index 79440c4..d500a39 100644 --- a/src/lib/tangled/feed/index.ts +++ b/src/lib/tangled/feed/index.ts @@ -1,5 +1,13 @@ export { countStars } from './countStars' export { countStarsBy } from './countStarsBy' +export { listComments } from './listComments' export { listStarsBy } from './listStarsBy' export { getRepoDidsFromStars } from './types' -export type { ListStarsOptions, StarList, StarRecord } from './types' +export type { + CommentList, + CommentRecord, + ListCommentsOptions, + ListStarsOptions, + StarList, + StarRecord, +} from './types' diff --git a/src/lib/tangled/feed/listComments.ts b/src/lib/tangled/feed/listComments.ts new file mode 100644 index 0000000..964091e --- /dev/null +++ b/src/lib/tangled/feed/listComments.ts @@ -0,0 +1,42 @@ +import { ok } from '@atcute/client' +import type { ResourceUri } from '@atcute/lexicons' +import { safeParse } from '@atcute/lexicons' +import { mainSchema as commentSchema } from '@atcute/tangled/types/feed/comment' +import { mainSchema as commentListSchema } from '@atcute/tangled/types/feed/listComments' +import { rpc } from '../client' +import { removeNullCursor } from '../utils' +import type { CommentList, ListCommentsOptions } from './types' + +export async function listComments( + subject: ResourceUri, + options: ListCommentsOptions = {}, +): Promise { + const response = await ok( + rpc.get('sh.tangled.feed.listComments', { + params: { + subject, + cursor: options.cursor, + limit: options.limit, + order: options.order, + }, + }), + ) + + const listValidation = safeParse(commentListSchema.output.schema, removeNullCursor(response)) + if (!listValidation.ok) { + throw new Error(`Bobbin returned an invalid comment list: ${listValidation.message}`) + } + + const items = listValidation.value.items.map((item, index) => { + const valueValidation = safeParse(commentSchema, item.value) + if (!valueValidation.ok) { + throw new Error( + `Bobbin returned an invalid comment record at index ${index}: ${valueValidation.message}`, + ) + } + + return { ...item, value: valueValidation.value } + }) + + return { items, cursor: listValidation.value.cursor } +} diff --git a/src/lib/tangled/feed/types.ts b/src/lib/tangled/feed/types.ts index 73cebbb..ec20c5d 100644 --- a/src/lib/tangled/feed/types.ts +++ b/src/lib/tangled/feed/types.ts @@ -1,5 +1,22 @@ import type { Cid, Did, ResourceUri } from '@atcute/lexicons' +import type { Main as TangledComment } from '@atcute/tangled/types/feed/comment' import type { Main as TangledStar } from '@atcute/tangled/types/feed/star' +import type { $output as CommentListResponse } from '@atcute/tangled/types/feed/listComments' + +export type CommentRecord = Omit & { + value: TangledComment +} + +export type CommentList = { + items: CommentRecord[] + cursor?: string +} + +export type ListCommentsOptions = { + cursor?: string + limit?: number + order?: 'asc' | 'desc' +} export type StarRecord = { cid?: Cid diff --git a/src/pages/IssuePage.tsx b/src/pages/IssuePage.tsx index 327cd8d..526e777 100644 --- a/src/pages/IssuePage.tsx +++ b/src/pages/IssuePage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom' import { PageContainer } from '../components/layout/PageContainer' import { ProfileByline } from '../components/profile/ProfileByline' +import { IssueComments } from '../components/repo/IssueComments' import { ErrorPage } from '../components/shared/ErrorPage' import { RepoRecordView } from '../components/repo/RepoRecordView' import { RepoPageSkeleton } from '../components/shared/PageSkeletons' @@ -66,8 +67,9 @@ export function IssuePage() { { label: issue.value.title }, ]} /> -
+
+