diff --git a/js/mobile/src/components/MarkdownBody.tsx b/js/mobile/src/components/MarkdownBody.tsx
new file mode 100644
index 0000000..ab171cb
--- /dev/null
+++ b/js/mobile/src/components/MarkdownBody.tsx
@@ -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 {nodes};
+}
diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx
index 4ea69d1..a5098cd 100644
--- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx
+++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx
@@ -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 = {
- 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({
- {meta.label}
+ {particle.type}
{title ? (
diff --git a/js/mobile/src/features/stream-view/FileParticleView.tsx b/js/mobile/src/features/stream-view/FileParticleView.tsx
new file mode 100644
index 0000000..54594d6
--- /dev/null
+++ b/js/mobile/src/features/stream-view/FileParticleView.tsx
@@ -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;
+
+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 (
+
+
+
+
+
+
+ {filename}
+
+
+ {formatBytes(size_bytes)}
+
+
+
+
+
+
+
+ {downloading ? 'Opening…' : 'Download'}
+
+
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/PaperParticleView.tsx b/js/mobile/src/features/stream-view/PaperParticleView.tsx
new file mode 100644
index 0000000..1d005a6
--- /dev/null
+++ b/js/mobile/src/features/stream-view/PaperParticleView.tsx
@@ -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;
+
+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 (
+
+
+ {title}
+
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx
index d0e99d8..77aec18 100644
--- a/js/mobile/src/features/stream-view/StreamView.tsx
+++ b/js/mobile/src/features/stream-view/StreamView.tsx
@@ -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 (
+
+ );
+ case 'file':
+ return (
+
+ );
default:
return (
- `${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 ? (
@@ -290,7 +135,7 @@ export function TextParticleView({
showsVerticalScrollIndicator
indicatorStyle="white"
>
- {markdownNodes}
+
{editedLabel}