From 9a46f3912120c7f82e8f9dc5f48ed74fbb0e6410 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:03:40 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/src/components/MarkdownBody.tsx | 165 ++++++++++++++++++ .../stream-view/FallbackParticleView.tsx | 37 +--- .../features/stream-view/FileParticleView.tsx | 103 +++++++++++ .../stream-view/PaperParticleView.tsx | 79 +++++++++ .../src/features/stream-view/StreamView.tsx | 21 +++ .../features/stream-view/TextParticleView.tsx | 163 +---------------- 6 files changed, 380 insertions(+), 188 deletions(-) create mode 100644 js/mobile/src/components/MarkdownBody.tsx create mode 100644 js/mobile/src/features/stream-view/FileParticleView.tsx create mode 100644 js/mobile/src/features/stream-view/PaperParticleView.tsx 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}