Files
llink/js/mobile/src/features/stream-view/PaperParticleView.tsx
T
Claude 9a46f39121 mobile: paper and file particle views (parity phase 4)
- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
2026-06-21 02:03:40 +00:00

80 lines
2.4 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { ScrollView, Text, View } from 'react-native';
import type { Particle } from '@/api/types';
import { MarkdownBody } from '@/components/MarkdownBody';
import { useStreamSafeArea } from './stream-safe-area';
type PaperParticle = Extract<Particle, { type: 'paper' }>;
interface PaperParticleViewProps {
particle: PaperParticle;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
// Papers are longer-form documents, so they read at the text cadence but with a
// higher cap — the reader can still scroll at their own pace while the timer
// ticks toward auto-advance.
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 4;
const MAX_DURATION_S = 30;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
}
/**
* Read-only paper (document) view. Mirrors desktop's paper rendering: a title
* heading above markdown body, scrollable, with a length-based dwell timer.
*/
export function PaperParticleView({
particle,
paused,
onEnded,
onProgress,
}: PaperParticleViewProps) {
const { title, content } = particle.properties;
const safe = useStreamSafeArea();
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
onProgress(0);
}, [particle.id, onProgress]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<ScrollView
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5"
showsVerticalScrollIndicator
indicatorStyle="white"
>
<Text className="text-white text-2xl font-semibold mb-3">{title}</Text>
<MarkdownBody content={content} />
</ScrollView>
</View>
);
}