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:
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 & 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="Today’s 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 ? `Couldn’t 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">
|
||||
Couldn’t 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">
|
||||
You’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
You’ve 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 aren’t 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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
||||
serverUrl: string;
|
||||
};
|
||||
NewStream: { networkId: string };
|
||||
NetworkSettings: { networkId: string };
|
||||
Settings: undefined;
|
||||
Account: undefined;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user