3d80ac2993
* 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * decrease clutter in stream-view * format * format * consolidate avatar --------- Co-authored-by: Claude <noreply@anthropic.com>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
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';
|
||
|
||
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||
|
||
interface TextParticleViewProps {
|
||
particle: TextParticle;
|
||
paused: boolean;
|
||
onEnded: () => void;
|
||
onProgress: (ratio: number) => void;
|
||
}
|
||
|
||
// Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per
|
||
// link/attachment, clamped 3–15s). Mobile v1 has no attachments and we
|
||
// don't extract link previews mid-render, so the formula collapses to
|
||
// a length-only base.
|
||
const CHARS_PER_MINUTE = 1000;
|
||
const MIN_DURATION_S = 3;
|
||
const MAX_DURATION_S = 15;
|
||
const TICK_MS = 100;
|
||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||
|
||
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);
|
||
}
|
||
|
||
function getImmersiveStyle(length: number) {
|
||
if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
|
||
if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
|
||
return { className: 'text-2xl font-normal leading-snug' };
|
||
}
|
||
|
||
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
||
// centered treatment; anything with markdown syntax renders formatted instead
|
||
// of showing raw `**asterisks**`. Covers the full GFM set desktop's Crepe
|
||
// engine handles — including ~~strikethrough~~ and tables — so short formatted
|
||
// messages drop to the rendered card rather than showing raw syntax.
|
||
function hasMarkdownFormatting(content: string): boolean {
|
||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|~~|^>|\|.*\|/m.test(
|
||
content,
|
||
);
|
||
}
|
||
|
||
export function TextParticleView({
|
||
particle,
|
||
paused,
|
||
onEnded,
|
||
onProgress,
|
||
}: TextParticleViewProps) {
|
||
const content = particle.properties.content;
|
||
const editedAt = particle.properties.edited_at;
|
||
const durationS = computeReadDuration(content);
|
||
const elapsedRef = useRef(0);
|
||
const safe = useStreamSafeArea();
|
||
|
||
const editedLabel = editedAt ? (
|
||
<View className="mt-3 items-center">
|
||
<Text className="text-white/40 text-xs">
|
||
edited <RelativeTimestamp date={editedAt} />
|
||
</Text>
|
||
</View>
|
||
) : null;
|
||
|
||
// Reset when the particle changes.
|
||
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]);
|
||
|
||
// Immersive (short, plain): centered, large type — feels like a lock-screen
|
||
// note. Short messages that contain markdown fall through to the rendered
|
||
// card so formatting isn't shown as raw syntax.
|
||
if (
|
||
content.length < IMMERSIVE_CHAR_LIMIT &&
|
||
!hasMarkdownFormatting(content)
|
||
) {
|
||
const style = getImmersiveStyle(content.length);
|
||
return (
|
||
<View
|
||
className="flex-1 items-center justify-center px-8"
|
||
style={{
|
||
paddingTop: safe.top + 16,
|
||
paddingBottom: safe.bottom + 16,
|
||
}}
|
||
>
|
||
<Text
|
||
className={cn('text-white text-center max-w-xl', style.className)}
|
||
>
|
||
{content}
|
||
</Text>
|
||
{editedLabel}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// Long text or markdown: scrollable card so the reader can pace themselves;
|
||
// the duration timer keeps ticking either way, which is intentional — long
|
||
// messages should still auto-advance at the 15s cap. Markdown is rendered
|
||
// via the useMarkdown hook (not the FlatList-based component) so its blocks
|
||
// nest cleanly inside this ScrollView. Padding is pulled from the
|
||
// StreamSafeArea so the card never slips under chrome.
|
||
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"
|
||
>
|
||
<MarkdownBody content={content} />
|
||
{editedLabel}
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|