feat: increase feature parity between mobile and desktop (#298)

* mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: network member management and avatars (parity phase 2)

- New NetworkSettingsScreen (reachable from the stream-list header) lists
  members with admin remove, an invite-by-email sheet, and pending
  invitations with revoke — backed by new use-member-management hooks.
- Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/
  getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a
  use-avatar-url hook, and image rendering in the shared Avatar component.
  AccountScreen gains a tap-to-change profile picture via expo-image-picker.
- auth-store gains refreshUser to pick up avatar changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: task particles — view, edit, and compose (parity phase 3)

Bring the task particle to parity with desktop's richer model:

- Replace the thin `quest` schema with desktop's `task` model
  (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done)
  in the discriminated union, the Firestore converter, and consumers
  (StreamCard, FallbackParticleView).
- New TaskParticleView renders an editable card (round done checkbox,
  title, notes, checklist with add/toggle/edit/remove, assignee chips)
  persisting each edit to Firestore; an 8s dwell auto-advances and
  field focus suspends playback. Wired into StreamView's render switch.
- Compose: a task button in the ComposeDock opens a TaskComposeSheet
  (createTaskParticle helper). Gated off in the new-stream flow, where a
  stream's first particle must be text or media.

Note: particles are written client-side to Firestore, matching desktop;
Orion's REST validator still only accepts `quest`, which is a pre-existing
inconsistency to reconcile backend-side separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* 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

* mobile: billing & usage in network settings (parity phase 5)

Surface the network plan, daily usage, and Stripe management — all backed
by client methods that already existed. New use-billing hooks and a
BillingSection (mirroring desktop): every member sees the plan + usage
summary; admins get cadence selection + "Upgrade to Pro" (checkout) and
"Manage subscription" (portal), opening Stripe in the system browser.
Added to NetworkSettingsScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <[email protected]>
This commit was merged in pull request #298.
This commit is contained in:
Arjun Patel
2026-06-21 10:38:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 428f9ea1c9
commit 3d80ac2993
38 changed files with 2458 additions and 352 deletions
@@ -1,22 +1,10 @@
import { useEffect } from 'react';
import { Text, View } from 'react-native';
import {
FileIcon,
HelpCircle,
ScrollText,
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 }> = {
quest: { icon: ScrollText, label: 'Quest' },
paper: { icon: BookOpen, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
const PLACEHOLDER_DURATION_MS = 5000;
interface FallbackParticleViewProps {
@@ -26,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,
@@ -37,25 +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 'quest':
return particle.properties.title;
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;
@@ -70,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>
);
}
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
import { Plus } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { resolveHumanDisplay } from '@/lib/humans';
import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils';
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -77,7 +77,6 @@ export function ReactionStack({
{activeTextKeys.map((text) => {
const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
return (
<Pressable
key={text}
@@ -93,11 +92,7 @@ export function ReactionStack({
: null,
]}
>
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
<Text className="text-white/90 text-xs" numberOfLines={1}>
{text}
</Text>
@@ -0,0 +1,50 @@
import { Text, View } from 'react-native';
import { Clock, Pause } from 'lucide-react-native';
interface StreamStatusPillsProps {
paused: boolean;
/** Null when no auto-exit is pending; ms remaining otherwise. */
exitRemainingMs: number | null;
}
/**
* Compact playback-state pills shown in the right chrome margin so they never
* overlay the particle canvas. "Paused" collapses to an icon-only badge; the
* exit countdown pairs a clock icon with tabular-number seconds so the digit
* tick doesn't reflow neighbors.
*/
export function StreamStatusPills({
paused,
exitRemainingMs,
}: StreamStatusPillsProps) {
if (!paused && exitRemainingMs === null) return null;
return (
<View className="flex-row items-center gap-1.5">
{paused ? (
<View
accessibilityLabel="Paused"
className="bg-white/15 h-6 w-6 items-center justify-center rounded-full"
>
<Pause color="white" size={11} fill="white" strokeWidth={0} />
</View>
) : null}
{exitRemainingMs !== null ? (
<View
accessibilityLabel={`Closing in ${Math.ceil(
exitRemainingMs / 1000,
)} seconds`}
className="bg-white/15 h-6 flex-row items-center gap-1 rounded-full px-2"
>
<Clock color="white" size={11} strokeWidth={2} />
<Text
className="text-white text-[11px] font-semibold"
style={{ fontVariant: ['tabular-nums'] }}
>
{Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
);
}
@@ -53,11 +53,15 @@ import {
useStreamComposing,
} 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';
import { useExitCountdown } from './use-exit-countdown';
import { StreamTopActions } from './StreamTopActions';
import { StreamStatusPills } from './StreamStatusPills';
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
import { StreamMembersSheet } from './StreamMembersSheet';
import { RenameStreamSheet } from './RenameStreamSheet';
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
contentFit={videoFit}
/>
);
case 'task':
return (
<TaskParticleView
key={particle.id}
particle={particle}
networkId={networkId}
streamId={streamParticle.id}
paused={paused}
onEnded={next}
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
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
/>
</View>
</View>
{/* Top status pills: paused + exit countdown. Anchored just below
the metadata row (avatar + name ≈ 40px tall, starts at
insets.top + 32) so they share the top chrome real estate
instead of competing with captions at the bottom. */}
<View
pointerEvents="none"
className="absolute inset-x-0 items-center"
style={{ top: insets.top + 88 }}
>
{paused ? (
<View className="bg-white/15 rounded-full px-3 py-1">
<Text className="text-white/90 text-xs font-medium">
Paused
</Text>
</View>
) : null}
{exitRemainingMs !== null ? (
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
<Text className="text-white/90 text-xs font-medium">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
</View>
</GestureDetector>
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
</View>
</View>
{/* Playback status pills — pinned to the right chrome margin just
below the top actions row so they live in the same band as the
chrome buttons instead of overlaying the particle canvas. */}
<View
pointerEvents="none"
className="absolute right-3"
style={{ top: insets.top + 72 }}
>
<StreamStatusPills
paused={paused}
exitRemainingMs={exitRemainingMs}
/>
</View>
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
centered on the canvas; outside the GestureDetector so each pill
tap toggles cleanly without competing with the stream advance/back
@@ -0,0 +1,435 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Keyboard,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { deleteField } from 'firebase/firestore';
import { Check, Plus, X } from 'lucide-react-native';
import type { ChecklistItem, Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import {
updateParticle,
updateParticleProperties,
} from '@/lib/firestore-particles';
import { useNetwork } from '@/hooks/use-networks';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { resolveHumanDisplay } from '@/lib/humans';
import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils';
import { useStreamSafeArea } from './stream-safe-area';
type TaskParticle = Extract<Particle, { type: 'task' }>;
interface TaskParticleViewProps {
particle: TaskParticle;
networkId: string;
streamId: string;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
const TICK_MS = 100;
/**
* Editable task card. Mirrors desktop's task-particle-view — round done
* checkbox + title, notes, a checklist, and an assignee picker — persisting
* each edit straight to Firestore. A fixed 8s dwell auto-advances the stream;
* focusing any field suspends playback so typing isn't raced by the timer.
*/
export function TaskParticleView({
particle,
networkId,
streamId,
paused,
onEnded,
onProgress,
}: TaskParticleViewProps) {
const network = useNetwork(networkId);
const safe = useStreamSafeArea();
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
);
const {
title,
notes,
checklist = [],
assigned_to,
done,
} = particle.properties;
// Suspend the dwell timer whenever a field is focused so typing isn't
// interrupted by an auto-advance. Mirrors desktop's `editing` suspender.
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
// --- Fixed dwell (mirrors TextParticleView's interval cadence) ---
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 / DWELL_DURATION_S, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, onEnded, onProgress, particle.id]);
// Checklist writes replace the whole array; concurrent edits are
// last-write-wins (same tradeoff desktop documents). Keep a ref so a second
// edit composes on the latest local base before the next snapshot arrives.
const checklistRef = useRef(checklist);
useEffect(() => {
checklistRef.current = checklist;
}, [checklist]);
const writeChecklist = useCallback(
(items: ChecklistItem[]) => {
checklistRef.current = items;
return updateParticleProperties<'task'>(docPath, { checklist: items });
},
[docPath],
);
const handleToggleDone = useCallback(
() => updateParticleProperties<'task'>(docPath, { done: !done }),
[docPath, done],
);
const handleToggleItem = useCallback(
(index: number) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, done: !item.done } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleCommitItemText = useCallback(
(index: number, text: string) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, text } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleRemoveItem = useCallback(
(index: number) => {
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
},
[writeChecklist],
);
const handleAddItem = useCallback(
(text: string) => {
void writeChecklist([...checklistRef.current, { text, done: false }]);
},
[writeChecklist],
);
const handleAssign = useCallback(
(humanId: string | null) => {
if (!humanId) {
void updateParticle(docPath, 'properties.assigned_to', deleteField());
} else {
void updateParticleProperties<'task'>(docPath, {
assigned_to: humanId,
});
}
},
[docPath],
);
const doneCount = checklist.filter((item) => item.done).length;
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
<ScrollView
className="max-h-full rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5 gap-5"
showsVerticalScrollIndicator
indicatorStyle="white"
keyboardShouldPersistTaps="handled"
// Dragging the card dismisses the keyboard (interactive follow on
// iOS; on-drag on Android, which lacks the interactive variant).
keyboardDismissMode={
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
}
>
{/* Title + done */}
<View
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
>
<Pressable
onPress={handleToggleDone}
hitSlop={8}
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
className={cn(
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
)}
>
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={title}
key={`title-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== title) {
void updateParticleProperties<'task'>(docPath, {
title: value,
});
}
}}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className={cn(
'flex-1 text-white text-2xl font-semibold',
done && 'text-white/50 line-through',
)}
/>
</View>
{/* Notes */}
<TextInput
defaultValue={notes ?? ''}
key={`notes-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== (notes ?? '')) {
void updateParticleProperties<'task'>(docPath, {
notes: value,
});
}
}}
placeholder="Add notes…"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className="text-white/80 text-base"
/>
{/* Checklist */}
<View className="gap-2">
{checklist.length > 0 ? (
<Text className="text-white/40 text-xs">
{doneCount} / {checklist.length} done
</Text>
) : null}
{checklist.map((item, index) => (
<ChecklistItemRow
key={`${particle.id}-item-${index}`}
item={item}
onToggle={() => handleToggleItem(index)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
onFocusChange={setEditing}
/>
))}
<AddChecklistItemRow
onAdd={handleAddItem}
onFocusChange={setEditing}
/>
</View>
{/* Assignee */}
<View className="gap-2">
<Text className="text-white/40 text-xs">Assignee</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2"
>
<AssigneeChip
label="Unassigned"
selected={!assigned_to}
onPress={() => handleAssign(null)}
/>
{network?.humans?.map((human) => {
const display = resolveHumanDisplay(human.id, network?.humans);
return (
<AssigneeChip
key={human.id}
label={display.displayName}
selected={assigned_to === human.id}
onPress={() => handleAssign(human.id)}
avatarHumanId={human.id}
humans={network?.humans}
/>
);
})}
</ScrollView>
</View>
</ScrollView>
{/* While a field is focused the keyboard hides the stream's tap-zones,
so offer an explicit way out. Dismissing blurs the active field,
which flips `editing` off and resumes the dwell + tap navigation. */}
{editing ? (
<View className="absolute right-2 top-2">
<Pressable
onPress={() => Keyboard.dismiss()}
hitSlop={8}
accessibilityLabel="Done editing"
className="rounded-full bg-white/15 px-3 py-1.5"
>
<Text className="text-white text-sm font-medium">Done</Text>
</Pressable>
</View>
) : null}
</View>
</View>
);
}
function ChecklistItemRow({
item,
onToggle,
onCommitText,
onRemove,
onFocusChange,
}: {
item: ChecklistItem;
onToggle: () => void;
onCommitText: (text: string) => void;
onRemove: () => void;
onFocusChange: (focused: boolean) => void;
}) {
return (
<View className="flex-row items-center gap-2.5">
<Pressable
onPress={onToggle}
hitSlop={6}
accessibilityLabel={
item.done ? 'Mark subtask not done' : 'Mark subtask done'
}
className={cn(
'h-5 w-5 items-center justify-center rounded border',
item.done ? 'bg-emerald-500 border-emerald-500' : 'border-white/30',
)}
>
{item.done ? <Check color="white" size={12} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={item.text}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== item.text) onCommitText(value);
}}
placeholder="Subtask"
placeholderTextColor="rgba(255,255,255,0.3)"
className={cn(
'flex-1 text-white/90 text-sm',
item.done && 'text-white/40 line-through',
)}
/>
<Pressable
onPress={onRemove}
hitSlop={6}
accessibilityLabel="Remove subtask"
>
<X color="rgba(255,255,255,0.4)" size={14} />
</Pressable>
</View>
);
}
function AddChecklistItemRow({
onAdd,
onFocusChange,
}: {
onAdd: (text: string) => void;
onFocusChange: (focused: boolean) => void;
}) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (!text) return;
onAdd(text);
setDraft('');
};
return (
<View className="flex-row items-center gap-2.5">
<Plus color="rgba(255,255,255,0.3)" size={16} />
<TextInput
value={draft}
onChangeText={setDraft}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onSubmitEditing={submit}
blurOnSubmit={false}
placeholder="Add subtask…"
placeholderTextColor="rgba(255,255,255,0.3)"
className="flex-1 text-white/70 text-sm"
/>
</View>
);
}
function AssigneeChip({
label,
selected,
onPress,
avatarHumanId,
humans,
}: {
label: string;
selected: boolean;
onPress: () => void;
avatarHumanId?: string;
humans?: import('@/api/types').Human[];
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-2 rounded-full px-3 py-2',
selected ? 'bg-white' : 'bg-white/10',
)}
>
{avatarHumanId ? (
<Avatar humanId={avatarHumanId} humans={humans} size="xs" />
) : null}
<Text
className={cn(
'text-sm font-medium',
selected ? 'text-black' : 'text-white/80',
)}
>
{label}
</Text>
</Pressable>
);
}
@@ -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>