diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 0a122c1..fd766cc 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 680a423..796a8ea 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); @@ -199,7 +258,7 @@ export function TaskParticleView({ onFocus={notesField.onFocus} onBlur={notesField.onBlur} placeholder="Add notes…" - className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0" + className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 dark:bg-transparent" />
@@ -248,6 +307,16 @@ export function TaskParticleView({
+ {immersive && editing && ( + + to finish + + )}
); } 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"