mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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>
315 lines
9.8 KiB
TypeScript
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)
|
|
}
|