1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { TextInputWrapper } from "expo-paste-input";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { useCallback, useEffect, useMemo, useState } from "react";
5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
9
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";
12import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill } from "../../components/ControlPill";
14import { cn } from "../../lib/cn";
15import type { DraftComposerImageAttachment } from "../../lib/composerImages";
16import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
17import { useNativePaste } from "../../lib/useNativePaste";
18import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
19import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
20import {
21 clearReviewCommentTarget,
22 formatReviewCommentContext,
23 getReviewUnifiedLineNumber,
24 getSelectedReviewCommentLines,
25 useReviewCommentTarget,
26} from "./reviewCommentSelection";
27import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
28import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
29import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";
30import {
31 highlightReviewSelectedLines,
32 type ReviewHighlightedToken,
33} from "./shikiReviewHighlighter";
34
35const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
36
37type ReviewCommentComposerSheetProps = StaticScreenProps<{
38 readonly environmentId: EnvironmentId;
39 readonly threadId: ThreadId;
40}>;
41
42export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
43 const isAndroid = Platform.OS === "android";
44 const navigation = useNavigation();
45 const insets = useSafeAreaInsets();
46 const { width } = useWindowDimensions();
47 const { themeAppearance: selectedTheme } = useAppearancePreferences();
48 const target = useReviewCommentTarget();
49 const { codeSurface } = useAppearanceCodeSurface();
50 const { environmentId, threadId } = props.route.params;
51 const [commentText, setCommentText] = useState("");
52 const [highlightedLinesById, setHighlightedLinesById] = useState<
53 Record<string, ReadonlyArray<ReviewHighlightedToken>>
54 >({});
55 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
56 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
57
58 const selectedLines = useMemo(
59 () => (target ? getSelectedReviewCommentLines(target) : []),
60 [target],
61 );
62 const firstLine = selectedLines[0] ?? null;
63 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
64 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
65 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
66 const canSubmit =
67 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
68 const selectionLabel =
69 selectedLines.length === 1
70 ? firstNumber !== null
71 ? `Line ${firstNumber}`
72 : "File comment"
73 : firstNumber !== null && lastNumber !== null
74 ? `Lines ${firstNumber}-${lastNumber}`
75 : `${selectedLines.length} lines selected`;
76 const previewHeight = Math.max(
77 Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * codeSurface.rowHeight,
78 codeSurface.rowHeight,
79 );
80 const previewViewportWidth = Math.max(width - 40, 280);
81 const dismissComposer = useCallback(() => {
82 clearReviewCommentTarget();
83 navigation.goBack();
84 }, [navigation]);
85 const handleNativePaste = useNativePaste((uris) => {
86 void (async () => {
87 try {
88 const images = await convertPastedImagesToAttachments({
89 uris,
90 existingCount: attachments.length,
91 });
92 if (images.length > 0) {
93 setAttachments((current) => [...current, ...images]);
94 }
95 } catch (error) {
96 console.error("[review comment] error converting pasted images", error);
97 }
98 })();
99 });
100
101 useEffect(() => {
102 if (!target || selectedLines.length === 0) {
103 setHighlightedLinesById({});
104 return;
105 }
106
107 let cancelled = false;
108 void highlightReviewSelectedLines({
109 filePath: target.filePath,
110 lines: selectedLines,
111 theme: selectedTheme,
112 })
113 .then((next) => {
114 if (!cancelled) {
115 setHighlightedLinesById(next);
116 }
117 })
118 .catch(() => {
119 if (!cancelled) {
120 setHighlightedLinesById({});
121 }
122 });
123
124 return () => {
125 cancelled = true;
126 };
127 }, [selectedLines, selectedTheme, target]);
128
129 async function handlePickImages(): Promise<void> {
130 const result = await pickComposerImages({ existingCount: attachments.length });
131 if (result.images.length > 0) {
132 setAttachments((current) => [...current, ...result.images]);
133 }
134 if (result.error) {
135 setPendingConnectionError(result.error);
136 }
137 }
138
139 const handleSubmit = useCallback(() => {
140 if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
141 return;
142 }
143
144 appendReviewCommentToDraft({
145 environmentId,
146 threadId,
147 text: formatReviewCommentContext(target, commentText),
148 attachments,
149 });
150 setAttachments([]);
151 dismissComposer();
152 }, [attachments, commentText, dismissComposer, environmentId, target, threadId]);
153
154 return (
155 <View className="flex-1 bg-sheet">
156 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
157 <View
158 className="flex-1 px-5"
159 style={{
160 paddingTop: isAndroid ? insets.top + 8 : 8,
161 paddingBottom: target ? (isAndroid ? 72 : 0) : Math.max(insets.bottom, 18),
162 }}
163 >
164 <View className="flex-row items-center justify-between py-2">
165 <Pressable
166 className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
167 onPress={dismissComposer}
168 >
169 <SymbolView
170 name="xmark"
171 size={18}
172 tintColorClassName={"accent-icon"}
173 type="monochrome"
174 />
175 </Pressable>
176
177 <Text className="text-lg font-t3-bold text-foreground">Add Comment</Text>
178
179 <View className="h-12 w-12" />
180 </View>
181
182 {!target ? (
183 <View className="rounded-[22px] border border-border bg-card px-4 py-5">
184 <Text className="text-base font-t3-bold text-foreground">No selection</Text>
185 <Text className="mt-1 text-sm leading-normal text-foreground-muted">
186 Select a diff line or range first.
187 </Text>
188 </View>
189 ) : (
190 <View className="min-h-0 flex-1 gap-4">
191 <View className="gap-1 px-1">
192 <Text className="text-2xs font-t3-bold uppercase text-foreground-muted">
193 {selectionLabel}
194 </Text>
195 <Text
196 className="font-mono text-xs leading-snug text-foreground-muted"
197 ellipsizeMode="middle"
198 numberOfLines={2}
199 >
200 {target.filePath}
201 </Text>
202 </View>
203
204 <View className="overflow-hidden rounded-[22px] border border-border bg-card">
205 <ScrollView
206 horizontal
207 bounces={false}
208 keyboardShouldPersistTaps="always"
209 showsHorizontalScrollIndicator={false}
210 >
211 <ScrollView
212 bounces={false}
213 scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES}
214 nestedScrollEnabled
215 keyboardShouldPersistTaps="always"
216 showsVerticalScrollIndicator={
217 selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
218 }
219 style={{ height: previewHeight }}
220 >
221 <View style={{ minWidth: previewViewportWidth }}>
222 {selectedLines.map((line) => {
223 const lineNumber = getReviewUnifiedLineNumber(line);
224
225 return (
226 <View
227 key={line.id}
228 className={cn("flex-row items-start", changeTone(line.change))}
229 style={{ height: codeSurface.rowHeight }}
230 >
231 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
232 <Text className="w-9 py-1 pr-1 text-right text-2xs font-mono text-foreground-muted">
233 {lineNumber ?? ""}
234 </Text>
235 <View className="min-w-0 flex-1 shrink-0 px-1 py-1">
236 <DiffTokenText
237 fallback={line.content}
238 tokens={highlightedLinesById[line.id] ?? null}
239 change={line.change}
240 fontSize={codeSurface.fontSize}
241 lineHeight={codeSurface.rowHeight}
242 />
243 </View>
244 </View>
245 );
246 })}
247 </View>
248 </ScrollView>
249 </ScrollView>
250 </View>
251
252 <View className="min-h-0 flex-1 gap-2">
253 <Text className="text-sm font-t3-bold text-foreground">Comment</Text>
254 <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card">
255 <View className="min-h-0 flex-1 px-4 pt-3.5">
256 <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1, minHeight: 0 }}>
257 <TextInput
258 autoFocus
259 multiline
260 scrollEnabled
261 placeholder="Leave a comment..."
262 textAlignVertical="top"
263 value={commentText}
264 onChangeText={setCommentText}
265 className="h-full min-h-0 flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base"
266 />
267 </TextInputWrapper>
268 </View>
269 {attachments.length > 0 ? (
270 <View className="px-4 pb-3 pt-2">
271 <ComposerAttachmentStrip
272 attachments={attachments}
273 imageBorderRadius={16}
274 imageSize={60}
275 onPressPreview={setPreviewFile}
276 removeButtonPlacement="gutter"
277 onRemove={(imageId) => {
278 setAttachments((current) =>
279 current.filter((image) => image.id !== imageId),
280 );
281 }}
282 />
283 </View>
284 ) : null}
285 </View>
286 </View>
287 </View>
288 )}
289 </View>
290 {!isAndroid && target ? (
291 <View className="flex-row items-center gap-3 bg-sheet px-5 py-2">
292 <ControlPill
293 accessibilityLabel="Add image"
294 icon="plus"
295 onPress={() => void handlePickImages()}
296 />
297 <View className="flex-1" />
298 <ControlPill
299 accessibilityLabel="Comment"
300 icon="arrow.up"
301 label="Comment"
302 variant="primary"
303 disabled={!canSubmit}
304 onPress={handleSubmit}
305 />
306 </View>
307 ) : null}
308 </KeyboardAvoidingView>
309 {isAndroid && target ? (
310 <KeyboardStickyView
311 className="absolute inset-x-0 bottom-0"
312 offset={{ closed: 0, opened: 0 }}
313 >
314 <View
315 className="flex-row items-center gap-3 border-t border-border bg-sheet px-5 pt-2"
316 style={{ paddingBottom: Math.max(insets.bottom, 10) }}
317 >
318 <ControlPill
319 accessibilityLabel="Add image"
320 icon="plus"
321 onPress={() => void handlePickImages()}
322 />
323 <View className="flex-1" />
324 <ControlPill
325 accessibilityLabel="Comment"
326 icon="arrow.up"
327 label="Comment"
328 variant="primary"
329 disabled={!canSubmit}
330 onPress={handleSubmit}
331 />
332 </View>
333 </KeyboardStickyView>
334 ) : null}
335 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
336 </View>
337 );
338}