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"