import { useLocalSearchParams, useRouter } from "expo-router";import { SymbolView } from "expo-symbols";import { TextInputWrapper } from "expo-paste-input";import type { EnvironmentId, ThreadId } from "@t3tools/contracts";import { useCallback, useEffect, useMemo, useState } from "react";import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native";import { KeyboardAvoidingView } from "react-native-keyboard-controller";import { useSafeAreaInsets } from "react-native-safe-area-context";import ImageViewing from "react-native-image-viewing"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";import { ControlPill } from "../../components/ControlPill";import { cn } from "../../lib/cn";import type { DraftComposerImageAttachment } from "../../lib/composerImages";import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";import { useThemeColor } from "../../lib/useThemeColor";import { useNativePaste } from "../../lib/useNativePaste";import { setPendingConnectionError } from "../../state/use-remote-environment-registry";import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";import { clearReviewCommentTarget, formatReviewCommentContext, getReviewUnifiedLineNumber, getSelectedReviewCommentLines, useReviewCommentTarget,} from "./reviewCommentSelection";import { changeTone, DiffTokenText, REVIEW_DIFF_LINE_HEIGHT, REVIEW_MONO_FONT_FAMILY, ReviewChangeBar,} from "./reviewDiffRendering";import { highlightReviewSelectedLines, type ReviewDiffTheme, type ReviewHighlightedToken,} from "./shikiReviewHighlighter"; const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; export function ReviewCommentComposerSheet() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const colorScheme = useColorScheme(); const iconTint = String(useThemeColor("--color-icon")); const target = useReviewCommentTarget(); const { environmentId, threadId } = useLocalSearchParams<{ environmentId: EnvironmentId; threadId: ThreadId; }>(); const [commentText, setCommentText] = useState(""); const [highlightedLinesById, setHighlightedLinesById] = useState< Record<string, ReadonlyArray<ReviewHighlightedToken>> >({}); const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), [target], ); const firstLine = selectedLines[0] ?? null; const lastLine = selectedLines[selectedLines.length - 1] ?? null; const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; const canSubmit = commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; const selectionLabel = selectedLines.length === 1 ? firstNumber !== null ? `Line ${firstNumber}` : "File comment" : firstNumber !== null && lastNumber !== null ? `Lines ${firstNumber}-${lastNumber}` : `${selectedLines.length} lines selected`; const previewHeight = Math.max( Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT, REVIEW_DIFF_LINE_HEIGHT, ); const previewViewportWidth = Math.max(width - 40, 280); const dismissComposer = useCallback(() => { clearReviewCommentTarget(); router.dismiss(); }, [router]); const handleNativePaste = useNativePaste((uris) => { void (async () => { try { const images = await convertPastedImagesToAttachments({ uris, existingCount: attachments.length, }); if (images.length > 0) { setAttachments((current) => [...current, ...images]); } } catch (error) { console.error("[review comment] error converting pasted images", error); } })(); }); useEffect(() => { if (!target || selectedLines.length === 0) { setHighlightedLinesById({}); return; } let cancelled = false; void highlightReviewSelectedLines({ filePath: target.filePath, lines: selectedLines, theme: selectedTheme, }) .then((next) => { if (!cancelled) { setHighlightedLinesById(next); } }) .catch(() => { if (!cancelled) { setHighlightedLinesById({}); } }); return () => { cancelled = true; }; }, [selectedLines, selectedTheme, target]); async function handlePickImages(): Promise<void> { const result = await pickComposerImages({ existingCount: attachments.length }); if (result.images.length > 0) { setAttachments((current) => [...current, ...result.images]); } if (result.error) { setPendingConnectionError(result.error); } } return ( <View style={{ flex: 1 }}> <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8, paddingBottom: target ? 0 : Math.max(insets.bottom, 18), }} > <View className="flex-row items-center justify-between py-2"> <Pressable className="bg-subtle h-12 w-12 items-center justify-center rounded-full" onPress={dismissComposer} > <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> </Pressable> <Text className="text-lg font-t3-bold text-foreground">Add Comment</Text> <View className="h-12 w-12" /> </View> {!target ? ( <View className="rounded-[22px] border border-border bg-card px-4 py-5"> <Text className="text-base font-t3-bold text-foreground">No selection</Text> <Text className="mt-1 text-sm leading-[19px] text-foreground-muted"> Select a diff line or range first. </Text> </View> ) : ( <View className="min-h-0 flex-1 gap-4"> <View className="gap-1 px-1"> <Text className="text-2xs font-t3-bold uppercase text-foreground-muted"> {selectionLabel} </Text> <Text className="font-mono text-xs leading-[17px] text-foreground-muted" ellipsizeMode="middle" numberOfLines={2} > {target.filePath} </Text> </View> <View className="overflow-hidden rounded-[22px] border border-border bg-card"> <ScrollView horizontal bounces={false} keyboardShouldPersistTaps="always" showsHorizontalScrollIndicator={false} > <ScrollView bounces={false} scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES} nestedScrollEnabled keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={ selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES } style={{ height: previewHeight }} > <View style={{ minWidth: previewViewportWidth }}> {selectedLines.map((line) => { const lineNumber = getReviewUnifiedLineNumber(line); return ( <View key={line.id} className={cn("flex-row items-start", changeTone(line.change))} style={{ height: REVIEW_DIFF_LINE_HEIGHT }} > <ReviewChangeBar change={line.change} /> <Text className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted" style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} > {lineNumber ?? ""} </Text> <View className="min-w-0 flex-1 shrink-0 px-1 py-1"> <DiffTokenText fallback={line.content} tokens={highlightedLinesById[line.id] ?? null} change={line.change} /> </View> </View> ); })} </View> </ScrollView> </ScrollView> </View> <View className="min-h-0 flex-1 gap-2"> <Text className="text-sm font-t3-bold text-foreground">Comment</Text> <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card"> <View className="min-h-0 flex-1 px-4 pt-3.5"> <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1, minHeight: 0 }}> <TextInput autoFocus multiline scrollEnabled placeholder="Leave a comment..." textAlignVertical="top" value={commentText} onChangeText={setCommentText} className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" style={{ flex: 1, minHeight: 0 }} /> </TextInputWrapper> </View> {attachments.length > 0 ? ( <View className="px-4 pb-3 pt-2"> <ComposerAttachmentStrip attachments={attachments} imageBorderRadius={16} imageSize={60} onPressImage={setPreviewImageUri} removeButtonPlacement="gutter" onRemove={(imageId) => { setAttachments((current) => current.filter((image) => image.id !== imageId), ); }} /> </View> ) : null} </View> </View> </View> )} </View> {target ? ( <View className="flex-row items-center gap-3 bg-sheet px-5 py-2"> <ControlPill accessibilityLabel="Add image" icon="plus" onPress={() => void handlePickImages()} /> <View className="flex-1" /> <ControlPill accessibilityLabel="Comment" icon="arrow.up" label="Comment" variant="primary" disabled={!canSubmit} onPress={() => { if (!target || !environmentId || !threadId || commentText.trim().length === 0) { return; } appendReviewCommentToDraft({ environmentId, threadId, text: formatReviewCommentContext(target, commentText), attachments, }); setAttachments([]); dismissComposer(); }} /> </View> ) : null} </KeyboardAvoidingView> <ImageViewing images={previewImageUri ? [{ uri: previewImageUri }] : []} imageIndex={0} visible={previewImageUri !== null} onRequestClose={() => setPreviewImageUri(null)} swipeToCloseEnabled doubleTapToZoomEnabled /> </View> );}
import { useLocalSearchParams, useRouter } from "expo-router";
import { SymbolView } from "expo-symbols";
import { TextInputWrapper } from "expo-paste-input";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ImageViewing from "react-native-image-viewing";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
import { ControlPill } from "../../components/ControlPill";
import { cn } from "../../lib/cn";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
import { useThemeColor } from "../../lib/useThemeColor";
import { useNativePaste } from "../../lib/useNativePaste";
import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
import {
clearReviewCommentTarget,
formatReviewCommentContext,
getReviewUnifiedLineNumber,
getSelectedReviewCommentLines,
useReviewCommentTarget,
} from "./reviewCommentSelection";
import {
changeTone,
DiffTokenText,
REVIEW_DIFF_LINE_HEIGHT,
REVIEW_MONO_FONT_FAMILY,
ReviewChangeBar,
} from "./reviewDiffRendering";
import {
highlightReviewSelectedLines,
type ReviewDiffTheme,
type ReviewHighlightedToken,
} from "./shikiReviewHighlighter";
const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
export function ReviewCommentComposerSheet() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const colorScheme = useColorScheme();
const iconTint = String(useThemeColor("--color-icon"));
const target = useReviewCommentTarget();
const { environmentId, threadId } = useLocalSearchParams<{
environmentId: EnvironmentId;
threadId: ThreadId;
}>();
const [commentText, setCommentText] = useState("");
const [highlightedLinesById, setHighlightedLinesById] = useState<
Record<string, ReadonlyArray<ReviewHighlightedToken>>
>({});
const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const selectedLines = useMemo(
() => (target ? getSelectedReviewCommentLines(target) : []),
[target],
);
const firstLine = selectedLines[0] ?? null;
const lastLine = selectedLines[selectedLines.length - 1] ?? null;
const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
const canSubmit =
commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
const selectionLabel =
selectedLines.length === 1
? firstNumber !== null
? `Line ${firstNumber}`
: "File comment"
: firstNumber !== null && lastNumber !== null
? `Lines ${firstNumber}-${lastNumber}`
: `${selectedLines.length} lines selected`;
const previewHeight = Math.max(
Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,
REVIEW_DIFF_LINE_HEIGHT,
);
const previewViewportWidth = Math.max(width - 40, 280);
const dismissComposer = useCallback(() => {
clearReviewCommentTarget();
router.dismiss();
}, [router]);
const handleNativePaste = useNativePaste((uris) => {
void (async () => {
try {
const images = await convertPastedImagesToAttachments({
uris,
existingCount: attachments.length,
});
if (images.length > 0) {
setAttachments((current) => [...current, ...images]);
}
} catch (error) {
console.error("[review comment] error converting pasted images", error);
}
})();
});
useEffect(() => {
if (!target || selectedLines.length === 0) {
setHighlightedLinesById({});
return;
}
let cancelled = false;
void highlightReviewSelectedLines({
filePath: target.filePath,
lines: selectedLines,
theme: selectedTheme,
})
.then((next) => {
if (!cancelled) {
setHighlightedLinesById(next);
}
})
.catch(() => {
if (!cancelled) {
setHighlightedLinesById({});
}
});
return () => {
cancelled = true;
};
}, [selectedLines, selectedTheme, target]);
async function handlePickImages(): Promise<void> {
const result = await pickComposerImages({ existingCount: attachments.length });
if (result.images.length > 0) {
setAttachments((current) => [...current, ...result.images]);
}
if (result.error) {
setPendingConnectionError(result.error);
}
}
return (
<View style={{ flex: 1 }}>
<KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
<View
style={{
flex: 1,
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: target ? 0 : Math.max(insets.bottom, 18),
}}
>
<View className="flex-row items-center justify-between py-2">
<Pressable
className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
onPress={dismissComposer}
>
<SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" />
</Pressable>
<Text className="text-lg font-t3-bold text-foreground">Add Comment</Text>
<View className="h-12 w-12" />
</View>
{!target ? (
<View className="rounded-[22px] border border-border bg-card px-4 py-5">
<Text className="text-base font-t3-bold text-foreground">No selection</Text>
<Text className="mt-1 text-sm leading-[19px] text-foreground-muted">
Select a diff line or range first.
</Text>
</View>
) : (
<View className="min-h-0 flex-1 gap-4">
<View className="gap-1 px-1">
<Text className="text-2xs font-t3-bold uppercase text-foreground-muted">
{selectionLabel}
</Text>
<Text
className="font-mono text-xs leading-[17px] text-foreground-muted"
ellipsizeMode="middle"
numberOfLines={2}
>
{target.filePath}
</Text>
</View>
<View className="overflow-hidden rounded-[22px] border border-border bg-card">
<ScrollView
horizontal
bounces={false}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}
>
<ScrollView
bounces={false}
scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES}
nestedScrollEnabled
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={
selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
}
style={{ height: previewHeight }}
>
<View style={{ minWidth: previewViewportWidth }}>
{selectedLines.map((line) => {
const lineNumber = getReviewUnifiedLineNumber(line);
return (
<View
key={line.id}
className={cn("flex-row items-start", changeTone(line.change))}
style={{ height: REVIEW_DIFF_LINE_HEIGHT }}
>
<ReviewChangeBar change={line.change} />
<Text
className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
>
{lineNumber ?? ""}
</Text>
<View className="min-w-0 flex-1 shrink-0 px-1 py-1">
<DiffTokenText
fallback={line.content}
tokens={highlightedLinesById[line.id] ?? null}
change={line.change}
/>
</View>
</View>
);
})}
</View>
</ScrollView>
</ScrollView>
</View>
<View className="min-h-0 flex-1 gap-2">
<Text className="text-sm font-t3-bold text-foreground">Comment</Text>
<View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card">
<View className="min-h-0 flex-1 px-4 pt-3.5">
<TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1, minHeight: 0 }}>
<TextInput
autoFocus
multiline
scrollEnabled
placeholder="Leave a comment..."
textAlignVertical="top"
value={commentText}
onChangeText={setCommentText}
className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base"
style={{ flex: 1, minHeight: 0 }}
/>
</TextInputWrapper>
</View>
{attachments.length > 0 ? (
<View className="px-4 pb-3 pt-2">
<ComposerAttachmentStrip
attachments={attachments}
imageBorderRadius={16}
imageSize={60}
onPressImage={setPreviewImageUri}
removeButtonPlacement="gutter"
onRemove={(imageId) => {
setAttachments((current) =>
current.filter((image) => image.id !== imageId),
);
}}
/>
</View>
) : null}
</View>
</View>
</View>
)}
</View>
{target ? (
<View className="flex-row items-center gap-3 bg-sheet px-5 py-2">
<ControlPill
accessibilityLabel="Add image"
icon="plus"
onPress={() => void handlePickImages()}
/>
<View className="flex-1" />
<ControlPill
accessibilityLabel="Comment"
icon="arrow.up"
label="Comment"
variant="primary"
disabled={!canSubmit}
onPress={() => {
if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
return;
}
appendReviewCommentToDraft({
environmentId,
threadId,
text: formatReviewCommentContext(target, commentText),
attachments,
});
setAttachments([]);
dismissComposer();
}}
/>
</View>
) : null}
</KeyboardAvoidingView>
<ImageViewing
images={previewImageUri ? [{ uri: previewImageUri }] : []}
imageIndex={0}
visible={previewImageUri !== null}
onRequestClose={() => setPreviewImageUri(null)}
swipeToCloseEnabled
doubleTapToZoomEnabled
/>
</View>
);
}