Files
orca/mobile/src/session/MobileSessionFileReader.tsx
T
2584ed906e feat: play video and music files in mobile previews (#26148)
Play workspace video and music files on mobile using bounded authenticated downloads and native media controls.

Adversarial review fixes cover rewritten files and Android policy tests. Includes iOS simulator screenshots and a playback recording in PR #26148.

Co-authored-by: ChangJun Park <40492343+ckdwns9121@users.noreply.github.com>
Co-authored-by: Lirone Levy <lirone88@outlook.fr>
Co-authored-by: dupi <david.li.du@gmail.com>
Co-authored-by: John Cusack <5961784+John-Cusack@users.noreply.github.com>
2026-10-07 03:41:42 -07:00

315 lines
9.8 KiB
TypeScript

import { MobileFileMediaPreview } from '../files/MobileFileMediaPreview'
import type { MobileFilePreviewRpcSender } from '../files/mobile-file-preview-operations'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
FlatList,
Image,
View,
Text,
ScrollView,
Pressable,
Platform,
ActivityIndicator,
type ListRenderItem
} from 'react-native'
import { Copy, MessageSquare, Send } from 'lucide-react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from './mobile-file-syntax'
import { MobileHtmlPreview } from '../components/MobileHtmlPreview'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type {
DiffCommentActions,
DiffSyntaxState,
FileDocState,
FileSyntaxState,
RenderableDiffLine
} from './mobile-session-route-types'
import { DiffLineRow } from './MobileSessionDiffLineRow'
import { sendableMobileDiffComments } from './mobile-diff-comments'
export function FileReader({
doc,
client = null,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
client?: MobileFilePreviewRpcSender | null
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<DiffLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount = diffCommentActions
? sendableMobileDiffComments(
diffCommentActions.comments,
diffCommentActions.sendingCommentIds
).length
: 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'media') {
return <MobileFileMediaPreview media={doc.media} client={client} title={title} />
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}