| 1 | import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useCallback, useRef, useState } from "react"; |
| 3 | import { Alert, View } from "react-native"; |
| 4 | import type { PullRequestReviewPosition } from "@t3tools/contracts"; |
| 5 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 6 | import { MaterialButton } from "../../components/MaterialButton"; |
| 7 | import { uuidv4 } from "../../lib/uuid"; |
| 8 | import { composerPullRequests } from "../../state/pull-requests"; |
| 9 | import { useEnvironmentQuery } from "../../state/query"; |
| 10 | import { PullRequestSheetContent } from "./PullRequestSheetContent"; |
| 11 | import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; |
| 12 | import { reviewPositionLabel } from "./pullRequestDiffComments"; |
| 13 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 14 | import { usePullRequestPermissions } from "./usePullRequestPermissions"; |
| 15 | |
| 16 | export type InlineCommentParams = PullRequestParams & { |
| 17 | readonly draftId?: string; |
| 18 | readonly path: string; |
| 19 | readonly oldPath?: string; |
| 20 | readonly position: PullRequestReviewPosition; |
| 21 | readonly headSha: string; |
| 22 | readonly code: string; |
| 23 | }; |
| 24 | export function PullRequestInlineCommentScreen({ route }: StaticScreenProps<InlineCommentParams>) { |
| 25 | const params = route.params; |
| 26 | const navigation = useNavigation(); |
| 27 | const store = usePullRequestReviewDraft(params); |
| 28 | const saved = store.draft.comments.find((draft) => draft.id === params.draftId); |
| 29 | const [editedBody, setBody] = useState<string | null>(null); |
| 30 | const body = editedBody ?? saved?.body ?? ""; |
| 31 | const detail = useEnvironmentQuery(composerPullRequests.detail(pullRequestTarget(params))); |
| 32 | const { refresh: refreshDetail } = detail; |
| 33 | useFocusEffect(useCallback(() => refreshDetail(), [refreshDetail])); |
| 34 | const permissions = usePullRequestPermissions(params, detail.data); |
| 35 | const stale = detail.data && detail.data.headSha !== params.headSha; |
| 36 | const pending = useRef(false); |
| 37 | const [busy, setBusy] = useState(false); |
| 38 | const save = async () => { |
| 39 | if ( |
| 40 | pending.current || |
| 41 | !store.ready || |
| 42 | !body.trim() || |
| 43 | detail.isPending || |
| 44 | detail.error || |
| 45 | stale || |
| 46 | !permissions.canInlineComment |
| 47 | ) |
| 48 | return; |
| 49 | pending.current = true; |
| 50 | setBusy(true); |
| 51 | store.update((draft) => ({ |
| 52 | ...draft, |
| 53 | comments: [ |
| 54 | ...draft.comments.filter((comment) => comment.id !== params.draftId), |
| 55 | { |
| 56 | id: params.draftId ?? uuidv4(), |
| 57 | path: params.path, |
| 58 | ...(params.oldPath ? { oldPath: params.oldPath } : {}), |
| 59 | position: params.position, |
| 60 | headSha: params.headSha, |
| 61 | code: params.code, |
| 62 | body, |
| 63 | }, |
| 64 | ], |
| 65 | })); |
| 66 | try { |
| 67 | await store.flush(); |
| 68 | navigation.goBack(); |
| 69 | } finally { |
| 70 | pending.current = false; |
| 71 | setBusy(false); |
| 72 | } |
| 73 | }; |
| 74 | return ( |
| 75 | <PullRequestSheetContent |
| 76 | busy={busy} |
| 77 | title={saved ? "Edit review comment" : "Review comment"} |
| 78 | subtitle={`#${params.number} · ${reviewPositionLabel(params.position)}`} |
| 79 | footer={ |
| 80 | <MaterialButton |
| 81 | fullWidth |
| 82 | tone="contrast" |
| 83 | label={busy ? "Saving…" : "Save draft"} |
| 84 | loading={busy} |
| 85 | disabled={ |
| 86 | !store.ready || |
| 87 | !body.trim() || |
| 88 | detail.isPending || |
| 89 | detail.error !== null || |
| 90 | Boolean(stale) || |
| 91 | !permissions.canInlineComment |
| 92 | } |
| 93 | onPress={() => void save()} |
| 94 | /> |
| 95 | } |
| 96 | > |
| 97 | <View className="gap-2 rounded-[20px] bg-grouped-card p-4"> |
| 98 | <Text className="font-mono text-xs text-foreground-muted">{params.path}</Text> |
| 99 | <Text selectable className="font-mono text-xs"> |
| 100 | {params.code} |
| 101 | </Text> |
| 102 | </View> |
| 103 | <TextInput |
| 104 | multiline |
| 105 | editable={store.ready && !busy} |
| 106 | autoFocus |
| 107 | accessibilityLabel="Inline review comment" |
| 108 | placeholder="Write feedback on this line…" |
| 109 | placeholderTextColorClassName="accent-foreground-muted" |
| 110 | maxLength={65536} |
| 111 | value={body} |
| 112 | onChangeText={setBody} |
| 113 | className="min-h-32 rounded-[20px] bg-grouped-card p-4 text-base text-foreground" |
| 114 | style={{ textAlignVertical: "top" }} |
| 115 | /> |
| 116 | <Text className="text-xs text-foreground-muted"> |
| 117 | Saved comments are private until you submit your review. |
| 118 | </Text> |
| 119 | {stale ? ( |
| 120 | <Text className="text-sm text-danger-foreground"> |
| 121 | The branch changed. Return to the current diff and select the line again. |
| 122 | </Text> |
| 123 | ) : null} |
| 124 | {store.error || detail.error ? ( |
| 125 | <Text className="text-sm text-danger-foreground">{store.error ?? detail.error}</Text> |
| 126 | ) : null} |
| 127 | {saved ? ( |
| 128 | <MaterialButton |
| 129 | label="Delete draft" |
| 130 | onPress={() => |
| 131 | Alert.alert("Delete this draft?", "Your other review comments will be kept.", [ |
| 132 | { text: "Cancel", style: "cancel" }, |
| 133 | { |
| 134 | text: "Delete", |
| 135 | style: "destructive", |
| 136 | onPress: () => { |
| 137 | store.update((draft) => ({ |
| 138 | ...draft, |
| 139 | comments: draft.comments.filter((comment) => comment.id !== saved.id), |
| 140 | })); |
| 141 | navigation.goBack(); |
| 142 | }, |
| 143 | }, |
| 144 | ]) |
| 145 | } |
| 146 | /> |
| 147 | ) : null} |
| 148 | </PullRequestSheetContent> |
| 149 | ); |
| 150 | } |