270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
|
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
|
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
|
|
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
|
|
import { colors } from '../../theme/mobile-theme'
|
|
import { canAddRootComment } from '../../session/pr-comment-actions'
|
|
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
|
|
import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions'
|
|
import { PRSection } from './PRSection'
|
|
import { CommentMarkdown } from './CommentMarkdown'
|
|
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
|
|
import { PRCommentComposer } from './PRCommentComposer'
|
|
import {
|
|
PR_COMMENT_AUDIENCE_FILTERS,
|
|
filterPRCommentsByAudience,
|
|
getPRCommentAudienceCounts,
|
|
getPRCommentAudienceEmptyLabel,
|
|
type PRCommentAudienceFilter
|
|
} from './pr-comment-audience'
|
|
import {
|
|
getPRCommentGroupCount,
|
|
getPRCommentGroupId,
|
|
getPRCommentGroupRoot,
|
|
groupPRComments,
|
|
isResolvedPRCommentGroup,
|
|
type PRCommentGroup
|
|
} from './pr-comment-groups'
|
|
import { prCommentsStyles as styles } from './pr-comments-styles'
|
|
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
|
|
|
|
type Props = {
|
|
details: GitHubWorkItemDetails | null
|
|
// The PR conversation state — gates the root-comment composer (open PRs only).
|
|
prState: PRState | null
|
|
// Repo slug for the slug-addressed comment edit/delete RPCs; threaded into the
|
|
// per-card actions so the edit/delete affordances can gate on its presence.
|
|
prRepo?: GitHubPrRepoSlug | null
|
|
// Interactive comment actions (reply/resolve/add/edit/delete). Absent (e.g.
|
|
// non-PR) leaves the timeline read-only.
|
|
actions?: MobilePrCommentActions
|
|
// Author logins manually marked as bots on desktop; keeps the Humans/Bots
|
|
// tabs classifying the same comments as the desktop panel.
|
|
botAuthorOverrides?: ReadonlySet<string>
|
|
}
|
|
|
|
// Render comments in bounded pages — the whole sidebar is one ScrollView (can't
|
|
// virtualize a nested list), so eagerly rendering a large set parses markdown for
|
|
// every comment synchronously and ANRs the JS thread. Start small, reveal in chunks.
|
|
const COMMENT_PAGE = 12
|
|
|
|
// PR body + full comment timeline, mirroring the desktop PR page: a Description
|
|
// card, then a Comments section with an audience filter (PRs only), threaded
|
|
// review comments, reactions, and collapsible resolved threads.
|
|
export function PRCommentsSection({
|
|
details,
|
|
prState,
|
|
prRepo,
|
|
actions,
|
|
botAuthorOverrides
|
|
}: Props) {
|
|
// details is null while phase 2 (the heavy comments/body payload) is still loading.
|
|
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
|
|
const loadingDetails = details === null
|
|
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
|
|
const body = details?.body ?? ''
|
|
const comments = useMemo(
|
|
() => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []),
|
|
[details]
|
|
)
|
|
const isPr = details != null && !detailsFailed && details.item.type === 'pr'
|
|
|
|
// Per-card action bundle (stable callbacks from the hook) — built once so the
|
|
// memo'd cards don't re-render on unrelated timeline changes.
|
|
const cardActions = useMemo<PRCommentCardActions | undefined>(
|
|
() =>
|
|
actions && isPr
|
|
? {
|
|
reply: actions.reply,
|
|
toggleResolve: actions.toggleResolve,
|
|
editComment: actions.editComment,
|
|
deleteComment: actions.deleteComment,
|
|
isReplyBusy: actions.isReplyBusy,
|
|
isResolveBusy: actions.isResolveBusy,
|
|
isEditBusy: actions.isEditBusy,
|
|
isDeleteBusy: actions.isDeleteBusy,
|
|
prRepo: prRepo ?? null
|
|
}
|
|
: undefined,
|
|
[actions, isPr, prRepo]
|
|
)
|
|
const canComment = isPr && actions !== undefined && canAddRootComment(prState)
|
|
|
|
const [filter, setFilter] = useState<PRCommentAudienceFilter>('all')
|
|
const counts = useMemo(
|
|
() => getPRCommentAudienceCounts(comments, botAuthorOverrides),
|
|
[botAuthorOverrides, comments]
|
|
)
|
|
const visible = useMemo(
|
|
() => filterPRCommentsByAudience(comments, filter, botAuthorOverrides),
|
|
[botAuthorOverrides, comments, filter]
|
|
)
|
|
const groups = useMemo(() => groupPRComments(visible), [visible])
|
|
|
|
// Bounded render window; reset to the first page when the user selects another filter.
|
|
const [limit, setLimit] = useState(COMMENT_PAGE)
|
|
const selectFilter = (nextFilter: PRCommentAudienceFilter): void => {
|
|
if (nextFilter === filter) {
|
|
return
|
|
}
|
|
// Why: paging belongs to the filter-tab event, so reset it in the same batch
|
|
// instead of briefly rendering the new filter with the previous page size.
|
|
setLimit(COMMENT_PAGE)
|
|
setFilter(nextFilter)
|
|
}
|
|
const shownGroups = groups.slice(0, limit)
|
|
const remaining = groups.length - shownGroups.length
|
|
|
|
return (
|
|
<>
|
|
<PRSection title="Description">
|
|
{loadingDetails ? (
|
|
<ActivityIndicator color={colors.textSecondary} />
|
|
) : detailsFailed ? (
|
|
<Text style={styles.noDescription}>
|
|
Could not load description. Tap refresh to try again.
|
|
</Text>
|
|
) : body.trim() ? (
|
|
<CommentMarkdown content={body} variant="document" />
|
|
) : (
|
|
<Text style={styles.noDescription}>No description provided.</Text>
|
|
)}
|
|
</PRSection>
|
|
|
|
<PRSection
|
|
title="Comments"
|
|
trailing={
|
|
comments.length > 0 ? (
|
|
<View style={styles.countChip}>
|
|
<Text style={styles.countChipText}>{comments.length}</Text>
|
|
</View>
|
|
) : undefined
|
|
}
|
|
>
|
|
{loadingDetails ? (
|
|
<ActivityIndicator color={colors.textSecondary} />
|
|
) : detailsFailed ? (
|
|
<Text style={styles.empty}>Could not load comments. Tap refresh to try again.</Text>
|
|
) : (
|
|
<View style={styles.list}>
|
|
{comments.length === 0 ? (
|
|
<Text style={styles.empty}>No comments yet.</Text>
|
|
) : (
|
|
<>
|
|
{isPr ? (
|
|
<View style={styles.audienceTabs}>
|
|
{PR_COMMENT_AUDIENCE_FILTERS.map((tab) => {
|
|
const active = tab.value === filter
|
|
return (
|
|
<Pressable
|
|
key={tab.value}
|
|
style={[styles.audienceTab, active && styles.audienceTabActive]}
|
|
onPress={() => selectFilter(tab.value)}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: active }}
|
|
>
|
|
<Text
|
|
style={[styles.audienceTabText, active && styles.audienceTabTextActive]}
|
|
>
|
|
{tab.label}
|
|
</Text>
|
|
<Text
|
|
style={[styles.audienceTabText, active && styles.audienceTabTextActive]}
|
|
>
|
|
{counts[tab.value]}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
) : null}
|
|
{visible.length === 0 ? (
|
|
<Text style={styles.empty}>{getPRCommentAudienceEmptyLabel(filter)}</Text>
|
|
) : (
|
|
<>
|
|
{shownGroups.map((group) => (
|
|
<CommentGroupView
|
|
key={getPRCommentGroupId(group)}
|
|
group={group}
|
|
actions={cardActions}
|
|
/>
|
|
))}
|
|
{remaining > 0 ? (
|
|
<Pressable
|
|
style={styles.showMore}
|
|
onPress={() => setLimit((l) => l + COMMENT_PAGE)}
|
|
accessibilityRole="button"
|
|
>
|
|
<Text style={styles.showMoreText}>
|
|
Show {Math.min(remaining, COMMENT_PAGE)} more
|
|
{remaining > COMMENT_PAGE ? ` of ${remaining}` : ''}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{actions?.error ? <Text style={styles.actionError}>{actions.error}</Text> : null}
|
|
{canComment && actions ? (
|
|
<View style={styles.rootComposer}>
|
|
<PRCommentComposer
|
|
placeholder="Add a comment…"
|
|
submitLabel="Comment"
|
|
submitting={actions.isRootBusy}
|
|
onSubmit={actions.addRootComment}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</PRSection>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function CommentGroupView({
|
|
group,
|
|
actions
|
|
}: {
|
|
group: PRCommentGroup
|
|
actions?: PRCommentCardActions
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const cards =
|
|
group.kind === 'thread'
|
|
? [
|
|
<PRCommentCard key={group.root.id} comment={group.root} actions={actions} />,
|
|
...group.replies.map((reply) => (
|
|
<PRCommentCard key={reply.id} comment={reply} isReply actions={actions} />
|
|
))
|
|
]
|
|
: [<PRCommentCard key={group.comment.id} comment={group.comment} actions={actions} />]
|
|
|
|
if (!isResolvedPRCommentGroup(group)) {
|
|
return <View style={styles.group}>{cards}</View>
|
|
}
|
|
|
|
// Resolved threads collapse behind a summary row (desktop accordion parity).
|
|
const root = getPRCommentGroupRoot(group)
|
|
const count = getPRCommentGroupCount(group)
|
|
const Chevron = expanded ? ChevronDown : ChevronRight
|
|
return (
|
|
<View style={styles.group}>
|
|
<Pressable
|
|
style={styles.resolvedHeader}
|
|
onPress={() => setExpanded((v) => !v)}
|
|
accessibilityRole="button"
|
|
>
|
|
<Chevron size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
|
<Text style={styles.resolvedHeaderText} numberOfLines={1}>
|
|
Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by {root.author}
|
|
{count > 1 ? ` (${count})` : ''}
|
|
</Text>
|
|
</Pressable>
|
|
{expanded ? <View style={shared.sectionBody}>{cards}</View> : null}
|
|
</View>
|
|
)
|
|
}
|