1import { useOpenPullRequest } from "../native/useOpenPullRequest";
2import { SourceFileSurface } from "../features/files/SourceFileSurface";
3import { filePreviewKind } from "@t3tools/shared/filePreview";
4import type {
5 ComposerContextRecord,
6 ElementContextSource,
7 EnvironmentId,
8 ProjectId,
9 ScopedThreadRef,
10} from "@t3tools/contracts";
11import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
12import { videoMimeType } from "@t3tools/shared/video";
13import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering";
16import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard";
17import {
18 composerAttachmentInlineUri,
19 isFileBackedComposerAttachment,
20 type DraftComposerAttachment,
21} from "../lib/composerImages";
22import { contextImageGallery } from "../lib/contextImageGallery";
23import { FilePreviewModal } from "./FilePreviewModal";
24import { VideoPreviewModal } from "./VideoPreviewModal";
25import { ComposerContextAttachment } from "./ComposerContextAttachment";
26import { AppText as Text } from "./AppText";
27import { AppSheet } from "./AppSheet";
28import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
29import { getMobileTerminalTheme } from "../features/terminal/terminalTheme";
30
31function ContextField(props: { label: string; value: string | null | undefined; code?: boolean }) {
32 if (!props.value) return null;
33 return (
34 <View className="gap-1">
35 <Text className="text-xs text-foreground-muted">{props.label}</Text>
36 {props.code && (props.label === "HTML" || props.label === "Styles") ? (
37 <View
38 className="overflow-hidden rounded-xl border border-border"
39 style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }}
40 >
41 <SourceFileSurface
42 embedded
43 contents={props.value}
44 path={props.label === "HTML" ? "element.html" : "styles.css"}
45 />
46 </View>
47 ) : (
48 <Text
49 selectable
50 className={props.code ? "text-sm text-foreground" : "text-base text-foreground"}
51 style={props.code ? { fontFamily: REVIEW_MONO_FONT_FAMILY } : undefined}
52 >
53 {props.value}
54 </Text>
55 )}
56 </View>
57 );
58}
59
60function ContextSource(props: { source: ElementContextSource | null }) {
61 const source = props.source;
62 if (!source) return null;
63 const location = source.fileName
64 ? `${source.fileName}${source.lineNumber !== null ? `:${source.lineNumber}${source.columnNumber !== null ? `:${source.columnNumber}` : ""}` : ""}`
65 : null;
66 return (
67 <ContextField
68 label="Source"
69 value={[source.functionName, location].filter(Boolean).join("\n")}
70 code
71 />
72 );
73}
74
75/** Touch equivalent of the web context popover; snapshots remain readable offline. */
76export function ComposerContextSheet(props: {
77 readonly label: string;
78 readonly record: ComposerContextRecord | undefined;
79 readonly onClose: () => void;
80 readonly onRemove?: () => void;
81 readonly onOpenAttachment?: () => void;
82 readonly onOpenPullRequest?: () => void;
83 readonly skillDescription?: string;
84 readonly onOpenSkill?: () => void;
85 readonly onOpenThread?: (thread: ScopedThreadRef) => void;
86 readonly environmentId?: EnvironmentId;
87 readonly projectId?: ProjectId;
88 readonly records?: ReadonlyArray<ComposerContextRecord>;
89 readonly attachments?: ReadonlyArray<DraftComposerAttachment>;
90}) {
91 const openPullRequest = useOpenPullRequest({
92 context:
93 props.environmentId && props.projectId
94 ? { environmentId: props.environmentId, projectId: props.projectId }
95 : undefined,
96 });
97 const reviewColors = useReviewCommentColors();
98 const insets = useSafeAreaInsets();
99 const { themeId, themeAppearance } = useAppearancePreferences();
100 const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance);
101 const record = props.record;
102 const localAttachment =
103 record && "attachmentId" in record
104 ? props.attachments?.find((entry) => entry.id === record.attachmentId)
105 : undefined;
106 const localFile =
107 localAttachment && isFileBackedComposerAttachment(localAttachment)
108 ? localAttachment
109 : undefined;
110 if (record && !("payload" in record) && (record.kind === "image" || record.kind === "file")) {
111 const mimeType = videoMimeType(record) ?? record.mimeType;
112 const previewKind = filePreviewKind(record);
113 const resource = {
114 _tag: "attachment" as const,
115 attachmentId: record.attachmentId,
116 fileName: record.name,
117 mimeType,
118 };
119 const remoteSource = props.environmentId
120 ? { environmentId: props.environmentId, resource }
121 : null;
122 if (videoMimeType(record)) {
123 if (localFile?.type === "file") {
124 return (
125 <VideoPreviewModal
126 source={{ type: "local", attachment: localFile }}
127 onRequestClose={props.onClose}
128 />
129 );
130 }
131 if (remoteSource && !localFile) {
132 return (
133 <VideoPreviewModal
134 source={{
135 type: "media",
136 name: record.name,
137 mimeType,
138 ...remoteSource,
139 actionsSource: { name: record.name, mimeType, ...remoteSource },
140 }}
141 onRequestClose={props.onClose}
142 />
143 );
144 }
145 } else if (record.kind === "image" || previewKind === "image" || previewKind === "pdf") {
146 const inlineUri = composerAttachmentInlineUri(localAttachment);
147 const source = localFile
148 ? { attachment: localFile }
149 : inlineUri
150 ? { uri: inlineUri }
151 : remoteSource;
152 if (source) {
153 return (
154 <FilePreviewModal
155 source={{
156 kind: record.kind === "image" || previewKind === "image" ? "image" : "pdf",
157 name: record.name,
158 ...source,
159 gallery: contextImageGallery(
160 props.records ?? [],
161 props.attachments ?? [],
162 props.environmentId,
163 ),
164 actionsSource: { name: record.name, mimeType, ...source },
165 }}
166 onRequestClose={props.onClose}
167 />
168 );
169 }
170 }
171 }
172 const attachmentRecord =
173 record && "attachmentId" in record
174 ? record
175 : record?.kind === "preview-annotation" && "screenshotContextId" in record
176 ? props.records?.find(
177 (entry) => entry.contextId === record.screenshotContextId && "attachmentId" in entry,
178 )
179 : undefined;
180 const pullRequestUrl =
181 record?.kind === "review-comment" && "pullRequest" in record
182 ? record.pullRequest?.url
183 : undefined;
184 const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null;
185 return (
186 <AppSheet title={terminal?.terminalLabel ?? props.label} onClose={props.onClose}>
187 <ScrollView
188 className="flex-1"
189 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
190 contentContainerStyle={{
191 paddingHorizontal: 20,
192 paddingTop: 12,
193 gap: 16,
194 paddingBottom: Math.max(20, insets.bottom),
195 }}
196 >
197 {terminal ? (
198 <Text className="text-sm text-foreground-muted">
199 Lines {terminal.lineStart}–{terminal.lineEnd}
200 </Text>
201 ) : null}
202 {!record ? (
203 <Text className="text-foreground">
204 Context unavailable. The reference was copied without its payload. Copy it again from
205 the original message or remove it.
206 </Text>
207 ) : "payload" in record ? (
208 <Text className="text-foreground">
209 This context type is not supported by this version of the app. Its payload will be
210 preserved when sent.
211 </Text>
212 ) : (
213 <>
214 {record.kind === "terminal" ? (
215 <View
216 className="overflow-hidden rounded-xl"
217 style={{ backgroundColor: terminalTheme.background }}
218 >
219 <ScrollView
220 horizontal
221 showsHorizontalScrollIndicator
222 contentContainerStyle={{ padding: 12 }}
223 >
224 <Text
225 selectable
226 className="text-sm text-foreground"
227 style={{
228 fontFamily: REVIEW_MONO_FONT_FAMILY,
229 fontSize: 13,
230 lineHeight: 20,
231 color: terminalTheme.foreground,
232 }}
233 >
234 {record.text}
235 </Text>
236 </ScrollView>
237 </View>
238 ) : null}
239 {record.kind === "review-comment" ? (
240 <>
241 {record.pullRequest ? (
242 <ContextField
243 label={`#${record.pullRequest.number} · ${record.pullRequest.isDraft ? "draft" : record.pullRequest.state}`}
244 value={`${record.pullRequest.title}\n${record.pullRequest.headBranch} → ${record.pullRequest.baseBranch}`}
245 />
246 ) : null}
247 {!record.sectionId.startsWith("pull-request:") ? (
248 <>
249 <ReviewCommentCard
250 comment={{ ...record, id: record.contextId }}
251 colors={reviewColors}
252 />
253 </>
254 ) : null}
255 </>
256 ) : null}
257 {record.kind === "preview-annotation" ? (
258 <>
259 <ContextField label="Page" value={record.pageTitle ?? record.pageUrl} />
260 <ContextField label="URL" value={record.pageUrl} />
261 <ContextField label="Comment" value={record.comment} />
262 <ContextField label="Selection" value={record.targetSummary} />
263 <ContextField label="Requested changes" value={record.styleChanges.join("\n")} />
264 {record.elements?.map((element, index) => (
265 <View key={`${element.selector ?? element.tagName}:${index}`} className="gap-3">
266 <ContextField
267 label="Element"
268 value={element.componentName ?? element.tagName}
269 />
270 <ContextField label="Selector" value={element.selector} code />
271 <ContextSource source={element.source} />
272 <ContextField label="HTML" value={element.htmlPreview} code />
273 <ContextField label="Styles" value={element.styles} code />
274 </View>
275 ))}
276 </>
277 ) : null}
278 {record.kind === "element" ? (
279 <>
280 <ContextField label="Page" value={record.pageUrl} />
281 <ContextField label="Element" value={record.componentName ?? record.tagName} />
282 <ContextField label="Selector" value={record.selector} code />
283 <ContextSource source={record.source} />
284 <ContextField label="HTML" value={record.htmlPreview} code />
285 <ContextField label="Styles" value={record.styles} code />
286 </>
287 ) : null}
288 {record.kind === "image" ? (
289 <ContextField
290 label="File"
291 value={`${record.mimeType} · ${formatAttachmentSize(record.sizeBytes)}`}
292 />
293 ) : null}
294 {record.kind === "mention" ? (
295 <ContextField label="Path" value={record.path} code />
296 ) : null}
297 {record.kind === "thread" ? (
298 <View className="gap-3">
299 <ContextField label="Thread" value={record.title} />
300 {props.onOpenThread ? (
301 <Pressable
302 accessibilityRole="button"
303 onPress={() =>
304 props.onOpenThread?.({
305 environmentId: record.environmentId,
306 threadId: record.threadId,
307 })
308 }
309 className="rounded-xl bg-subtle p-4"
310 >
311 <Text className="text-foreground">Open thread</Text>
312 </Pressable>
313 ) : null}
314 </View>
315 ) : null}
316 {record.kind === "skill" ? (
317 <View className="gap-3">
318 <ContextField label="Skill" value={record.name} />
319 <ContextField
320 label="Description"
321 value={props.skillDescription ?? "No description is available for this skill."}
322 />
323 {props.onOpenSkill ? (
324 <Pressable
325 accessibilityRole="button"
326 onPress={props.onOpenSkill}
327 className="rounded-xl bg-subtle p-4"
328 >
329 <Text className="text-foreground">View instructions</Text>
330 </Pressable>
331 ) : null}
332 </View>
333 ) : null}
334 </>
335 )}
336 {attachmentRecord && "attachmentId" in attachmentRecord ? (
337 <ComposerContextAttachment
338 key={JSON.stringify([
339 props.environmentId,
340 attachmentRecord.attachmentId,
341 props.attachments?.find((entry) => entry.id === attachmentRecord.attachmentId)
342 ?.fileUri,
343 ])}
344 record={attachmentRecord}
345 environmentId={props.environmentId}
346 attachment={props.attachments?.find(
347 (entry) => entry.id === attachmentRecord.attachmentId,
348 )}
349 />
350 ) : null}
351 {pullRequestUrl && /^https?:\/\//i.test(pullRequestUrl) ? (
352 <Pressable
353 accessibilityRole="link"
354 onPress={() => {
355 props.onClose();
356 void openPullRequest(pullRequestUrl).catch(() =>
357 Alert.alert("Could not open pull request", "Try again when connected."),
358 );
359 }}
360 className="rounded-xl bg-subtle p-4"
361 >
362 <Text className="text-foreground">Open pull request</Text>
363 </Pressable>
364 ) : null}
365 {props.onOpenAttachment ? (
366 <Pressable
367 accessibilityRole="button"
368 onPress={props.onOpenAttachment}
369 className="rounded-xl bg-subtle p-4"
370 >
371 <Text className="text-foreground">Open attachment</Text>
372 </Pressable>
373 ) : null}
374 {props.onOpenPullRequest && !pullRequestUrl ? (
375 <Pressable
376 accessibilityRole="button"
377 onPress={props.onOpenPullRequest}
378 className="rounded-xl bg-subtle p-4"
379 >
380 <Text className="text-foreground">Open pull request</Text>
381 </Pressable>
382 ) : null}
383 {props.onRemove ? (
384 <Pressable
385 accessibilityRole="button"
386 onPress={props.onRemove}
387 className="rounded-xl bg-subtle p-4"
388 >
389 <Text className="text-foreground">Remove from draft</Text>
390 </Pressable>
391 ) : null}
392 </ScrollView>
393 </AppSheet>
394 );
395}