| 1 | import { useOpenPullRequest } from "../native/useOpenPullRequest"; |
| 2 | import { SourceFileSurface } from "../features/files/SourceFileSurface"; |
| 3 | import { filePreviewKind } from "@t3tools/shared/filePreview"; |
| 4 | import type { |
| 5 | ComposerContextRecord, |
| 6 | ElementContextSource, |
| 7 | EnvironmentId, |
| 8 | ProjectId, |
| 9 | ScopedThreadRef, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 12 | import { videoMimeType } from "@t3tools/shared/video"; |
| 13 | import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering"; |
| 16 | import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard"; |
| 17 | import { |
| 18 | composerAttachmentInlineUri, |
| 19 | isFileBackedComposerAttachment, |
| 20 | type DraftComposerAttachment, |
| 21 | } from "../lib/composerImages"; |
| 22 | import { contextImageGallery } from "../lib/contextImageGallery"; |
| 23 | import { FilePreviewModal } from "./FilePreviewModal"; |
| 24 | import { VideoPreviewModal } from "./VideoPreviewModal"; |
| 25 | import { ComposerContextAttachment } from "./ComposerContextAttachment"; |
| 26 | import { AppText as Text } from "./AppText"; |
| 27 | import { AppSheet } from "./AppSheet"; |
| 28 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 29 | import { getMobileTerminalTheme } from "../features/terminal/terminalTheme"; |
| 30 | |
| 31 | function 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 | |
| 60 | function 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. */ |
| 76 | export 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 | } |