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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: paper and file particle views (parity phase 4)

- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #298.
This commit is contained in:
Arjun Patel
2026-06-21 10:38:16 -07:00
committed by GitHub
parent 428f9ea1c9
commit 3d80ac2993
38 changed files with 2458 additions and 352 deletions
+7
View File
@@ -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',
{
+1
View File
@@ -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",
+40
View File
@@ -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<void> {
const headers: Record<string, string> = { '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<void> {
await this.requestVoid('DELETE', '/humans/me/avatar');
}
async getAvatarDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
const data = await response.json();
return data.url;
}
// --- Push notification tokens ---
async registerPushToken(data: {
+16 -7
View File
@@ -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<typeof HumanSchema>;
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
export const ChecklistItemSchema = z.object({
text: z.string(),
done: z.boolean(),
});
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
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<typeof QuestPropertiesSchema>;
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
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({
+27 -10
View File
@@ -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<Size, { box: string; text: string; ring: number }> = {
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 (
<View
className={cn(
'bg-black/15 items-center justify-center rounded-full',
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
dims.box,
className,
)}
@@ -52,9 +65,13 @@ export function Avatar({
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
}}
>
<Text className={cn('text-white font-semibold', dims.text)}>
{initials}
</Text>
{avatarUrl ? (
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
) : (
<Text className={cn('text-white font-semibold', dims.text)}>
{initials}
</Text>
)}
</View>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { Fragment, type ReactNode } from 'react';
import { Platform, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
// Shared markdown rendering for text and paper particles. Mirrors the desktop
// Crepe palette (markdown-editor.css `--crepe-*`) so a message reads the same
// on both surfaces: white-on-transparent text, a blue accent, pink inline
// code, and a near-opaque dark surface behind code blocks and tables.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
/**
* Renders GFM markdown using the shared Flowy palette. `useMarkdown` returns an
* array of block nodes; we splat them into a Fragment so they nest cleanly
* inside a parent ScrollView (vs. the library's own FlatList-based component).
*/
export function MarkdownBody({ content }: { content: string }) {
const nodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
return <Fragment>{nodes}</Fragment>;
}
+100 -36
View File
@@ -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<void>;
submitText?: (content: string) => Promise<void>;
/**
@@ -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<RecordingMode>('video');
const [ui, setUi] = useState<ComposeUiState>({ 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<boolean> => {
@@ -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({
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
<View
pointerEvents="box-none"
className="flex-row items-center justify-between px-8 pb-10"
className="flex-row items-center px-8 pb-10"
>
<Pressable
onPress={() =>
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' ? (
<VideoIcon color="white" size={20} strokeWidth={1.6} />
) : (
<Mic color="white" size={20} strokeWidth={1.6} />
)}
</Pressable>
{/* Left and right clusters flex equally so the record button stays
centered regardless of how many side controls are present. */}
<View className="flex-1 flex-row items-center">
<Pressable
onPress={() =>
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' ? (
<VideoIcon color="white" size={20} strokeWidth={1.6} />
) : (
<Mic color="white" size={20} strokeWidth={1.6} />
)}
</Pressable>
</View>
<View className="items-center">
<Pressable
@@ -230,17 +266,33 @@ export function ComposeDock({
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
</View>
<Pressable
onPress={() => 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',
)}
>
<TypeIcon color="white" size={20} strokeWidth={1.6} />
</Pressable>
<View className="flex-1 flex-row items-center justify-end gap-3">
{allowTask ? (
<Pressable
onPress={() => 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',
)}
>
<ListTodo color="white" size={20} strokeWidth={1.6} />
</Pressable>
) : null}
<Pressable
onPress={() => 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',
)}
>
<TypeIcon color="white" size={20} strokeWidth={1.6} />
</Pressable>
</View>
</View>
</View>
) : null}
@@ -277,6 +329,12 @@ export function ComposeDock({
onClose={() => setTextOpen(false)}
onSubmit={submitText}
/>
<TaskComposeSheet
open={taskOpen}
onClose={() => 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;
@@ -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<void>;
}
/**
* 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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="70%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">New task</Text>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
editable={!submitting}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<TextInput
value={notes}
onChangeText={setNotes}
placeholder="Notes (optional)"
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
editable={!submitting}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-3 min-h-20"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSend}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSend ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSend ? 'text-black' : 'text-white/40',
)}
>
{submitting ? 'Creating…' : 'Create task'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
@@ -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({
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
) : (
<View className="flex-1 items-center justify-center">
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
<Text className="text-white text-xl font-semibold">{initials}</Text>
</View>
<Avatar humanId={identity} humans={humans} size="lg" />
</View>
)}
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
@@ -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<string[]>([]);
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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="60%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">Add members</Text>
<Text className="text-white/50 text-sm mt-1">
Existing users join right away; others get an email invite.
</Text>
{emails.length > 0 ? (
<View className="flex-row flex-wrap gap-2 mt-4">
{emails.map((email) => (
<Pressable
key={email}
onPress={() => removeEmail(email)}
className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1"
>
<Text className="text-white text-sm">{email}</Text>
<Text className="text-white/50 text-base ml-1">×</Text>
</Pressable>
))}
</View>
) : null}
<TextInput
value={draft}
onChangeText={(text) => {
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"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSubmit}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSubmit ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSubmit ? 'text-black' : 'text-white/40',
)}
>
{addMembers.isPending ? 'Sending…' : 'Send invites'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
@@ -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 (
<View className="flex-row items-center gap-3 px-1 py-2">
<Text className="text-muted-foreground text-sm">{label}</Text>
<View className="flex-1" />
<View>{value}</View>
</View>
);
}
/**
* 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 (
<View className="px-3">
<Text className="text-foreground text-base font-semibold pt-2 pb-2">
Plan &amp; billing
</Text>
<InfoRow
label="Plan"
value={
<Text className="text-foreground text-sm font-medium">
{isPro ? 'Llink Pro' : 'Llink Free'}
</Text>
}
/>
{!isPro && usage?.limit != null ? (
<InfoRow
label="Todays messages"
value={
<Text className="text-foreground text-sm tabular-nums">
{usage.used} / {usage.limit}
</Text>
}
/>
) : null}
{isAdmin ? <AdminBillingControls networkId={networkId} /> : null}
</View>
);
}
function AdminBillingControls({ networkId }: { networkId: string }) {
const {
data: billing,
isLoading,
error,
} = useNetworkBilling(networkId, true);
if (isLoading || !billing) {
return (
<Text className="text-muted-foreground text-sm px-1 py-2">
{error ? `Couldnt load billing: ${toUserMessage(error)}` : 'Loading…'}
</Text>
);
}
return billing.plan === 'pro' ? (
<ProBilling networkId={networkId} billing={billing} />
) : (
<FreeBilling networkId={networkId} billing={billing} />
);
}
function FreeBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>('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 (
<View className="mt-2 gap-2">
<CadenceOption
label="Annual"
note="Billed annually"
perSeatCents={annualPerSeatMonthlyCents}
badge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === 'annual'}
onPress={() => setCadence('annual')}
/>
<CadenceOption
label="Monthly"
note="Billed monthly · cancel anytime"
perSeatCents={billing.price_monthly_cents}
selected={cadence === 'monthly'}
onPress={() => setCadence('monthly')}
/>
<Pressable
onPress={handleUpgrade}
disabled={createCheckout.isPending}
className="mt-2 rounded-xl bg-primary py-3 items-center"
>
<Text className="text-primary-foreground text-base font-semibold">
{createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'}
</Text>
</Pressable>
</View>
);
}
function CadenceOption({
label,
note,
perSeatCents,
badge,
selected,
onPress,
}: {
label: string;
note: string;
perSeatCents: number;
badge?: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-3 rounded-xl border px-4 py-3',
selected ? 'border-primary bg-accent' : 'border-border',
)}
>
<View
className={cn(
'h-5 w-5 rounded-full border-2',
selected ? 'border-primary bg-primary' : 'border-muted-foreground',
)}
/>
<View className="flex-1">
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-sm font-medium">{label}</Text>
{badge ? (
<View className="bg-primary rounded-full px-2 py-0.5">
<Text className="text-primary-foreground text-[10px] font-semibold">
{badge}
</Text>
</View>
) : null}
</View>
<Text className="text-muted-foreground text-xs">{note}</Text>
</View>
<View className="items-end">
<Text className="text-foreground text-sm font-medium">
{formatCents(perSeatCents)}
</Text>
<Text className="text-muted-foreground text-xs">per seat / mo</Text>
</View>
</Pressable>
);
}
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 (
<View className="mt-2">
{billing.cancel_at_period_end && renewal ? (
<Text className="text-destructive text-sm py-2">
Your subscription downgrades to Free on {renewal}.
</Text>
) : null}
{billing.plan_status === 'past_due' ? (
<Text className="text-destructive text-sm py-2">
Your last payment failed. Update your payment method to keep Pro
active.
</Text>
) : null}
<InfoRow
label="Billing"
value={
<Text className="text-foreground text-sm">
{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
</Text>
}
/>
<InfoRow
label="Seats"
value={<Text className="text-foreground text-sm">{billing.seats}</Text>}
/>
{renewal ? (
<InfoRow
label={billing.cancel_at_period_end ? 'Ends' : 'Renews'}
value={<Text className="text-foreground text-sm">{renewal}</Text>}
/>
) : null}
<Pressable
onPress={handleManage}
disabled={createPortal.isPending}
className="mt-2 flex-row items-center justify-center gap-2 rounded-xl border border-border py-3"
>
<ExternalLink color="#fafafa" size={15} />
<Text className="text-foreground text-base font-medium">
{createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'}
</Text>
</Pressable>
</View>
);
}
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
{network?.name ?? 'Network'}
</Text>
<View className="w-8" />
</View>
<ScrollView className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<View>
<Text className="text-foreground text-base font-semibold">
Members
</Text>
<Text className="text-muted-foreground text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
</View>
{isAdmin ? (
<Pressable
onPress={() => setAddOpen(true)}
className="flex-row items-center bg-primary rounded-full px-3 py-2"
>
<UserPlus size={14} color="#000000" />
<Text className="text-primary-foreground text-sm font-semibold ml-1">
Add
</Text>
</Pressable>
) : null}
</View>
<View className="px-2">
{members.map((human) => {
const isRowAdmin = human.id === network?.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUserId;
return (
<View
key={human.id}
className="flex-row items-center gap-3 px-2 py-3"
>
<Avatar humanId={human.id} humans={members} size="md" />
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">
{human.email_prefix}
</Text>
<Text className="text-muted-foreground text-xs">
{human.email}
</Text>
</View>
{isRowAdmin ? (
<View className="flex-row items-center bg-muted rounded-full px-2 py-1">
<Shield size={12} color="#a6a6a6" />
<Text className="text-muted-foreground text-xs ml-1">
Admin
</Text>
</View>
) : null}
{canRemove ? (
<Pressable
onPress={() => handleRemove(human)}
hitSlop={8}
className="p-1"
accessibilityLabel={`Remove ${human.email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
) : null}
</View>
);
})}
</View>
{isAdmin ? (
<View className="mt-4">
<Text className="text-foreground text-base font-semibold px-4 pt-2 pb-2">
Pending invitations
</Text>
{invitationsError ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
Couldnt load pending invitations.
</Text>
) : pending.length === 0 ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
No pending invitations.
</Text>
) : (
<View className="px-2">
{pending.map((inv) => (
<PendingInvitationRow
key={inv.email}
email={inv.email}
networkId={networkId}
/>
))}
</View>
)}
</View>
) : null}
<View className="mt-4 border-t border-border pt-2">
<BillingSection networkId={networkId} />
</View>
</ScrollView>
{isAdmin ? (
<AddMembersSheet
open={addOpen}
onClose={() => setAddOpen(false)}
networkId={networkId}
/>
) : null}
</SafeAreaView>
);
}
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 (
<View className="flex-row items-center gap-3 px-2 py-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-muted">
<Mail size={16} color="#a6a6a6" />
</View>
<View className="flex-1">
<Text className="text-foreground text-sm" numberOfLines={1}>
{email}
</Text>
<Text className="text-muted-foreground text-xs">Pending</Text>
</View>
<Pressable
onPress={handleRevoke}
disabled={revokeInvitation.isPending}
hitSlop={8}
className="p-1"
accessibilityLabel={`Revoke invitation to ${email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
</View>
);
}
@@ -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 (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="50%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">New network</Text>
<Text className="text-white/50 text-sm mt-1">
Youll be the admin and can invite people next.
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Network name"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreate}
editable={!createNetwork.isPending}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<Pressable
onPress={handleCreate}
disabled={!canCreate}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canCreate ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canCreate ? 'text-black' : 'text-white/40',
)}
>
{createNetwork.isPending ? 'Creating…' : 'Create network'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
+19 -8
View File
@@ -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 (
<Modal
visible={open}
@@ -85,11 +89,11 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
>
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
{initials}
</Text>
</View>
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="md"
/>
<View className="flex-1">
<Text
className="text-sidebar-foreground text-base font-medium"
@@ -114,6 +118,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
onNavigateAccount();
}}
/>
<DrawerRow
label="Settings"
onPress={() => {
onClose();
onNavigateSettings();
}}
/>
</View>
<View className="border-sidebar-border border-t px-2 py-2">
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -44,14 +52,21 @@ export function NetworkListScreen({
<Pressable
onPress={() => setDrawerOpen(true)}
accessibilityLabel="Open menu"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
>
<Text className="text-muted-foreground text-xs font-semibold">
{initials}
</Text>
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="sm"
/>
</Pressable>
<FlowyLogo />
<View className="w-9" />
<Pressable
onPress={() => setCreateOpen(true)}
accessibilityLabel="Create network"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
>
<Plus size={18} color="#fafafa" />
</Pressable>
</View>
{isLoading ? (
@@ -67,16 +82,21 @@ export function NetworkListScreen({
<Text className="text-foreground font-medium">Retry</Text>
</Pressable>
</View>
) : !data || data.length === 0 ? (
<EmptyState />
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
<EmptyState onCreate={() => setCreateOpen(true)} />
) : (
<FlatList
data={data}
data={data ?? []}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ItemSeparatorComponent={ListSeparator}
ListHeaderComponent={
pendingInvitations.length > 0 ? (
<InvitationsSection invitations={pendingInvitations} />
) : null
}
renderItem={({ item }) => (
<NetworkCard
network={item}
@@ -94,10 +114,65 @@ export function NetworkListScreen({
onNavigateAccount={() => navigation.navigate('Account')}
onNavigateSettings={() => navigation.navigate('Settings')}
/>
<CreateNetworkSheet
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(networkId) =>
navigation.navigate('StreamList', { networkId })
}
/>
</SafeAreaView>
);
}
function InvitationsSection({ invitations }: { invitations: Invitation[] }) {
return (
<View className="border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
Invitations
</Text>
{invitations.map((invitation) => (
<InvitationCard key={invitation.network_id} invitation={invitation} />
))}
</View>
);
}
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 (
<View className="flex-row items-center justify-between px-4 py-4">
<View className="flex-1 pr-3">
<Text className="text-foreground text-base font-semibold">
{invitation.network_name}
</Text>
<Text className="text-muted-foreground text-sm">
Youve been invited to join
</Text>
</View>
<Pressable
onPress={handleAccept}
disabled={acceptInvitation.isPending}
className="bg-primary rounded-full px-4 py-2"
>
<Text className="text-primary-foreground text-sm font-semibold">
{acceptInvitation.isPending ? 'Joining…' : 'Accept'}
</Text>
</Pressable>
</View>
);
}
function NetworkCard({
network,
onPress,
@@ -124,15 +199,23 @@ function NetworkCard({
);
}
function EmptyState() {
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center">
You arent in any networks yet.
</Text>
<Text className="text-muted-foreground mt-2 text-center">
Ask a friend for an invite, or create one on desktop.
Create one to get started, or ask a friend for an invite.
</Text>
<Pressable
onPress={onCreate}
className="bg-primary rounded-full px-5 py-3 mt-6"
>
<Text className="text-primary-foreground font-semibold">
Create a network
</Text>
</Pressable>
</View>
);
}
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
<View className="w-8" />
</View>
<View className="px-6 py-6 gap-4">
<View className="items-center px-6 py-8">
<Pressable
onPress={pickAndUpload}
disabled={busy}
accessibilityLabel="Change profile picture"
className="relative"
>
{busy ? (
<View className="h-24 w-24 items-center justify-center rounded-full bg-muted">
<ActivityIndicator />
</View>
) : (
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="xl"
className="bg-muted"
/>
)}
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
<Camera size={13} color="#000000" />
</View>
</Pressable>
{user?.avatar_object_id ? (
<Pressable onPress={removeAvatar} disabled={busy} className="mt-3">
<Text className="text-destructive text-sm">Remove photo</Text>
</Pressable>
) : null}
</View>
<View className="px-6 gap-4">
<Field label="Email" value={user?.email ?? '—'} />
</View>
</SafeAreaView>
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
@@ -17,11 +54,55 @@ export function SettingsScreen({
<View className="w-8" />
</View>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-muted-foreground text-center">
Theme, notifications, and account preferences land here later.
</Text>
<View className="px-4 py-4">
<SettingsGroup title="Notifications">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">
Email notifications
</Text>
<Switch
value={emailNotifications}
onValueChange={handleToggleEmailNotifications}
/>
</View>
</SettingsGroup>
<SettingsGroup title="About">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">Version</Text>
<Text className="text-muted-foreground text-base">{version}</Text>
</View>
</SettingsGroup>
<View className="mt-4">
<Pressable
onPress={() => void signOut()}
disabled={isSigningOut}
className="px-4 py-3 active:bg-accent rounded-xl"
>
<Text className="text-destructive text-base font-medium">
{isSigningOut ? 'Signing out…' : 'Sign out'}
</Text>
</Pressable>
</View>
</View>
</SafeAreaView>
);
}
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-2">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
{title}
</Text>
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
</View>
);
}
@@ -1,22 +1,10 @@
import { useEffect } from 'react';
import { Text, View } from 'react-native';
import {
FileIcon,
HelpCircle,
ScrollText,
BookOpen,
type LucideIcon,
} from 'lucide-react-native';
import { HelpCircle } from 'lucide-react-native';
import type { Particle } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans';
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
quest: { icon: ScrollText, label: 'Quest' },
paper: { icon: BookOpen, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
const PLACEHOLDER_DURATION_MS = 5000;
interface FallbackParticleViewProps {
@@ -26,6 +14,10 @@ interface FallbackParticleViewProps {
onEnded: () => void;
}
// Catch-all for particle types this client version doesn't render with a
// dedicated view (e.g. a folder slipping into a stream, or a future type a
// newer client wrote). Known content types — media, text, task, paper, file —
// each have their own view in StreamView's switch.
export function FallbackParticleView({
particle,
networkId,
@@ -37,25 +29,8 @@ export function FallbackParticleView({
particle.created_by_human_id,
network?.humans,
);
const meta = TYPE_META[particle.type] ?? {
icon: HelpCircle,
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'folder':
return particle.properties.name;
default:
return null;
}
})();
const Icon = HelpCircle;
const title = particle.type === 'folder' ? particle.properties.name : null;
useEffect(() => {
if (paused) return;
@@ -70,7 +45,7 @@ export function FallbackParticleView({
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
<View className="flex-1">
<Text className="text-white text-base font-semibold">
{meta.label}
{particle.type}
</Text>
{title ? (
<Text className="text-white/70 text-sm" numberOfLines={2}>
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, Text, View } from 'react-native';
import { Download, FileIcon } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Particle } from '@/api/types';
import { apiClient } from '@/api/client';
import { toUserMessage } from '@/lib/errors';
type FileParticle = Extract<Particle, { type: 'file' }>;
interface FileParticleViewProps {
particle: FileParticle;
paused: boolean;
onEnded: () => void;
}
// Files don't auto-play; give the reader a beat to act before advancing.
const DWELL_DURATION_MS = 8000;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
/**
* File particle: name, size, and a download action. Tapping resolves a signed
* URL and opens it (the OS handles the download / preview). Mirrors desktop's
* file attachment, minus in-app preview.
*/
export function FileParticleView({
particle,
paused,
onEnded,
}: FileParticleViewProps) {
const { filename, size_bytes, object_id } = particle.properties;
const [downloading, setDownloading] = useState(false);
const elapsedRef = useRef(0);
// Pause the dwell while a download is being resolved so the stream doesn't
// advance out from under the user mid-tap.
useEffect(() => {
if (paused || downloading) return;
const start = Date.now();
const interval = setInterval(() => {
elapsedRef.current += Date.now() - start;
if (elapsedRef.current >= DWELL_DURATION_MS) {
clearInterval(interval);
onEnded();
}
}, 250);
return () => clearInterval(interval);
}, [paused, downloading, onEnded, particle.id]);
const handleDownload = async () => {
setDownloading(true);
try {
const url = await apiClient.getParticleDownloadUrl(object_id);
const canOpen = await Linking.canOpenURL(url);
if (!canOpen) throw new Error('Could not open this file.');
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setDownloading(false);
}
};
return (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
<View className="flex-row items-center gap-3">
<FileIcon color="rgba(255,255,255,0.7)" size={26} strokeWidth={1.5} />
<View className="flex-1">
<Text
className="text-white text-base font-semibold"
numberOfLines={2}
>
{filename}
</Text>
<Text className="text-white/50 text-xs mt-0.5">
{formatBytes(size_bytes)}
</Text>
</View>
</View>
<Pressable
onPress={handleDownload}
disabled={downloading}
className="mt-5 flex-row items-center justify-center gap-2 rounded-xl bg-white py-3"
>
<Download color="#000000" size={16} strokeWidth={2} />
<Text className="text-black text-base font-semibold">
{downloading ? 'Opening…' : 'Download'}
</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { ScrollView, Text, View } from 'react-native';
import type { Particle } from '@/api/types';
import { MarkdownBody } from '@/components/MarkdownBody';
import { useStreamSafeArea } from './stream-safe-area';
type PaperParticle = Extract<Particle, { type: 'paper' }>;
interface PaperParticleViewProps {
particle: PaperParticle;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
// Papers are longer-form documents, so they read at the text cadence but with a
// higher cap — the reader can still scroll at their own pace while the timer
// ticks toward auto-advance.
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 4;
const MAX_DURATION_S = 30;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
}
/**
* Read-only paper (document) view. Mirrors desktop's paper rendering: a title
* heading above markdown body, scrollable, with a length-based dwell timer.
*/
export function PaperParticleView({
particle,
paused,
onEnded,
onProgress,
}: PaperParticleViewProps) {
const { title, content } = particle.properties;
const safe = useStreamSafeArea();
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
onProgress(0);
}, [particle.id, onProgress]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<ScrollView
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5"
showsVerticalScrollIndicator
indicatorStyle="white"
>
<Text className="text-white text-2xl font-semibold mb-3">{title}</Text>
<MarkdownBody content={content} />
</ScrollView>
</View>
);
}
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
import { Plus } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { resolveHumanDisplay } from '@/lib/humans';
import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils';
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -77,7 +77,6 @@ export function ReactionStack({
{activeTextKeys.map((text) => {
const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
return (
<Pressable
key={text}
@@ -93,11 +92,7 @@ export function ReactionStack({
: null,
]}
>
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
<Text className="text-white/90 text-xs" numberOfLines={1}>
{text}
</Text>
@@ -0,0 +1,50 @@
import { Text, View } from 'react-native';
import { Clock, Pause } from 'lucide-react-native';
interface StreamStatusPillsProps {
paused: boolean;
/** Null when no auto-exit is pending; ms remaining otherwise. */
exitRemainingMs: number | null;
}
/**
* Compact playback-state pills shown in the right chrome margin so they never
* overlay the particle canvas. "Paused" collapses to an icon-only badge; the
* exit countdown pairs a clock icon with tabular-number seconds so the digit
* tick doesn't reflow neighbors.
*/
export function StreamStatusPills({
paused,
exitRemainingMs,
}: StreamStatusPillsProps) {
if (!paused && exitRemainingMs === null) return null;
return (
<View className="flex-row items-center gap-1.5">
{paused ? (
<View
accessibilityLabel="Paused"
className="bg-white/15 h-6 w-6 items-center justify-center rounded-full"
>
<Pause color="white" size={11} fill="white" strokeWidth={0} />
</View>
) : null}
{exitRemainingMs !== null ? (
<View
accessibilityLabel={`Closing in ${Math.ceil(
exitRemainingMs / 1000,
)} seconds`}
className="bg-white/15 h-6 flex-row items-center gap-1 rounded-full px-2"
>
<Clock color="white" size={11} strokeWidth={2} />
<Text
className="text-white text-[11px] font-semibold"
style={{ fontVariant: ['tabular-nums'] }}
>
{Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
);
}
@@ -53,11 +53,15 @@ import {
useStreamComposing,
} from './stream-presence-context';
import { TextParticleView } from './TextParticleView';
import { TaskParticleView } from './TaskParticleView';
import { PaperParticleView } from './PaperParticleView';
import { FileParticleView } from './FileParticleView';
import { MediaParticleView } from './MediaParticleView';
import { DeletedParticleView } from './DeletedParticleView';
import { FallbackParticleView } from './FallbackParticleView';
import { useExitCountdown } from './use-exit-countdown';
import { StreamTopActions } from './StreamTopActions';
import { StreamStatusPills } from './StreamStatusPills';
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
import { StreamMembersSheet } from './StreamMembersSheet';
import { RenameStreamSheet } from './RenameStreamSheet';
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
contentFit={videoFit}
/>
);
case 'task':
return (
<TaskParticleView
key={particle.id}
particle={particle}
networkId={networkId}
streamId={streamParticle.id}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case 'paper':
return (
<PaperParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case 'file':
return (
<FileParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
/>
);
default:
return (
<FallbackParticleView
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
/>
</View>
</View>
{/* Top status pills: paused + exit countdown. Anchored just below
the metadata row (avatar + name ≈ 40px tall, starts at
insets.top + 32) so they share the top chrome real estate
instead of competing with captions at the bottom. */}
<View
pointerEvents="none"
className="absolute inset-x-0 items-center"
style={{ top: insets.top + 88 }}
>
{paused ? (
<View className="bg-white/15 rounded-full px-3 py-1">
<Text className="text-white/90 text-xs font-medium">
Paused
</Text>
</View>
) : null}
{exitRemainingMs !== null ? (
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
<Text className="text-white/90 text-xs font-medium">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
</View>
</GestureDetector>
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
</View>
</View>
{/* Playback status pills — pinned to the right chrome margin just
below the top actions row so they live in the same band as the
chrome buttons instead of overlaying the particle canvas. */}
<View
pointerEvents="none"
className="absolute right-3"
style={{ top: insets.top + 72 }}
>
<StreamStatusPills
paused={paused}
exitRemainingMs={exitRemainingMs}
/>
</View>
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
centered on the canvas; outside the GestureDetector so each pill
tap toggles cleanly without competing with the stream advance/back
@@ -0,0 +1,435 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Keyboard,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { deleteField } from 'firebase/firestore';
import { Check, Plus, X } from 'lucide-react-native';
import type { ChecklistItem, Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import {
updateParticle,
updateParticleProperties,
} from '@/lib/firestore-particles';
import { useNetwork } from '@/hooks/use-networks';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { resolveHumanDisplay } from '@/lib/humans';
import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils';
import { useStreamSafeArea } from './stream-safe-area';
type TaskParticle = Extract<Particle, { type: 'task' }>;
interface TaskParticleViewProps {
particle: TaskParticle;
networkId: string;
streamId: string;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
const TICK_MS = 100;
/**
* Editable task card. Mirrors desktop's task-particle-view — round done
* checkbox + title, notes, a checklist, and an assignee picker — persisting
* each edit straight to Firestore. A fixed 8s dwell auto-advances the stream;
* focusing any field suspends playback so typing isn't raced by the timer.
*/
export function TaskParticleView({
particle,
networkId,
streamId,
paused,
onEnded,
onProgress,
}: TaskParticleViewProps) {
const network = useNetwork(networkId);
const safe = useStreamSafeArea();
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
);
const {
title,
notes,
checklist = [],
assigned_to,
done,
} = particle.properties;
// Suspend the dwell timer whenever a field is focused so typing isn't
// interrupted by an auto-advance. Mirrors desktop's `editing` suspender.
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
// --- Fixed dwell (mirrors TextParticleView's interval cadence) ---
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
onProgress(0);
}, [particle.id, onProgress]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / DWELL_DURATION_S, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, onEnded, onProgress, particle.id]);
// Checklist writes replace the whole array; concurrent edits are
// last-write-wins (same tradeoff desktop documents). Keep a ref so a second
// edit composes on the latest local base before the next snapshot arrives.
const checklistRef = useRef(checklist);
useEffect(() => {
checklistRef.current = checklist;
}, [checklist]);
const writeChecklist = useCallback(
(items: ChecklistItem[]) => {
checklistRef.current = items;
return updateParticleProperties<'task'>(docPath, { checklist: items });
},
[docPath],
);
const handleToggleDone = useCallback(
() => updateParticleProperties<'task'>(docPath, { done: !done }),
[docPath, done],
);
const handleToggleItem = useCallback(
(index: number) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, done: !item.done } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleCommitItemText = useCallback(
(index: number, text: string) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, text } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleRemoveItem = useCallback(
(index: number) => {
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
},
[writeChecklist],
);
const handleAddItem = useCallback(
(text: string) => {
void writeChecklist([...checklistRef.current, { text, done: false }]);
},
[writeChecklist],
);
const handleAssign = useCallback(
(humanId: string | null) => {
if (!humanId) {
void updateParticle(docPath, 'properties.assigned_to', deleteField());
} else {
void updateParticleProperties<'task'>(docPath, {
assigned_to: humanId,
});
}
},
[docPath],
);
const doneCount = checklist.filter((item) => item.done).length;
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
<ScrollView
className="max-h-full rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5 gap-5"
showsVerticalScrollIndicator
indicatorStyle="white"
keyboardShouldPersistTaps="handled"
// Dragging the card dismisses the keyboard (interactive follow on
// iOS; on-drag on Android, which lacks the interactive variant).
keyboardDismissMode={
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
}
>
{/* Title + done */}
<View
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
>
<Pressable
onPress={handleToggleDone}
hitSlop={8}
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
className={cn(
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
)}
>
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={title}
key={`title-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== title) {
void updateParticleProperties<'task'>(docPath, {
title: value,
});
}
}}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className={cn(
'flex-1 text-white text-2xl font-semibold',
done && 'text-white/50 line-through',
)}
/>
</View>
{/* Notes */}
<TextInput
defaultValue={notes ?? ''}
key={`notes-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== (notes ?? '')) {
void updateParticleProperties<'task'>(docPath, {
notes: value,
});
}
}}
placeholder="Add notes…"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className="text-white/80 text-base"
/>
{/* Checklist */}
<View className="gap-2">
{checklist.length > 0 ? (
<Text className="text-white/40 text-xs">
{doneCount} / {checklist.length} done
</Text>
) : null}
{checklist.map((item, index) => (
<ChecklistItemRow
key={`${particle.id}-item-${index}`}
item={item}
onToggle={() => handleToggleItem(index)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
onFocusChange={setEditing}
/>
))}
<AddChecklistItemRow
onAdd={handleAddItem}
onFocusChange={setEditing}
/>
</View>
{/* Assignee */}
<View className="gap-2">
<Text className="text-white/40 text-xs">Assignee</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2"
>
<AssigneeChip
label="Unassigned"
selected={!assigned_to}
onPress={() => handleAssign(null)}
/>
{network?.humans?.map((human) => {
const display = resolveHumanDisplay(human.id, network?.humans);
return (
<AssigneeChip
key={human.id}
label={display.displayName}
selected={assigned_to === human.id}
onPress={() => handleAssign(human.id)}
avatarHumanId={human.id}
humans={network?.humans}
/>
);
})}
</ScrollView>
</View>
</ScrollView>
{/* While a field is focused the keyboard hides the stream's tap-zones,
so offer an explicit way out. Dismissing blurs the active field,
which flips `editing` off and resumes the dwell + tap navigation. */}
{editing ? (
<View className="absolute right-2 top-2">
<Pressable
onPress={() => Keyboard.dismiss()}
hitSlop={8}
accessibilityLabel="Done editing"
className="rounded-full bg-white/15 px-3 py-1.5"
>
<Text className="text-white text-sm font-medium">Done</Text>
</Pressable>
</View>
) : null}
</View>
</View>
);
}
function ChecklistItemRow({
item,
onToggle,
onCommitText,
onRemove,
onFocusChange,
}: {
item: ChecklistItem;
onToggle: () => void;
onCommitText: (text: string) => void;
onRemove: () => void;
onFocusChange: (focused: boolean) => void;
}) {
return (
<View className="flex-row items-center gap-2.5">
<Pressable
onPress={onToggle}
hitSlop={6}
accessibilityLabel={
item.done ? 'Mark subtask not done' : 'Mark subtask done'
}
className={cn(
'h-5 w-5 items-center justify-center rounded border',
item.done ? 'bg-emerald-500 border-emerald-500' : 'border-white/30',
)}
>
{item.done ? <Check color="white" size={12} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={item.text}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== item.text) onCommitText(value);
}}
placeholder="Subtask"
placeholderTextColor="rgba(255,255,255,0.3)"
className={cn(
'flex-1 text-white/90 text-sm',
item.done && 'text-white/40 line-through',
)}
/>
<Pressable
onPress={onRemove}
hitSlop={6}
accessibilityLabel="Remove subtask"
>
<X color="rgba(255,255,255,0.4)" size={14} />
</Pressable>
</View>
);
}
function AddChecklistItemRow({
onAdd,
onFocusChange,
}: {
onAdd: (text: string) => void;
onFocusChange: (focused: boolean) => void;
}) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (!text) return;
onAdd(text);
setDraft('');
};
return (
<View className="flex-row items-center gap-2.5">
<Plus color="rgba(255,255,255,0.3)" size={16} />
<TextInput
value={draft}
onChangeText={setDraft}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onSubmitEditing={submit}
blurOnSubmit={false}
placeholder="Add subtask…"
placeholderTextColor="rgba(255,255,255,0.3)"
className="flex-1 text-white/70 text-sm"
/>
</View>
);
}
function AssigneeChip({
label,
selected,
onPress,
avatarHumanId,
humans,
}: {
label: string;
selected: boolean;
onPress: () => void;
avatarHumanId?: string;
humans?: import('@/api/types').Human[];
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-2 rounded-full px-3 py-2',
selected ? 'bg-white' : 'bg-white/10',
)}
>
{avatarHumanId ? (
<Avatar humanId={avatarHumanId} humans={humans} size="xs" />
) : null}
<Text
className={cn(
'text-sm font-medium',
selected ? 'text-black' : 'text-white/80',
)}
>
{label}
</Text>
</Pressable>
);
}
@@ -1,8 +1,8 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
import { useEffect, useRef } from 'react';
import { ScrollView, Text, View } from 'react-native';
import type { Particle } from '@/api/types';
import { cn } from '@/lib/utils';
import { MarkdownBody } from '@/components/MarkdownBody';
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useStreamSafeArea } from './stream-safe-area';
@@ -47,156 +47,6 @@ function hasMarkdownFormatting(content: string): boolean {
);
}
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
// message reads the same on both surfaces: white-on-transparent text, a blue
// accent, pink inline code, and a near-opaque dark surface behind code blocks
// and tables. Defined at module scope so the references stay stable —
// `useMarkdown` re-parses only when these or the content change.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks. Instantiated once at module scope to keep the reference stable
// for `useMarkdown`'s memoization.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
export function TextParticleView({
particle,
paused,
@@ -208,11 +58,6 @@ export function TextParticleView({
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
const safe = useStreamSafeArea();
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
const editedLabel = editedAt ? (
<View className="mt-3 items-center">
@@ -290,7 +135,7 @@ export function TextParticleView({
showsVerticalScrollIndicator
indicatorStyle="white"
>
{markdownNodes}
<MarkdownBody content={content} />
{editedLabel}
</ScrollView>
</View>
@@ -189,6 +189,7 @@ export function NewStreamScreen({
networkId={networkId}
targetPath={placeholderPath}
silentPresence
allowTask={false}
submitMedia={submitMedia}
submitText={submitText}
/>
+17 -39
View File
@@ -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"
>
<View
className={cn(
'h-10 w-10 items-center justify-center rounded-full',
isUnseen ? 'bg-primary' : 'bg-muted',
)}
>
<Text
className={cn(
'text-xs font-semibold',
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
)}
>
{initials}
</Text>
</View>
<Avatar
humanId={avatarHumanId}
humans={network?.humans}
size="md"
fallbackInitials={fallbackInitials}
/>
<View className="flex-1">
<Text
@@ -6,6 +6,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Settings as SettingsIcon } from 'lucide-react-native';
import { ListSeparator } from '@/components/ListSeparator';
import { toUserMessage } from '@/lib/errors';
import { particlePath } from '@/lib/particle-path';
@@ -32,6 +33,9 @@ export function StreamListScreen({
<Header
title={network?.name ?? 'Streams'}
onBack={() => 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 (
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
>
{title}
</Text>
<View className="w-8" />
<Pressable
onPress={onOpenSettings}
className="px-2 py-1"
accessibilityLabel="Network settings"
hitSlop={8}
>
<SettingsIcon size={20} color="#fafafa" />
</Pressable>
</View>
);
}
+20
View File
@@ -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;
}
+33
View File
@@ -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),
});
}
+39
View File
@@ -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'] });
},
});
}
@@ -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],
});
},
});
}
+1 -1
View File
@@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
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.
+26
View File
@@ -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<string> {
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';
@@ -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' }}
/>
<Stack.Screen
name="NetworkSettings"
component={NetworkSettingsScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Account" component={AccountScreen} />
</Stack.Navigator>
+1
View File
@@ -15,6 +15,7 @@ export type RootStackParamList = {
serverUrl: string;
};
NewStream: { networkId: string };
NetworkSettings: { networkId: string };
Settings: undefined;
Account: undefined;
};
+11
View File
@@ -63,6 +63,8 @@ interface AuthState {
requestCode: (email: string) => Promise<void>;
signIn: (email: string, code: string) => Promise<void>;
signOut: () => Promise<void>;
/** Re-fetch the current user from Orion (e.g. after an avatar change). */
refreshUser: () => Promise<void>;
/**
* 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<AuthState>((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);
+12
View File
@@ -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"