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:
@@ -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 { useEffect } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import {
|
import { HelpCircle } from 'lucide-react-native';
|
||||||
FileIcon,
|
|
||||||
HelpCircle,
|
|
||||||
BookOpen,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react-native';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
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;
|
const PLACEHOLDER_DURATION_MS = 5000;
|
||||||
|
|
||||||
interface FallbackParticleViewProps {
|
interface FallbackParticleViewProps {
|
||||||
@@ -24,6 +14,10 @@ interface FallbackParticleViewProps {
|
|||||||
onEnded: () => void;
|
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({
|
export function FallbackParticleView({
|
||||||
particle,
|
particle,
|
||||||
networkId,
|
networkId,
|
||||||
@@ -35,23 +29,8 @@ export function FallbackParticleView({
|
|||||||
particle.created_by_human_id,
|
particle.created_by_human_id,
|
||||||
network?.humans,
|
network?.humans,
|
||||||
);
|
);
|
||||||
const meta = TYPE_META[particle.type] ?? {
|
const Icon = HelpCircle;
|
||||||
icon: HelpCircle,
|
const title = particle.type === 'folder' ? particle.properties.name : null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
@@ -66,7 +45,7 @@ export function FallbackParticleView({
|
|||||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="text-white text-base font-semibold">
|
<Text className="text-white text-base font-semibold">
|
||||||
{meta.label}
|
{particle.type}
|
||||||
</Text>
|
</Text>
|
||||||
{title ? (
|
{title ? (
|
||||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
<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';
|
} from './stream-presence-context';
|
||||||
import { TextParticleView } from './TextParticleView';
|
import { TextParticleView } from './TextParticleView';
|
||||||
import { TaskParticleView } from './TaskParticleView';
|
import { TaskParticleView } from './TaskParticleView';
|
||||||
|
import { PaperParticleView } from './PaperParticleView';
|
||||||
|
import { FileParticleView } from './FileParticleView';
|
||||||
import { MediaParticleView } from './MediaParticleView';
|
import { MediaParticleView } from './MediaParticleView';
|
||||||
import { DeletedParticleView } from './DeletedParticleView';
|
import { DeletedParticleView } from './DeletedParticleView';
|
||||||
import { FallbackParticleView } from './FallbackParticleView';
|
import { FallbackParticleView } from './FallbackParticleView';
|
||||||
@@ -469,6 +471,25 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
onProgress={setProgress}
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
<FallbackParticleView
|
<FallbackParticleView
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MarkdownBody } from '@/components/MarkdownBody';
|
||||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useStreamSafeArea } from './stream-safe-area';
|
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({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
@@ -208,11 +58,6 @@ export function TextParticleView({
|
|||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const safe = useStreamSafeArea();
|
const safe = useStreamSafeArea();
|
||||||
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
|
||||||
renderer: MARKDOWN_RENDERER,
|
|
||||||
theme: MARKDOWN_THEME,
|
|
||||||
styles: MARKDOWN_STYLES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editedLabel = editedAt ? (
|
const editedLabel = editedAt ? (
|
||||||
<View className="mt-3 items-center">
|
<View className="mt-3 items-center">
|
||||||
@@ -290,7 +135,7 @@ export function TextParticleView({
|
|||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
indicatorStyle="white"
|
indicatorStyle="white"
|
||||||
>
|
>
|
||||||
{markdownNodes}
|
<MarkdownBody content={content} />
|
||||||
{editedLabel}
|
{editedLabel}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user