From 428f9ea1c9856cf542bad6202348189233f48b5d Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 10:20:26 -0700 Subject: [PATCH 1/2] Add immersive focus mode for task editing (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): immersive focus mode for editing tasks Editing a task in the stream was ambiguous: nothing signalled that you were editing, playback chrome stayed live, Escape exited the whole stream, and arrow keys could navigate to another particle mid-edit. Focusing any field now enters a focus mode: the surrounding stream chrome dims and blurs while the task card lifts above it with a soft ring, so the task you're editing is unmistakable. Escape (or clicking outside the card) leaves the editor and resumes playback from where it paused instead of exiting the stream, and arrow/list keys no longer pull focus to another particle while editing. Text particles already get this via their full-screen edit overlay, so the two editing flows are now consistent. Addresses #286 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM * refactor(desktop): make task focus mode self-contained Handle editing focus entirely within TaskParticleView instead of threading editing state up to StreamView and special-casing the navigation hook. While editing, the card now registers a capture-phase key listener — the same pattern the app's other focus overlays use — that consumes Escape (blurring the field, which flushes the draft and resumes playback) and swallows stream-navigation keys so they can't pull focus to another particle. An "Esc to finish" hint shows while editing. This restores use-stream-navigation-keys to its original form and reduces the StreamView change to a single `immersive` prop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM * fix style of keyhint --------- Co-authored-by: Claude --- .../src/features/particles/stream-view.tsx | 1 + .../features/particles/task-particle-view.tsx | 71 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 6abe7e3..319710f 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { paused={paused} onEnded={handleParticleEnded} onProgress={setProgress} + immersive /> ); default: diff --git a/js/desktop/src/features/particles/task-particle-view.tsx b/js/desktop/src/features/particles/task-particle-view.tsx index cc73772..76c2209 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell'; import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { resolveHumanDisplay } from '@/lib/humans'; +import { isTypingTarget } from '@/lib/keyboard'; +import { KeyHint } from '@/components/key-hint'; type TaskParticle = Extract; @@ -37,11 +39,28 @@ interface TaskParticleViewProps { paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; + /** + * Enable focus mode while a field is focused: dim and blur the surrounding + * stream, and trap stream keys (Escape leaves the editor, navigation keys + * stay put). Off in the standalone leaf view, which has no stream chrome. + */ + immersive?: boolean; } const DWELL_DURATION_S = 8; const UNASSIGNED = 'unassigned'; +// Stream-navigation keys to swallow while editing so they can't pull focus to +// another particle (only when focus isn't already in a text field). +const NAV_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'l', + 'L', +]); + function useParticleDocPath( containerPath: ParticlePath, particleId: string, @@ -56,6 +75,7 @@ export function TaskParticleView({ paused, onEnded, onProgress, + immersive = false, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); @@ -74,6 +94,33 @@ export function TaskParticleView({ const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); + const exitFocus = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + + // While editing, the card acts like the app's other focus overlays: a + // capture-phase listener consumes Escape (which blurs the field — flushing + // the draft and resuming playback) and swallows stream-navigation keys, so + // the stream's own handlers never see them. Self-contained, so no editing + // state has to be threaded back up to the stream. + useEffect(() => { + if (!immersive || !editing) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + exitFocus(); + } else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) { + // Arrows still move the caret inside text fields; only block them when + // focus is on a non-text control (checkbox, assignee select). + e.stopPropagation(); + } + }; + window.addEventListener('keydown', onKeyDown, { capture: true }); + return () => + window.removeEventListener('keydown', onKeyDown, { capture: true }); + }, [immersive, editing, exitFocus]); + useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, @@ -166,8 +213,20 @@ export function TaskParticleView({ return (
+ {immersive && editing && ( +
+ )}
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); @@ -251,6 +310,16 @@ export function TaskParticleView({
+ {immersive && editing && ( + + to finish + + )}
); } From 3d80ac2993ae4463318d0b46528dbc5c9a980d0c Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 10:38:16 -0700 Subject: [PATCH 2/2] feat: increase feature parity between mobile and desktop (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) 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) 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) 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) 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) 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) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * decrease clutter in stream-view * format * format * consolidate avatar --------- Co-authored-by: Claude --- js/mobile/app.config.ts | 7 + js/mobile/package.json | 1 + js/mobile/src/api/client.ts | 40 ++ js/mobile/src/api/types.ts | 23 +- js/mobile/src/components/Avatar.tsx | 37 +- js/mobile/src/components/MarkdownBody.tsx | 165 +++++++ .../src/features/compose/ComposeDock.tsx | 136 ++++-- .../src/features/compose/TaskComposeSheet.tsx | 102 ++++ .../src/features/huddle/HuddleScreen.tsx | 6 +- .../network-settings/AddMembersSheet.tsx | 145 ++++++ .../network-settings/BillingSection.tsx | 281 +++++++++++ .../NetworkSettingsScreen.tsx | 218 +++++++++ .../features/networks/CreateNetworkSheet.tsx | 90 ++++ js/mobile/src/features/networks/Drawer.tsx | 27 +- .../features/networks/NetworkListScreen.tsx | 111 ++++- .../src/features/settings/AccountScreen.tsx | 93 +++- .../src/features/settings/SettingsScreen.tsx | 91 +++- .../stream-view/FallbackParticleView.tsx | 41 +- .../features/stream-view/FileParticleView.tsx | 103 +++++ .../stream-view/PaperParticleView.tsx | 79 ++++ .../features/stream-view/ReactionStack.tsx | 9 +- .../stream-view/StreamStatusPills.tsx | 50 ++ .../src/features/stream-view/StreamView.tsx | 74 ++- .../features/stream-view/TaskParticleView.tsx | 435 ++++++++++++++++++ .../features/stream-view/TextParticleView.tsx | 163 +------ .../src/features/streams/NewStreamScreen.tsx | 1 + js/mobile/src/features/streams/StreamCard.tsx | 56 +-- .../src/features/streams/StreamListScreen.tsx | 23 +- js/mobile/src/hooks/use-avatar-url.ts | 20 + js/mobile/src/hooks/use-billing.ts | 33 ++ js/mobile/src/hooks/use-invitations.ts | 39 ++ js/mobile/src/hooks/use-member-management.ts | 53 +++ js/mobile/src/lib/firestore-particles.ts | 2 +- js/mobile/src/lib/upload.ts | 26 ++ js/mobile/src/navigation/RootNavigator.tsx | 6 + js/mobile/src/navigation/types.ts | 1 + js/mobile/src/stores/auth-store.ts | 11 + js/mobile/yarn.lock | 12 + 38 files changed, 2458 insertions(+), 352 deletions(-) create mode 100644 js/mobile/src/components/MarkdownBody.tsx create mode 100644 js/mobile/src/features/compose/TaskComposeSheet.tsx create mode 100644 js/mobile/src/features/network-settings/AddMembersSheet.tsx create mode 100644 js/mobile/src/features/network-settings/BillingSection.tsx create mode 100644 js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx create mode 100644 js/mobile/src/features/networks/CreateNetworkSheet.tsx create mode 100644 js/mobile/src/features/stream-view/FileParticleView.tsx create mode 100644 js/mobile/src/features/stream-view/PaperParticleView.tsx create mode 100644 js/mobile/src/features/stream-view/StreamStatusPills.tsx create mode 100644 js/mobile/src/features/stream-view/TaskParticleView.tsx create mode 100644 js/mobile/src/hooks/use-avatar-url.ts create mode 100644 js/mobile/src/hooks/use-billing.ts create mode 100644 js/mobile/src/hooks/use-invitations.ts create mode 100644 js/mobile/src/hooks/use-member-management.ts diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 16403ec..0b077c3 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -55,6 +55,13 @@ const config: ExpoConfig = { 'Flowy uses your microphone to record voice messages.', }, ], + [ + 'expo-image-picker', + { + photosPermission: + 'Flowy uses your photo library to set your profile picture.', + }, + ], [ 'expo-notifications', { diff --git a/js/mobile/package.json b/js/mobile/package.json index 17be350..ced1e56 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -31,6 +31,7 @@ "expo-device": "~8.0.10", "expo-file-system": "~19.0.16", "expo-haptics": "~15.0.7", + "expo-image-picker": "~17.0.8", "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 27da3e9..a6bbcd4 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -1,3 +1,4 @@ +import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy'; import { appConfig } from '@/config/env'; import { ApiError } from '@/lib/errors'; import type { z } from 'zod'; @@ -136,6 +137,45 @@ class ApiClient { await this.requestVoid('PATCH', '/humans/me/settings', data); } + // --- Avatar --- + + /** + * Upload a new profile picture. The endpoint takes the raw image bytes as + * the request body (not multipart), so we stream the file directly via + * expo-file-system rather than the JSON `fetch` helper. + */ + async uploadAvatar(fileUri: string, mimeType: string): Promise { + const headers: Record = { 'Content-Type': mimeType }; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + const result = await uploadAsync( + `${this.baseUrl}/humans/me/avatar`, + fileUri, + { + httpMethod: 'PUT', + uploadType: FileSystemUploadType.BINARY_CONTENT, + headers, + }, + ); + if (result.status === 401) { + throw new ApiError(401, 'Unauthorized'); + } + if (result.status < 200 || result.status >= 300) { + throw new ApiError(result.status, result.body || 'Avatar upload failed'); + } + } + + async deleteAvatar(): Promise { + await this.requestVoid('DELETE', '/humans/me/avatar'); + } + + async getAvatarDownloadUrl(objectId: string): Promise { + const response = await this.fetch('GET', `/humans/avatar/${objectId}`); + const data = await response.json(); + return data.url; + } + // --- Push notification tokens --- async registerPushToken(data: { diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index 7410cbb..2f11e8d 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -6,6 +6,7 @@ export const HumanSchema = z.object({ email: z.string().email(), email_prefix: z.string(), email_notifications_enabled: z.boolean(), + avatar_object_id: z.string().nullable().optional(), }); export type Human = z.infer; @@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({ }); export type TextProperties = z.infer; -export const QuestPropertiesSchema = z.object({ +export const ChecklistItemSchema = z.object({ + text: z.string(), + done: z.boolean(), +}); +export type ChecklistItem = z.infer; + +export const TaskPropertiesSchema = z.object({ title: z.string(), - description: z.string(), - status: z.string().optional(), + notes: z.string().optional(), + checklist: z.array(ChecklistItemSchema).optional(), // humanId assigned_to: z.string().optional(), + done: z.boolean(), }); -export type QuestProperties = z.infer; +export type TaskProperties = z.infer; export const PaperPropertiesSchema = z.object({ title: z.string(), @@ -193,7 +201,7 @@ export interface ParticlePropertiesMap { media: MediaProperties; file: FileProperties; text: TextProperties; - quest: QuestProperties; + task: TaskProperties; paper: PaperProperties; } @@ -247,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [ ...TombstoneFields, }), ParticleBaseSchema.extend({ - type: z.literal('quest'), - properties: QuestPropertiesSchema, + type: z.literal('task'), + properties: TaskPropertiesSchema, + reactions: ReactionsSchema, ...TombstoneFields, }), ParticleBaseSchema.extend({ diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index 8655682..4654909 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -1,9 +1,10 @@ -import { Text, View } from 'react-native'; +import { Image, Text, View } from 'react-native'; import type { Human } from '@/api/types'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { resolveHumanDisplay } from '@/lib/humans'; import { cn } from '@/lib/utils'; -type Size = 'xs' | 'sm' | 'md'; +type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; interface AvatarProps { humanId: string | null | undefined; @@ -13,6 +14,8 @@ interface AvatarProps { online?: boolean; /** Background ring used to separate stacked avatars from the chrome. */ stackBg?: string; + /** Initials shown when the human can't be resolved (e.g. a group stream). */ + fallbackInitials?: string; className?: string; } @@ -20,12 +23,15 @@ const sizeMap: Record = { xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 }, sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 }, md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 }, + lg: { box: 'h-16 w-16', text: 'text-xl', ring: 2.5 }, + xl: { box: 'h-24 w-24', text: 'text-3xl', ring: 3 }, }; /** - * Initials avatar with optional online ring (green) and an optional outer - * stack ring used to visually separate overlapping avatars on a busy chrome. - * Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`). + * Human avatar: renders the profile picture when one is set (resolved to a + * signed URL via React Query), otherwise initials. Supports an optional online + * ring (green) and an outer stack separator ring used to keep overlapping + * avatars distinct on busy chrome. Matches desktop's avatar + presence pattern. */ export function Avatar({ humanId, @@ -33,15 +39,22 @@ export function Avatar({ size = 'sm', online = false, stackBg, + fallbackInitials, className, }: AvatarProps) { - const { initials } = resolveHumanDisplay(humanId, humans); + const display = resolveHumanDisplay(humanId, humans); + const human = humanId ? humans?.find((h) => h.id === humanId) : undefined; + const avatarUrl = useAvatarUrl(human?.avatar_object_id); const dims = sizeMap[size]; + const initials = + display.exists || fallbackInitials === undefined + ? display.initials + : fallbackInitials; return ( - - {initials} - + {avatarUrl ? ( + + ) : ( + + {initials} + + )} ); } 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/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index c706257..bf86906 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native'; +import { + ListTodo, + Mic, + Type as TypeIcon, + Video as VideoIcon, +} from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera'; import { toast } from 'sonner-native'; @@ -8,13 +13,18 @@ import { cn } from '@/lib/utils'; import { useEvent } from '@/hooks/use-event'; import { usePlaybackPauseStore } from '@/stores/playback-pause-store'; import { useAuthStore } from '@/stores/auth-store'; -import { createTextParticle, uploadMediaParticle } from '@/lib/upload'; +import { + createTaskParticle, + createTextParticle, + uploadMediaParticle, +} from '@/lib/upload'; import type { ParticlePath } from '@/lib/particle-path'; import { useStreamComposingBroadcastOptional, type ComposingMode, } from '@/features/stream-view/stream-presence-context'; import { TextComposeModal } from './TextComposeModal'; +import { TaskComposeSheet } from './TaskComposeSheet'; import { VideoRecordingOverlay } from './VideoRecordingOverlay'; import { AudioRecordingOverlay } from './AudioRecordingOverlay'; import { ReviewSheet } from './ReviewSheet'; @@ -48,6 +58,12 @@ interface ComposeDockProps { networkId: string; targetPath: ParticlePath; silentPresence?: boolean; + /** + * Whether to show the task compose button. Disabled in the new-stream flow, + * where a stream's first particle must be text or media (a task can't open a + * stream — it's added once the stream exists). + */ + allowTask?: boolean; submitMedia?: (params: SubmitMediaParams) => Promise; submitText?: (content: string) => Promise; /** @@ -63,6 +79,7 @@ export function ComposeDock({ networkId, targetPath, silentPresence = false, + allowTask = true, submitMedia, submitText: submitTextOverride, onParticleCreated, @@ -72,6 +89,7 @@ export function ComposeDock({ const [mode, setMode] = useState('video'); const [ui, setUi] = useState({ kind: 'idle' }); const [textOpen, setTextOpen] = useState(false); + const [taskOpen, setTaskOpen] = useState(false); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); @@ -79,13 +97,13 @@ export function ComposeDock({ // Tell StreamView to fully unmount its expo-video player while we record. // That player otherwise holds the iOS AVAudioSession and crashes the camera. const setComposing = usePlaybackPauseStore((s) => s.setComposing); - const isComposing = ui.kind !== 'idle' || textOpen; + const isComposing = ui.kind !== 'idle' || textOpen || taskOpen; useEffect(() => { setComposing(isComposing); return () => setComposing(false); }, [isComposing, setComposing]); - useComposingBroadcast({ ui, textOpen, silent: silentPresence }); + useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence }); const ensurePermissions = useCallback( async (forVideo: boolean): Promise => { @@ -187,6 +205,20 @@ export function ComposeDock({ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); + const submitTask = useEvent( + async ({ title, notes }: { title: string; notes?: string }) => { + if (!userId) throw new Error('Not signed in.'); + const particleId = await createTaskParticle({ + targetPath, + title, + notes, + createdByHumanId: userId, + }); + onParticleCreated?.(particleId); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + ); + const dockHidden = ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording'; @@ -196,27 +228,31 @@ export function ComposeDock({ - - setMode((m) => (m === 'video' ? 'audio' : 'video')) - } - disabled={ui.kind !== 'idle'} - accessibilityLabel={`Switch to ${ - mode === 'video' ? 'audio' : 'video' - } mode`} - className={cn( - 'h-11 w-11 items-center justify-center rounded-full bg-white/15', - ui.kind !== 'idle' && 'opacity-40', - )} - > - {mode === 'video' ? ( - - ) : ( - - )} - + {/* Left and right clusters flex equally so the record button stays + centered regardless of how many side controls are present. */} + + + setMode((m) => (m === 'video' ? 'audio' : 'video')) + } + disabled={ui.kind !== 'idle'} + accessibilityLabel={`Switch to ${ + mode === 'video' ? 'audio' : 'video' + } mode`} + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + {mode === 'video' ? ( + + ) : ( + + )} + + Tap to record - setTextOpen(true)} - disabled={ui.kind !== 'idle'} - accessibilityLabel="Compose text" - className={cn( - 'h-11 w-11 items-center justify-center rounded-full bg-white/15', - ui.kind !== 'idle' && 'opacity-40', - )} - > - - + + {allowTask ? ( + setTaskOpen(true)} + disabled={ui.kind !== 'idle'} + accessibilityLabel="Create task" + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + + + ) : null} + + setTextOpen(true)} + disabled={ui.kind !== 'idle'} + accessibilityLabel="Compose text" + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + + + ) : null} @@ -277,6 +329,12 @@ export function ComposeDock({ onClose={() => setTextOpen(false)} onSubmit={submitText} /> + + setTaskOpen(false)} + onSubmit={submitTask} + /> ); } @@ -284,17 +342,23 @@ export function ComposeDock({ function useComposingBroadcast({ ui, textOpen, + taskOpen, silent, }: { ui: ComposeUiState; textOpen: boolean; + taskOpen: boolean; silent: boolean; }) { // null when the dock is rendered outside a stream (no presence provider). const broadcast = useStreamComposingBroadcastOptional(); const mode: ComposingMode | null = - ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null; + ui.kind === 'recording' + ? 'recording' + : textOpen || taskOpen + ? 'typing' + : null; useEffect(() => { if (silent || !broadcast) return; diff --git a/js/mobile/src/features/compose/TaskComposeSheet.tsx b/js/mobile/src/features/compose/TaskComposeSheet.tsx new file mode 100644 index 0000000..f222b9f --- /dev/null +++ b/js/mobile/src/features/compose/TaskComposeSheet.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface TaskComposeSheetProps { + open: boolean; + onClose: () => void; + /** Create the task. Must throw on failure so the sheet keeps the draft. */ + onSubmit: (input: { title: string; notes?: string }) => Promise; +} + +/** + * Quick task creator. Mirrors desktop's task-compose-step but pared to the + * essentials — title (required) plus optional notes. Checklist and assignee + * are added inline in the task card once it exists. + */ +export function TaskComposeSheet({ + open, + onClose, + onSubmit, +}: TaskComposeSheetProps) { + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setTitle(''); + setNotes(''); + setSubmitting(false); + } + } + + const trimmedTitle = title.trim(); + const canSend = trimmedTitle.length > 0 && !submitting; + + const handleSubmit = async () => { + if (!canSend) return; + setSubmitting(true); + try { + await onSubmit({ + title: trimmedTitle, + notes: notes.trim() || undefined, + }); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + setSubmitting(false); + } + }; + + return ( + + + New task + + + + + + + + {submitting ? 'Creating…' : 'Create task'} + + + + + ); +} diff --git a/js/mobile/src/features/huddle/HuddleScreen.tsx b/js/mobile/src/features/huddle/HuddleScreen.tsx index 9890290..8d1e420 100644 --- a/js/mobile/src/features/huddle/HuddleScreen.tsx +++ b/js/mobile/src/features/huddle/HuddleScreen.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from 'react'; import { Alert, Dimensions, Pressable, Text, View } from 'react-native'; import type { Human } from '@/api/types'; +import { Avatar } from '@/components/Avatar'; import { SafeAreaView } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { @@ -215,7 +216,6 @@ function Tile({ const identity = tile.participant.identity; const display = resolveHumanDisplay(identity, humans); const name = tile.participant.name || display.displayName; - const initials = display.initials; const isSpeaking = tile.participant.isSpeaking; const muted = tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ?? @@ -234,9 +234,7 @@ function Tile({ ) : ( - - {initials} - + )} diff --git a/js/mobile/src/features/network-settings/AddMembersSheet.tsx b/js/mobile/src/features/network-settings/AddMembersSheet.tsx new file mode 100644 index 0000000..9427c01 --- /dev/null +++ b/js/mobile/src/features/network-settings/AddMembersSheet.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { useAddMembers } from '@/hooks/use-member-management'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface AddMembersSheetProps { + open: boolean; + onClose: () => void; + networkId: string; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Invite people to a network by email. Mirrors desktop's add-members-dialog — + * accepts one email at a time (comma/space/enter to commit), shows chips, and + * submits the batch via `addMembers`. + */ +export function AddMembersSheet({ + open, + onClose, + networkId, +}: AddMembersSheetProps) { + const [draft, setDraft] = useState(''); + const [emails, setEmails] = useState([]); + const addMembers = useAddMembers(networkId); + + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setDraft(''); + setEmails([]); + } + } + + const commitDraft = (): string[] => { + const candidate = draft.trim().toLowerCase().replace(/,$/, ''); + if (!candidate) return emails; + if (!EMAIL_RE.test(candidate)) { + toast.error('Enter a valid email address'); + return emails; + } + if (emails.includes(candidate)) { + setDraft(''); + return emails; + } + const next = [...emails, candidate]; + setEmails(next); + setDraft(''); + return next; + }; + + const removeEmail = (email: string) => + setEmails((prev) => prev.filter((e) => e !== email)); + + const handleSubmit = async () => { + const finalEmails = commitDraft(); + if (finalEmails.length === 0) return; + try { + await addMembers.mutateAsync(finalEmails); + toast.success( + finalEmails.length === 1 + ? 'Invitation sent' + : `${finalEmails.length} invitations sent`, + ); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + const canSubmit = + !addMembers.isPending && + (emails.length > 0 || EMAIL_RE.test(draft.trim().toLowerCase())); + + return ( + + + Add members + + Existing users join right away; others get an email invite. + + + {emails.length > 0 ? ( + + {emails.map((email) => ( + removeEmail(email)} + className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1" + > + {email} + × + + ))} + + ) : null} + + { + if (text.endsWith(',') || text.endsWith(' ')) { + setDraft(text); + commitDraft(); + } else { + setDraft(text); + } + }} + placeholder="name@example.com" + placeholderTextColor="rgba(255,255,255,0.4)" + autoFocus + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + returnKeyType="done" + onSubmitEditing={() => commitDraft()} + editable={!addMembers.isPending} + className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4" + /> + + + + {addMembers.isPending ? 'Sending…' : 'Send invites'} + + + + + ); +} diff --git a/js/mobile/src/features/network-settings/BillingSection.tsx b/js/mobile/src/features/network-settings/BillingSection.tsx new file mode 100644 index 0000000..8d8dbb1 --- /dev/null +++ b/js/mobile/src/features/network-settings/BillingSection.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; +import { Linking, Pressable, Text, View } from 'react-native'; +import { ExternalLink } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { BillingCadence, BillingStatus } from '@/api/types'; +import { + useCreateCheckoutSession, + useCreatePortalSession, + useNetworkBilling, + useNetworkUsage, +} from '@/hooks/use-billing'; +import { useIsNetworkAdmin } from '@/hooks/use-networks'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +function formatCents(cents: number): string { + if (cents % 100 === 0) return `$${cents / 100}`; + return `$${(cents / 100).toFixed(2)}`; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + {label} + + {value} + + ); +} + +/** + * Plan + usage summary for every member, plus admin-only upgrade/manage + * controls. Mirrors desktop's BillingSection — `/usage` powers the + * everyone-visible summary; `/billing` (admin-gated) drives the controls. + * Stripe checkout/portal URLs are opened in the system browser. + */ +export function BillingSection({ networkId }: { networkId: string }) { + const isAdmin = useIsNetworkAdmin(networkId); + const { data: usage } = useNetworkUsage(networkId); + + const isPro = usage?.plan === 'pro'; + + return ( + + + Plan & billing + + + + {isPro ? 'Llink Pro' : 'Llink Free'} + + } + /> + {!isPro && usage?.limit != null ? ( + + {usage.used} / {usage.limit} + + } + /> + ) : null} + + {isAdmin ? : null} + + ); +} + +function AdminBillingControls({ networkId }: { networkId: string }) { + const { + data: billing, + isLoading, + error, + } = useNetworkBilling(networkId, true); + + if (isLoading || !billing) { + return ( + + {error ? `Couldn’t load billing: ${toUserMessage(error)}` : 'Loading…'} + + ); + } + + return billing.plan === 'pro' ? ( + + ) : ( + + ); +} + +function FreeBilling({ + networkId, + billing, +}: { + networkId: string; + billing: BillingStatus; +}) { + const createCheckout = useCreateCheckoutSession(networkId); + const [cadence, setCadence] = useState('annual'); + + const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12); + const savingsPct = Math.round( + (1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100, + ); + + const handleUpgrade = async () => { + try { + const { url } = await createCheckout.mutateAsync(cadence); + await Linking.openURL(url); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + 0 ? `Save ${savingsPct}%` : undefined} + selected={cadence === 'annual'} + onPress={() => setCadence('annual')} + /> + setCadence('monthly')} + /> + + + {createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'} + + + + ); +} + +function CadenceOption({ + label, + note, + perSeatCents, + badge, + selected, + onPress, +}: { + label: string; + note: string; + perSeatCents: number; + badge?: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + + + {label} + {badge ? ( + + + {badge} + + + ) : null} + + {note} + + + + {formatCents(perSeatCents)} + + per seat / mo + + + ); +} + +function ProBilling({ + networkId, + billing, +}: { + networkId: string; + billing: BillingStatus; +}) { + const createPortal = useCreatePortalSession(networkId); + + const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly'; + const perSeatCents = + billing.cadence === 'annual' + ? Math.round(billing.price_annual_cents / 12) + : billing.price_monthly_cents; + const renewal = billing.current_period_end + ? formatDate(billing.current_period_end) + : null; + + const handleManage = async () => { + try { + const { url } = await createPortal.mutateAsync(); + await Linking.openURL(url); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + {billing.cancel_at_period_end && renewal ? ( + + Your subscription downgrades to Free on {renewal}. + + ) : null} + {billing.plan_status === 'past_due' ? ( + + Your last payment failed. Update your payment method to keep Pro + active. + + ) : null} + + + {`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`} + + } + /> + {billing.seats}} + /> + {renewal ? ( + {renewal}} + /> + ) : null} + + + + + {createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'} + + + + ); +} diff --git a/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx new file mode 100644 index 0000000..9ae3632 --- /dev/null +++ b/js/mobile/src/features/network-settings/NetworkSettingsScreen.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react'; +import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Mail, Shield, UserPlus, X } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Human } from '@/api/types'; +import { useNetwork } from '@/hooks/use-networks'; +import { + useNetworkInvitations, + useRemoveMember, + useRevokeInvitation, +} from '@/hooks/use-member-management'; +import { useAuthStore } from '@/stores/auth-store'; +import { Avatar } from '@/components/Avatar'; +import { toUserMessage } from '@/lib/errors'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { AddMembersSheet } from './AddMembersSheet'; +import { BillingSection } from './BillingSection'; + +export function NetworkSettingsScreen({ + route, + navigation, +}: RootStackScreenProps<'NetworkSettings'>) { + const { networkId } = route.params; + const network = useNetwork(networkId); + const { data: invitations, error: invitationsError } = + useNetworkInvitations(networkId); + const currentUserId = useAuthStore((s) => s.user?.id); + const isAdmin = !!currentUserId && network?.admin_human.id === currentUserId; + const [addOpen, setAddOpen] = useState(false); + + const removeMember = useRemoveMember(networkId); + const members = network?.humans ?? []; + const pending = invitations ?? []; + + const handleRemove = (human: Human) => { + Alert.alert( + `Remove ${human.email_prefix}?`, + "They'll lose access to this network's streams and files. Content they posted stays in the network.", + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + try { + await removeMember.mutateAsync(human.id); + toast.success(`Removed ${human.email}`); + } catch (err) { + toast.error(toUserMessage(err)); + } + }, + }, + ], + ); + }; + + return ( + + + navigation.goBack()} className="px-2 py-1"> + + + + {network?.name ?? 'Network'} + + + + + + + + + Members + + + {members.length} {members.length === 1 ? 'member' : 'members'} + + + {isAdmin ? ( + setAddOpen(true)} + className="flex-row items-center bg-primary rounded-full px-3 py-2" + > + + + Add + + + ) : null} + + + + {members.map((human) => { + const isRowAdmin = human.id === network?.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUserId; + return ( + + + + + {human.email_prefix} + + + {human.email} + + + {isRowAdmin ? ( + + + + Admin + + + ) : null} + {canRemove ? ( + handleRemove(human)} + hitSlop={8} + className="p-1" + accessibilityLabel={`Remove ${human.email}`} + > + + + ) : null} + + ); + })} + + + {isAdmin ? ( + + + Pending invitations + + {invitationsError ? ( + + Couldn’t load pending invitations. + + ) : pending.length === 0 ? ( + + No pending invitations. + + ) : ( + + {pending.map((inv) => ( + + ))} + + )} + + ) : null} + + + + + + + {isAdmin ? ( + setAddOpen(false)} + networkId={networkId} + /> + ) : null} + + ); +} + +function PendingInvitationRow({ + email, + networkId, +}: { + email: string; + networkId: string; +}) { + const revokeInvitation = useRevokeInvitation(networkId); + + const handleRevoke = async () => { + try { + await revokeInvitation.mutateAsync(email); + toast.success(`Invitation to ${email} revoked`); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + + + + + {email} + + Pending + + + + + + ); +} diff --git a/js/mobile/src/features/networks/CreateNetworkSheet.tsx b/js/mobile/src/features/networks/CreateNetworkSheet.tsx new file mode 100644 index 0000000..6c892bf --- /dev/null +++ b/js/mobile/src/features/networks/CreateNetworkSheet.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { useCreateNetwork } from '@/hooks/use-invitations'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface CreateNetworkSheetProps { + open: boolean; + onClose: () => void; + /** Called with the new network's id once creation succeeds. */ + onCreated?: (networkId: string) => void; +} + +/** + * Minimal "name your network" sheet. Mirrors desktop's create-network flow in + * `network-selector.tsx` — single text field, creator becomes admin. + */ +export function CreateNetworkSheet({ + open, + onClose, + onCreated, +}: CreateNetworkSheetProps) { + const [name, setName] = useState(''); + const createNetwork = useCreateNetwork(); + + // Reset the field each time the sheet opens fresh. + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) setName(''); + } + + const trimmed = name.trim(); + const canCreate = trimmed.length > 0 && !createNetwork.isPending; + + const handleCreate = async () => { + if (!canCreate) return; + try { + const network = await createNetwork.mutateAsync({ name: trimmed }); + onClose(); + onCreated?.(network.id); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + New network + + You’ll be the admin and can invite people next. + + + + + + + {createNetwork.isPending ? 'Creating…' : 'Create network'} + + + + + ); +} diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index 4c03114..6a7258b 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -13,6 +13,7 @@ import { SafeAreaProvider, SafeAreaView, } from 'react-native-safe-area-context'; +import { Avatar } from '@/components/Avatar'; import { useAuthStore } from '@/stores/auth-store'; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -26,7 +27,12 @@ interface DrawerProps { onNavigateSettings: () => void; } -export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { +export function Drawer({ + open, + onClose, + onNavigateAccount, + onNavigateSettings, +}: DrawerProps) { // Lazy-init so each Animated.Value is created once; the setters are never // called — the values are mutated internally by the native driver. const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH)); @@ -53,8 +59,6 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { const signOut = useAuthStore((s) => s.signOut); const isSigningOut = useAuthStore((s) => s.isSigningOut); - const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??'; - return ( - - - {initials} - - + + { + onClose(); + onNavigateSettings(); + }} + /> diff --git a/js/mobile/src/features/networks/NetworkListScreen.tsx b/js/mobile/src/features/networks/NetworkListScreen.tsx index 8473932..fdefe17 100644 --- a/js/mobile/src/features/networks/NetworkListScreen.tsx +++ b/js/mobile/src/features/networks/NetworkListScreen.tsx @@ -8,22 +8,28 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import type { Network } from '@/api/types'; +import { Plus } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Invitation, Network } from '@/api/types'; import { useNetworks } from '@/hooks/use-networks'; +import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations'; import { useAuthStore } from '@/stores/auth-store'; import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; +import { Avatar } from '@/components/Avatar'; import { FlowyLogo } from '@/components/FlowyLogo'; import { ListSeparator } from '@/components/ListSeparator'; import { Drawer } from './Drawer'; +import { CreateNetworkSheet } from './CreateNetworkSheet'; export function NetworkListScreen({ navigation, }: RootStackScreenProps<'NetworkList'>) { const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const { data, isLoading, refetch, error } = useNetworks(); + const { data: invitations, refetch: refetchInvitations } = useMyInvitations(); const user = useAuthStore((s) => s.user); - const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??'; // Local refreshing state — driving RefreshControl from react-query's // isRefetching can leave the native spinner visually stuck after the @@ -32,11 +38,13 @@ export function NetworkListScreen({ const onRefresh = useCallback(async () => { setRefreshing(true); try { - await refetch(); + await Promise.all([refetch(), refetchInvitations()]); } finally { setRefreshing(false); } - }, [refetch]); + }, [refetch, refetchInvitations]); + + const pendingInvitations = invitations ?? []; return ( @@ -44,14 +52,21 @@ export function NetworkListScreen({ setDrawerOpen(true)} accessibilityLabel="Open menu" - className="bg-muted h-9 w-9 items-center justify-center rounded-full" > - - {initials} - + - + setCreateOpen(true)} + accessibilityLabel="Create network" + className="bg-muted h-9 w-9 items-center justify-center rounded-full" + > + + {isLoading ? ( @@ -67,16 +82,21 @@ export function NetworkListScreen({ Retry - ) : !data || data.length === 0 ? ( - + ) : (!data || data.length === 0) && pendingInvitations.length === 0 ? ( + setCreateOpen(true)} /> ) : ( item.id} refreshControl={ } ItemSeparatorComponent={ListSeparator} + ListHeaderComponent={ + pendingInvitations.length > 0 ? ( + + ) : null + } renderItem={({ item }) => ( navigation.navigate('Account')} onNavigateSettings={() => navigation.navigate('Settings')} /> + + setCreateOpen(false)} + onCreated={(networkId) => + navigation.navigate('StreamList', { networkId }) + } + /> ); } +function InvitationsSection({ invitations }: { invitations: Invitation[] }) { + return ( + + + Invitations + + {invitations.map((invitation) => ( + + ))} + + ); +} + +function InvitationCard({ invitation }: { invitation: Invitation }) { + const acceptInvitation = useAcceptInvitation(); + + const handleAccept = async () => { + try { + await acceptInvitation.mutateAsync(invitation.network_id); + } catch (err) { + toast.error(toUserMessage(err)); + } + }; + + return ( + + + + {invitation.network_name} + + + You’ve been invited to join + + + + + {acceptInvitation.isPending ? 'Joining…' : 'Accept'} + + + + ); +} + function NetworkCard({ network, onPress, @@ -124,15 +199,23 @@ function NetworkCard({ ); } -function EmptyState() { +function EmptyState({ onCreate }: { onCreate: () => void }) { return ( You aren’t in any networks yet. - Ask a friend for an invite, or create one on desktop. + Create one to get started, or ask a friend for an invite. + + + Create a network + + ); } diff --git a/js/mobile/src/features/settings/AccountScreen.tsx b/js/mobile/src/features/settings/AccountScreen.tsx index e57f93a..2347a4f 100644 --- a/js/mobile/src/features/settings/AccountScreen.tsx +++ b/js/mobile/src/features/settings/AccountScreen.tsx @@ -1,10 +1,68 @@ -import { Pressable, Text, View } from 'react-native'; +import { useState } from 'react'; +import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import * as ImagePicker from 'expo-image-picker'; +import { Camera } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import { apiClient } from '@/api/client'; +import { Avatar } from '@/components/Avatar'; import { useAuthStore } from '@/stores/auth-store'; +import { toUserMessage } from '@/lib/errors'; import type { RootStackScreenProps } from '@/navigation/types'; export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { const user = useAuthStore((s) => s.user); + const refreshUser = useAuthStore((s) => s.refreshUser); + const [busy, setBusy] = useState(false); + + const pickAndUpload = async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + toast.error('Photo library access is needed to set an avatar.'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + if (result.canceled) return; + const asset = result.assets[0]; + if (!asset) return; + + setBusy(true); + try { + await apiClient.uploadAvatar(asset.uri, asset.mimeType ?? 'image/jpeg'); + await refreshUser(); + toast.success('Avatar updated'); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setBusy(false); + } + }; + + const removeAvatar = () => { + Alert.alert('Remove avatar?', 'Your initials will be shown instead.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + setBusy(true); + try { + await apiClient.deleteAvatar(); + await refreshUser(); + } catch (err) { + toast.error(toUserMessage(err)); + } finally { + setBusy(false); + } + }, + }, + ]); + }; return ( @@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { - + + + {busy ? ( + + + + ) : ( + + )} + + + + + + {user?.avatar_object_id ? ( + + Remove photo + + ) : null} + + + diff --git a/js/mobile/src/features/settings/SettingsScreen.tsx b/js/mobile/src/features/settings/SettingsScreen.tsx index 6b71a6e..ffa1505 100644 --- a/js/mobile/src/features/settings/SettingsScreen.tsx +++ b/js/mobile/src/features/settings/SettingsScreen.tsx @@ -1,10 +1,47 @@ -import { Pressable, Text, View } from 'react-native'; +import { useState } from 'react'; +import { Pressable, Switch, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; +import { apiClient } from '@/api/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { logError, toUserMessage } from '@/lib/errors'; +import { toast } from 'sonner-native'; import type { RootStackScreenProps } from '@/navigation/types'; export function SettingsScreen({ navigation, }: RootStackScreenProps<'Settings'>) { + const user = useAuthStore((s) => s.user); + const signOut = useAuthStore((s) => s.signOut); + const isSigningOut = useAuthStore((s) => s.isSigningOut); + const [emailNotifications, setEmailNotifications] = useState( + user?.email_notifications_enabled ?? true, + ); + const version = Constants.expoConfig?.version ?? '—'; + + // Optimistic toggle — flip the local + auth-store state immediately, roll + // back on failure. Mirrors desktop's settings-page handler. + const handleToggleEmailNotifications = async (checked: boolean) => { + setEmailNotifications(checked); + useAuthStore.setState((state) => ({ + user: state.user + ? { ...state.user, email_notifications_enabled: checked } + : null, + })); + try { + await apiClient.updateSettings({ email_notifications_enabled: checked }); + } catch (err) { + setEmailNotifications(!checked); + useAuthStore.setState((state) => ({ + user: state.user + ? { ...state.user, email_notifications_enabled: !checked } + : null, + })); + toast.error(toUserMessage(err)); + logError(err, { scope: 'settings.emailNotifications' }); + } + }; + return ( @@ -17,11 +54,55 @@ export function SettingsScreen({ - - - Theme, notifications, and account preferences land here later. - + + + + + Email notifications + + + + + + + + Version + {version} + + + + + void signOut()} + disabled={isSigningOut} + className="px-4 py-3 active:bg-accent rounded-xl" + > + + {isSigningOut ? 'Signing out…' : 'Sign out'} + + + ); } + +function SettingsGroup({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx index 51e6959..a5098cd 100644 --- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx +++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx @@ -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 = { - 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({ - {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/ReactionStack.tsx b/js/mobile/src/features/stream-view/ReactionStack.tsx index c88afb0..63db52a 100644 --- a/js/mobile/src/features/stream-view/ReactionStack.tsx +++ b/js/mobile/src/features/stream-view/ReactionStack.tsx @@ -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(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 ( - - - {firstReactor.initials} - - + {text} diff --git a/js/mobile/src/features/stream-view/StreamStatusPills.tsx b/js/mobile/src/features/stream-view/StreamStatusPills.tsx new file mode 100644 index 0000000..4ce1e55 --- /dev/null +++ b/js/mobile/src/features/stream-view/StreamStatusPills.tsx @@ -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 ( + + {paused ? ( + + + + ) : null} + {exitRemainingMs !== null ? ( + + + + {Math.ceil(exitRemainingMs / 1000)}s + + + ) : null} + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index e1e438e..18609fc 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -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 ( + + ); + case 'paper': + return ( + + ); + case 'file': + return ( + + ); default: return ( - - {/* 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. */} - - {paused ? ( - - - Paused - - - ) : null} - {exitRemainingMs !== null ? ( - - - Closing in {Math.ceil(exitRemainingMs / 1000)}s - - - ) : null} - @@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { + {/* 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. */} + + + + {/* 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 diff --git a/js/mobile/src/features/stream-view/TaskParticleView.tsx b/js/mobile/src/features/stream-view/TaskParticleView.tsx new file mode 100644 index 0000000..649e08c --- /dev/null +++ b/js/mobile/src/features/stream-view/TaskParticleView.tsx @@ -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; + +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 ( + + + + {/* Title + done */} + + + {done ? : null} + + 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', + )} + /> + + + {/* Notes */} + 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 */} + + {checklist.length > 0 ? ( + + {doneCount} / {checklist.length} done + + ) : null} + {checklist.map((item, index) => ( + handleToggleItem(index)} + onCommitText={(text) => handleCommitItemText(index, text)} + onRemove={() => handleRemoveItem(index)} + onFocusChange={setEditing} + /> + ))} + + + + {/* Assignee */} + + Assignee + + handleAssign(null)} + /> + {network?.humans?.map((human) => { + const display = resolveHumanDisplay(human.id, network?.humans); + return ( + handleAssign(human.id)} + avatarHumanId={human.id} + humans={network?.humans} + /> + ); + })} + + + + + {/* 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 ? ( + + Keyboard.dismiss()} + hitSlop={8} + accessibilityLabel="Done editing" + className="rounded-full bg-white/15 px-3 py-1.5" + > + Done + + + ) : null} + + + ); +} + +function ChecklistItemRow({ + item, + onToggle, + onCommitText, + onRemove, + onFocusChange, +}: { + item: ChecklistItem; + onToggle: () => void; + onCommitText: (text: string) => void; + onRemove: () => void; + onFocusChange: (focused: boolean) => void; +}) { + return ( + + + {item.done ? : null} + + 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', + )} + /> + + + + + ); +} + +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 ( + + + 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" + /> + + ); +} + +function AssigneeChip({ + label, + selected, + onPress, + avatarHumanId, + humans, +}: { + label: string; + selected: boolean; + onPress: () => void; + avatarHumanId?: string; + humans?: import('@/api/types').Human[]; +}) { + return ( + + {avatarHumanId ? ( + + ) : null} + + {label} + + + ); +} diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx index 6e4e026..139129b 100644 --- a/js/mobile/src/features/stream-view/TextParticleView.tsx +++ b/js/mobile/src/features/stream-view/TextParticleView.tsx @@ -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 ? ( @@ -290,7 +135,7 @@ export function TextParticleView({ showsVerticalScrollIndicator indicatorStyle="white" > - {markdownNodes} + {editedLabel} diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx index 9ca70f8..268ccf9 100644 --- a/js/mobile/src/features/streams/NewStreamScreen.tsx +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -189,6 +189,7 @@ export function NewStreamScreen({ networkId={networkId} targetPath={placeholderPath} silentPresence + allowTask={false} submitMedia={submitMedia} submitText={submitText} /> diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index ce5d5cf..73bae88 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native'; import { Headphones } from 'lucide-react-native'; import type { Particle, StreamProperties } from '@/api/types'; import { isParticleDeleted } from '@/api/types'; +import { Avatar } from '@/components/Avatar'; import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { useLiveLatestChild } from '@/hooks/use-particle'; import { useNetwork } from '@/hooks/use-networks'; import { particlePath } from '@/lib/particle-path'; -import { cn, getInitials } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth-store'; interface StreamCardProps { @@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({ particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + // The human represented by the card: the other party in a DM, otherwise the + // author of the latest message. Group streams with no messages resolve to + // null and fall back to the stream-name initials below. + const avatarHumanId = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, ); - if (otherEntry) { - const otherId = otherEntry.replace('human:', ''); - const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); - } + if (otherEntry) return otherEntry.replace('human:', ''); } + return latestChild?.created_by_human_id ?? null; + }, [isDM, particle.visible_to, userId, latestChild]); - if (latestChild) { - const creator = network?.humans?.find( - (h) => h.id === latestChild.created_by_human_id, - ); - if (creator) return getInitials(creator.email); - } - - return particle.properties.name.slice(0, 2).toUpperCase(); - }, [ - isDM, - particle.visible_to, - particle.properties.name, - userId, - latestChild, - network, - ]); + const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase(); const isUnseen = useMemo(() => { if (!latestChild) return false; @@ -87,7 +74,7 @@ export const StreamCard = memo(function StreamCard({ return latestChild.properties.content; case 'file': return latestChild.properties.filename; - case 'quest': + case 'task': return latestChild.properties.title; case 'paper': return latestChild.properties.title; @@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({ android_ripple={{ color: 'rgba(0,0,0,0.05)' }} className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent" > - - - {initials} - - + navigation.goBack()} + onOpenSettings={() => + navigation.navigate('NetworkSettings', { networkId }) + } /> {error ? ( @@ -67,7 +71,15 @@ export function StreamListScreen({ ); } -function Header({ title, onBack }: { title: string; onBack: () => void }) { +function Header({ + title, + onBack, + onOpenSettings, +}: { + title: string; + onBack: () => void; + onOpenSettings: () => void; +}) { return ( void }) { > {title} - + + + ); } diff --git a/js/mobile/src/hooks/use-avatar-url.ts b/js/mobile/src/hooks/use-avatar-url.ts new file mode 100644 index 0000000..3360359 --- /dev/null +++ b/js/mobile/src/hooks/use-avatar-url.ts @@ -0,0 +1,20 @@ +import { skipToken, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** + * Resolve an avatar object id to a signed download URL. React Query handles + * caching and de-duping, so many avatars sharing an id make a single request. + * Mirrors desktop's use-avatar-url. + */ +export function useAvatarUrl( + objectId: string | null | undefined, +): string | undefined { + const { data } = useQuery({ + queryKey: ['avatar-url', objectId], + queryFn: objectId + ? () => apiClient.getAvatarDownloadUrl(objectId) + : skipToken, + staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h + }); + return data; +} diff --git a/js/mobile/src/hooks/use-billing.ts b/js/mobile/src/hooks/use-billing.ts new file mode 100644 index 0000000..2163efa --- /dev/null +++ b/js/mobile/src/hooks/use-billing.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { BillingCadence } from '@/api/types'; + +/** Plan + quota summary. Member-accessible (sourced from `/usage`). */ +export function useNetworkUsage(networkId: string) { + return useQuery({ + queryKey: ['network-usage', networkId], + queryFn: () => apiClient.getNetworkUsage(networkId), + }); +} + +/** Full billing status. Admin-gated (`/billing`). */ +export function useNetworkBilling(networkId: string, enabled: boolean) { + return useQuery({ + queryKey: ['network-billing', networkId], + queryFn: () => apiClient.getNetworkBilling(networkId), + enabled, + }); +} + +export function useCreateCheckoutSession(networkId: string) { + return useMutation({ + mutationFn: (cadence: BillingCadence) => + apiClient.createCheckoutSession(networkId, cadence), + }); +} + +export function useCreatePortalSession(networkId: string) { + return useMutation({ + mutationFn: () => apiClient.createPortalSession(networkId), + }); +} diff --git a/js/mobile/src/hooks/use-invitations.ts b/js/mobile/src/hooks/use-invitations.ts new file mode 100644 index 0000000..f54a129 --- /dev/null +++ b/js/mobile/src/hooks/use-invitations.ts @@ -0,0 +1,39 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { CreateNetworkRequest } from '@/api/types'; + +/** Invitations addressed to the signed-in user's email. */ +export function useMyInvitations() { + return useQuery({ + queryKey: ['invitations'], + queryFn: () => apiClient.listMyInvitations(), + meta: { toastOnError: true }, + }); +} + +/** + * Accept a pending invitation, then refresh both the networks list (the user + * is now a member) and the invitations list (the invite is consumed). + */ +export function useAcceptInvitation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (networkId: string) => + apiClient.acceptInvitation({ network_id: networkId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + void queryClient.invalidateQueries({ queryKey: ['invitations'] }); + }, + }); +} + +/** Create a network; the creator becomes its admin and first member. */ +export function useCreateNetwork() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateNetworkRequest) => apiClient.createNetwork(data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + }, + }); +} diff --git a/js/mobile/src/hooks/use-member-management.ts b/js/mobile/src/hooks/use-member-management.ts new file mode 100644 index 0000000..fe426db --- /dev/null +++ b/js/mobile/src/hooks/use-member-management.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** Pending invitations sent for a network (member-visible). */ +export function useNetworkInvitations(networkId: string) { + return useQuery({ + queryKey: ['network-invitations', networkId], + queryFn: () => apiClient.listNetworkInvitations(networkId), + }); +} + +/** + * Invite people by email. Existing users join directly; others get a pending + * invitation. Refreshes both the network (new members) and its invitation list. + */ +export function useAddMembers(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (emails: string[]) => + apiClient.addMembers(networkId, { email_addresses: emails }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + void queryClient.invalidateQueries({ + queryKey: ['network-invitations', networkId], + }); + }, + }); +} + +/** Remove a member from the network (admin only). */ +export function useRemoveMember(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['networks'] }); + }, + }); +} + +/** Revoke a pending invitation by email. */ +export function useRevokeInvitation(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (email: string) => + apiClient.revokeInvitation(networkId, { email }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['network-invitations', networkId], + }); + }, + }); +} diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts index a678b4c..b40754d 100644 --- a/js/mobile/src/lib/firestore-particles.ts +++ b/js/mobile/src/lib/firestore-particles.ts @@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter = { case 'media': case 'file': case 'text': - case 'quest': + case 'task': case 'paper': { // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // particles carry `properties.edited_at`, so coerce it if present. diff --git a/js/mobile/src/lib/upload.ts b/js/mobile/src/lib/upload.ts index f4ea35d..06163df 100644 --- a/js/mobile/src/lib/upload.ts +++ b/js/mobile/src/lib/upload.ts @@ -103,6 +103,32 @@ export async function createTextParticle({ return createParticle(collectionPath, 'text', { content }, createdByHumanId); } +interface CreateTaskParticleParams { + targetPath: ParticlePath; + title: string; + notes?: string; + createdByHumanId: string; +} + +/** + * Create a `task` particle. Mirrors createTextParticle — the checklist and + * assignee are left empty and edited inline in the task card afterwards. + */ +export async function createTaskParticle({ + targetPath, + title, + notes, + createdByHumanId, +}: CreateTaskParticleParams): Promise { + const collectionPath = toFirestoreChildrenPath(targetPath); + return createParticle( + collectionPath, + 'task', + { title, done: false, ...(notes ? { notes } : {}) }, + createdByHumanId, + ); +} + function extensionFromMime(mime: string): string { if (mime === 'video/mp4') return '.mp4'; if (mime === 'video/quicktime') return '.mov'; diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 4cbb217..ac04756 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen'; import { NewStreamScreen } from '@/features/streams/NewStreamScreen'; import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen'; import { HuddleScreen } from '@/features/huddle/HuddleScreen'; +import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen'; import { SettingsScreen } from '@/features/settings/SettingsScreen'; import { AccountScreen } from '@/features/settings/AccountScreen'; import type { RootStackParamList } from './types'; @@ -54,6 +55,11 @@ export function RootNavigator() { component={NewStreamScreen} options={{ animation: 'slide_from_bottom' }} /> + diff --git a/js/mobile/src/navigation/types.ts b/js/mobile/src/navigation/types.ts index f79ec26..876ab0d 100644 --- a/js/mobile/src/navigation/types.ts +++ b/js/mobile/src/navigation/types.ts @@ -15,6 +15,7 @@ export type RootStackParamList = { serverUrl: string; }; NewStream: { networkId: string }; + NetworkSettings: { networkId: string }; Settings: undefined; Account: undefined; }; diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index 852a61a..6f1c2f4 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -63,6 +63,8 @@ interface AuthState { requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; + /** Re-fetch the current user from Orion (e.g. after an avatar change). */ + refreshUser: () => Promise; /** * Wipes the session in response to a server-detected auth failure (e.g. a * 401 surfaced through react-query). Does not call `/auth/sign-out`; the @@ -166,6 +168,15 @@ export const useAuthStore = create((set, get) => ({ } }, + refreshUser: async () => { + try { + const user = await apiClient.me(); + set({ user }); + } catch (err) { + logError(err, { scope: 'auth.refreshUser' }); + } + }, + invalidateSession: async () => { stopPushTokenSync(); apiClient.setToken(null); diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index bc433ea..9ebf56e 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7: resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== +expo-image-loader@~6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" + integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== + +expo-image-picker@~17.0.8: + version "17.0.11" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc" + integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ== + dependencies: + expo-image-loader "~6.0.0" + expo-keep-awake@~15.0.8: version "15.0.8" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"