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 (
+ -
+
+
+ )
+ })}
+
+
+ )
+}
+
+function IssueCommentsSkeleton() {
+ return (
+
+
+
+ Loading comments...
+
+
+ {Array.from({ length: COMMENT_SKELETON_COUNT }, (_, index) => (
+ -
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+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 },
]}
/>
-