Files
llink/js/mobile/src/features/stream-view/TextParticleView.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

144 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
import { ScrollView, Text, View } from 'react-native';
import type { Particle } from '@/api/types';
import { cn } from '@/lib/utils';
import { MarkdownBody } from '@/components/MarkdownBody';
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useStreamSafeArea } from './stream-safe-area';
type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextParticleViewProps {
particle: TextParticle;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
// Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per
// link/attachment, clamped 315s). Mobile v1 has no attachments and we
// don't extract link previews mid-render, so the formula collapses to
// a length-only base.
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const IMMERSIVE_CHAR_LIMIT = 120;
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);
}
function getImmersiveStyle(length: number) {
if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
return { className: 'text-2xl font-normal leading-snug' };
}
// Mirrors desktop's text-particle-view: short plain notes get the immersive
// centered treatment; anything with markdown syntax renders formatted instead
// of showing raw `**asterisks**`. Covers the full GFM set desktop's Crepe
// engine handles — including ~~strikethrough~~ and tables — so short formatted
// messages drop to the rendered card rather than showing raw syntax.
function hasMarkdownFormatting(content: string): boolean {
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test(
content,
);
}
export function TextParticleView({
particle,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const content = particle.properties.content;
const editedAt = particle.properties.edited_at;
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
const safe = useStreamSafeArea();
const editedLabel = editedAt ? (
<View className="mt-3 items-center">
<Text className="text-white/40 text-xs">
edited <RelativeTimestamp date={editedAt} />
</Text>
</View>
) : null;
// Reset when the particle changes.
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]);
// Immersive (short, plain): centered, large type — feels like a lock-screen
// note. Short messages that contain markdown fall through to the rendered
// card so formatting isn't shown as raw syntax.
if (
content.length < IMMERSIVE_CHAR_LIMIT &&
!hasMarkdownFormatting(content)
) {
const style = getImmersiveStyle(content.length);
return (
<View
className="flex-1 items-center justify-center px-8"
style={{
paddingTop: safe.top + 16,
paddingBottom: safe.bottom + 16,
}}
>
<Text
className={cn('text-white text-center max-w-xl', style.className)}
>
{content}
</Text>
{editedLabel}
</View>
);
}
// Long text or markdown: scrollable card so the reader can pace themselves;
// the duration timer keeps ticking either way, which is intentional — long
// messages should still auto-advance at the 15s cap. Markdown is rendered
// via the useMarkdown hook (not the FlatList-based component) so its blocks
// nest cleanly inside this ScrollView. Padding is pulled from the
// StreamSafeArea so the card never slips under chrome.
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"
>
<MarkdownBody content={content} />
{editedLabel}
</ScrollView>
</View>
);
}