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
This commit is contained in:
Claude
2026-06-21 02:03:40 +00:00
parent 0cc6024621
commit 9a46f39121
6 changed files with 380 additions and 188 deletions
+165
View File
@@ -0,0 +1,165 @@
import { Fragment, type ReactNode } from 'react';
import { Platform, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
// Shared markdown rendering for text and paper particles. Mirrors the desktop
// Crepe palette (markdown-editor.css `--crepe-*`) so a message reads the same
// on both surfaces: white-on-transparent text, a blue accent, pink inline
// code, and a near-opaque dark surface behind code blocks and tables.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
/**
* Renders GFM markdown using the shared Flowy palette. `useMarkdown` returns an
* array of block nodes; we splat them into a Fragment so they nest cleanly
* inside a parent ScrollView (vs. the library's own FlatList-based component).
*/
export function MarkdownBody({ content }: { content: string }) {
const nodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
return <Fragment>{nodes}</Fragment>;
}
@@ -1,20 +1,10 @@
import { useEffect } from 'react';
import { Text, View } from 'react-native';
import {
FileIcon,
HelpCircle,
BookOpen,
type LucideIcon,
} from 'lucide-react-native';
import { HelpCircle } from 'lucide-react-native';
import type { Particle } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
paper: { icon: BookOpen, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
const PLACEHOLDER_DURATION_MS = 5000;
interface FallbackParticleViewProps {
@@ -24,6 +14,10 @@ interface FallbackParticleViewProps {
onEnded: () => void;
}
// Catch-all for particle types this client version doesn't render with a
// dedicated view (e.g. a folder slipping into a stream, or a future type a
// newer client wrote). Known content types — media, text, task, paper, file —
// each have their own view in StreamView's switch.
export function FallbackParticleView({
particle,
networkId,
@@ -35,23 +29,8 @@ export function FallbackParticleView({
particle.created_by_human_id,
network?.humans,
);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircle,
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'folder':
return particle.properties.name;
default:
return null;
}
})();
const Icon = HelpCircle;
const title = particle.type === 'folder' ? particle.properties.name : null;
useEffect(() => {
if (paused) return;
@@ -66,7 +45,7 @@ export function FallbackParticleView({
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
<View className="flex-1">
<Text className="text-white text-base font-semibold">
{meta.label}
{particle.type}
</Text>
{title ? (
<Text className="text-white/70 text-sm" numberOfLines={2}>
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, Text, View } from 'react-native';
import { Download, FileIcon } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Particle } from '@/api/types';
import { apiClient } from '@/api/client';
import { toUserMessage } from '@/lib/errors';
type FileParticle = Extract<Particle, { type: 'file' }>;
interface FileParticleViewProps {
particle: FileParticle;
paused: boolean;
onEnded: () => void;
}
// Files don't auto-play; give the reader a beat to act before advancing.
const DWELL_DURATION_MS = 8000;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
/**
* File particle: name, size, and a download action. Tapping resolves a signed
* URL and opens it (the OS handles the download / preview). Mirrors desktop's
* file attachment, minus in-app preview.
*/
export function FileParticleView({
particle,
paused,
onEnded,
}: FileParticleViewProps) {
const { filename, size_bytes, object_id } = particle.properties;
const [downloading, setDownloading] = useState(false);
const elapsedRef = useRef(0);
// Pause the dwell while a download is being resolved so the stream doesn't
// advance out from under the user mid-tap.
useEffect(() => {
if (paused || downloading) return;
const start = Date.now();
const interval = setInterval(() => {
elapsedRef.current += Date.now() - start;
if (elapsedRef.current >= DWELL_DURATION_MS) {
clearInterval(interval);
onEnded();
}
}, 250);
return () => clearInterval(interval);
}, [paused, downloading, onEnded, particle.id]);
const handleDownload = async () => {
setDownloading(true);
try {
const url = await apiClient.getParticleDownloadUrl(object_id);
const canOpen = await Linking.canOpenURL(url);
if (!canOpen) throw new Error('Could not open this file.');
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setDownloading(false);
}
};
return (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
<View className="flex-row items-center gap-3">
<FileIcon color="rgba(255,255,255,0.7)" size={26} strokeWidth={1.5} />
<View className="flex-1">
<Text
className="text-white text-base font-semibold"
numberOfLines={2}
>
{filename}
</Text>
<Text className="text-white/50 text-xs mt-0.5">
{formatBytes(size_bytes)}
</Text>
</View>
</View>
<Pressable
onPress={handleDownload}
disabled={downloading}
className="mt-5 flex-row items-center justify-center gap-2 rounded-xl bg-white py-3"
>
<Download color="#000000" size={16} strokeWidth={2} />
<Text className="text-black text-base font-semibold">
{downloading ? 'Opening…' : 'Download'}
</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,79 @@
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>
);
}
@@ -54,6 +54,8 @@ import {
} from './stream-presence-context';
import { TextParticleView } from './TextParticleView';
import { TaskParticleView } from './TaskParticleView';
import { PaperParticleView } from './PaperParticleView';
import { FileParticleView } from './FileParticleView';
import { MediaParticleView } from './MediaParticleView';
import { DeletedParticleView } from './DeletedParticleView';
import { FallbackParticleView } from './FallbackParticleView';
@@ -469,6 +471,25 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
onProgress={setProgress}
/>
);
case 'paper':
return (
<PaperParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case 'file':
return (
<FileParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
/>
);
default:
return (
<FallbackParticleView
@@ -1,8 +1,8 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
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';
@@ -47,156 +47,6 @@ function hasMarkdownFormatting(content: string): boolean {
);
}
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
// message reads the same on both surfaces: white-on-transparent text, a blue
// accent, pink inline code, and a near-opaque dark surface behind code blocks
// and tables. Defined at module scope so the references stay stable —
// `useMarkdown` re-parses only when these or the content change.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks. Instantiated once at module scope to keep the reference stable
// for `useMarkdown`'s memoization.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
export function TextParticleView({
particle,
paused,
@@ -208,11 +58,6 @@ export function TextParticleView({
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
const safe = useStreamSafeArea();
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
const editedLabel = editedAt ? (
<View className="mt-3 items-center">
@@ -290,7 +135,7 @@ export function TextParticleView({
showsVerticalScrollIndicator
indicatorStyle="white"
>
{markdownNodes}
<MarkdownBody content={content} />
{editedLabel}
</ScrollView>
</View>