feat: increase feature parity between mobile and desktop #298
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
|
|||||||
'Flowy uses your microphone to record voice messages.',
|
'Flowy uses your microphone to record voice messages.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'expo-image-picker',
|
||||||
|
{
|
||||||
|
photosPermission:
|
||||||
|
'Flowy uses your photo library to set your profile picture.',
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'expo-notifications',
|
'expo-notifications',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-file-system": "~19.0.16",
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image-picker": "~17.0.8",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
|
||||||
import { appConfig } from '@/config/env';
|
import { appConfig } from '@/config/env';
|
||||||
import { ApiError } from '@/lib/errors';
|
import { ApiError } from '@/lib/errors';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -136,6 +137,45 @@ class ApiClient {
|
|||||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
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 ---
|
// --- Push notification tokens ---
|
||||||
|
|
||||||
async registerPushToken(data: {
|
async registerPushToken(data: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
email_prefix: z.string(),
|
email_prefix: z.string(),
|
||||||
email_notifications_enabled: z.boolean(),
|
email_notifications_enabled: z.boolean(),
|
||||||
|
avatar_object_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Human = z.infer<typeof HumanSchema>;
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
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(),
|
title: z.string(),
|
||||||
description: z.string(),
|
notes: z.string().optional(),
|
||||||
status: z.string().optional(),
|
checklist: z.array(ChecklistItemSchema).optional(),
|
||||||
// humanId
|
// humanId
|
||||||
assigned_to: z.string().optional(),
|
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({
|
export const PaperPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
|
|||||||
media: MediaProperties;
|
media: MediaProperties;
|
||||||
file: FileProperties;
|
file: FileProperties;
|
||||||
text: TextProperties;
|
text: TextProperties;
|
||||||
quest: QuestProperties;
|
task: TaskProperties;
|
||||||
paper: PaperProperties;
|
paper: PaperProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('quest'),
|
type: z.literal('task'),
|
||||||
properties: QuestPropertiesSchema,
|
properties: TaskPropertiesSchema,
|
||||||
|
reactions: ReactionsSchema,
|
||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
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 type { Human } from '@/api/types';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Size = 'xs' | 'sm' | 'md';
|
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
humanId: string | null | undefined;
|
humanId: string | null | undefined;
|
||||||
@@ -13,6 +14,8 @@ interface AvatarProps {
|
|||||||
online?: boolean;
|
online?: boolean;
|
||||||
/** Background ring used to separate stacked avatars from the chrome. */
|
/** Background ring used to separate stacked avatars from the chrome. */
|
||||||
stackBg?: string;
|
stackBg?: string;
|
||||||
|
/** Initials shown when the human can't be resolved (e.g. a group stream). */
|
||||||
|
fallbackInitials?: string;
|
||||||
className?: 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 },
|
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
|
||||||
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
|
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
|
||||||
md: { box: 'h-10 w-10', text: 'text-sm', 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
|
* Human avatar: renders the profile picture when one is set (resolved to a
|
||||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
* signed URL via React Query), otherwise initials. Supports an optional online
|
||||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
* 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({
|
export function Avatar({
|
||||||
humanId,
|
humanId,
|
||||||
@@ -33,15 +39,22 @@ export function Avatar({
|
|||||||
size = 'sm',
|
size = 'sm',
|
||||||
online = false,
|
online = false,
|
||||||
stackBg,
|
stackBg,
|
||||||
|
fallbackInitials,
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) {
|
}: 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 dims = sizeMap[size];
|
||||||
|
const initials =
|
||||||
|
display.exists || fallbackInitials === undefined
|
||||||
|
? display.initials
|
||||||
|
: fallbackInitials;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-black/15 items-center justify-center rounded-full',
|
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
|
||||||
dims.box,
|
dims.box,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -52,9 +65,13 @@ export function Avatar({
|
|||||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
{avatarUrl ? (
|
||||||
{initials}
|
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||||
</Text>
|
) : (
|
||||||
|
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</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 { useCallback, useEffect, useState } from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
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 * as Haptics from 'expo-haptics';
|
||||||
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||||
import { toast } from 'sonner-native';
|
import { toast } from 'sonner-native';
|
||||||
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useEvent } from '@/hooks/use-event';
|
import { useEvent } from '@/hooks/use-event';
|
||||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||||
import { useAuthStore } from '@/stores/auth-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 type { ParticlePath } from '@/lib/particle-path';
|
||||||
import {
|
import {
|
||||||
useStreamComposingBroadcastOptional,
|
useStreamComposingBroadcastOptional,
|
||||||
type ComposingMode,
|
type ComposingMode,
|
||||||
} from '@/features/stream-view/stream-presence-context';
|
} from '@/features/stream-view/stream-presence-context';
|
||||||
import { TextComposeModal } from './TextComposeModal';
|
import { TextComposeModal } from './TextComposeModal';
|
||||||
|
import { TaskComposeSheet } from './TaskComposeSheet';
|
||||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||||
import { ReviewSheet } from './ReviewSheet';
|
import { ReviewSheet } from './ReviewSheet';
|
||||||
@@ -48,6 +58,12 @@ interface ComposeDockProps {
|
|||||||
networkId: string;
|
networkId: string;
|
||||||
targetPath: ParticlePath;
|
targetPath: ParticlePath;
|
||||||
silentPresence?: boolean;
|
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>;
|
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||||
submitText?: (content: string) => Promise<void>;
|
submitText?: (content: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +79,7 @@ export function ComposeDock({
|
|||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
silentPresence = false,
|
silentPresence = false,
|
||||||
|
allowTask = true,
|
||||||
submitMedia,
|
submitMedia,
|
||||||
submitText: submitTextOverride,
|
submitText: submitTextOverride,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
@@ -72,6 +89,7 @@ export function ComposeDock({
|
|||||||
const [mode, setMode] = useState<RecordingMode>('video');
|
const [mode, setMode] = useState<RecordingMode>('video');
|
||||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
|
const [taskOpen, setTaskOpen] = useState(false);
|
||||||
|
|
||||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||||
@@ -79,13 +97,13 @@ export function ComposeDock({
|
|||||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposing(isComposing);
|
setComposing(isComposing);
|
||||||
return () => setComposing(false);
|
return () => setComposing(false);
|
||||||
}, [isComposing, setComposing]);
|
}, [isComposing, setComposing]);
|
||||||
|
|
||||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
||||||
|
|
||||||
const ensurePermissions = useCallback(
|
const ensurePermissions = useCallback(
|
||||||
async (forVideo: boolean): Promise<boolean> => {
|
async (forVideo: boolean): Promise<boolean> => {
|
||||||
@@ -187,6 +205,20 @@ export function ComposeDock({
|
|||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
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 =
|
const dockHidden =
|
||||||
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
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="absolute inset-x-0 bottom-0">
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
className="flex-row items-center justify-between px-8 pb-10"
|
className="flex-row items-center px-8 pb-10"
|
||||||
>
|
>
|
||||||
<Pressable
|
{/* Left and right clusters flex equally so the record button stays
|
||||||
onPress={() =>
|
centered regardless of how many side controls are present. */}
|
||||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
<View className="flex-1 flex-row items-center">
|
||||||
}
|
<Pressable
|
||||||
disabled={ui.kind !== 'idle'}
|
onPress={() =>
|
||||||
accessibilityLabel={`Switch to ${
|
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||||
mode === 'video' ? 'audio' : 'video'
|
}
|
||||||
} mode`}
|
disabled={ui.kind !== 'idle'}
|
||||||
className={cn(
|
accessibilityLabel={`Switch to ${
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
mode === 'video' ? 'audio' : 'video'
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
} mode`}
|
||||||
)}
|
className={cn(
|
||||||
>
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
{mode === 'video' ? (
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
)}
|
||||||
) : (
|
>
|
||||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
{mode === 'video' ? (
|
||||||
)}
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
</Pressable>
|
) : (
|
||||||
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -230,17 +266,33 @@ export function ComposeDock({
|
|||||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<View className="flex-1 flex-row items-center justify-end gap-3">
|
||||||
onPress={() => setTextOpen(true)}
|
{allowTask ? (
|
||||||
disabled={ui.kind !== 'idle'}
|
<Pressable
|
||||||
accessibilityLabel="Compose text"
|
onPress={() => setTaskOpen(true)}
|
||||||
className={cn(
|
disabled={ui.kind !== 'idle'}
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
accessibilityLabel="Create task"
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
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>
|
>
|
||||||
|
<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>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -277,6 +329,12 @@ export function ComposeDock({
|
|||||||
onClose={() => setTextOpen(false)}
|
onClose={() => setTextOpen(false)}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TaskComposeSheet
|
||||||
|
open={taskOpen}
|
||||||
|
onClose={() => setTaskOpen(false)}
|
||||||
|
onSubmit={submitTask}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,17 +342,23 @@ export function ComposeDock({
|
|||||||
function useComposingBroadcast({
|
function useComposingBroadcast({
|
||||||
ui,
|
ui,
|
||||||
textOpen,
|
textOpen,
|
||||||
|
taskOpen,
|
||||||
silent,
|
silent,
|
||||||
}: {
|
}: {
|
||||||
ui: ComposeUiState;
|
ui: ComposeUiState;
|
||||||
textOpen: boolean;
|
textOpen: boolean;
|
||||||
|
taskOpen: boolean;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
}) {
|
}) {
|
||||||
// null when the dock is rendered outside a stream (no presence provider).
|
// null when the dock is rendered outside a stream (no presence provider).
|
||||||
const broadcast = useStreamComposingBroadcastOptional();
|
const broadcast = useStreamComposingBroadcastOptional();
|
||||||
|
|
||||||
const mode: ComposingMode | null =
|
const mode: ComposingMode | null =
|
||||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
ui.kind === 'recording'
|
||||||
|
? 'recording'
|
||||||
|
: textOpen || taskOpen
|
||||||
|
? 'typing'
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
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 { useCallback, useEffect } from 'react';
|
||||||
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||||
import type { Human } from '@/api/types';
|
import type { Human } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import {
|
import {
|
||||||
@@ -215,7 +216,6 @@ function Tile({
|
|||||||
const identity = tile.participant.identity;
|
const identity = tile.participant.identity;
|
||||||
const display = resolveHumanDisplay(identity, humans);
|
const display = resolveHumanDisplay(identity, humans);
|
||||||
const name = tile.participant.name || display.displayName;
|
const name = tile.participant.name || display.displayName;
|
||||||
const initials = display.initials;
|
|
||||||
const isSpeaking = tile.participant.isSpeaking;
|
const isSpeaking = tile.participant.isSpeaking;
|
||||||
const muted =
|
const muted =
|
||||||
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
||||||
@@ -234,9 +234,7 @@ function Tile({
|
|||||||
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
<Avatar humanId={identity} humans={humans} size="lg" />
|
||||||
<Text className="text-white text-xl font-semibold">{initials}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
|
<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="[email protected]"
|
||||||
|
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,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from 'react-native-safe-area-context';
|
} from 'react-native-safe-area-context';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
@@ -26,7 +27,12 @@ interface DrawerProps {
|
|||||||
onNavigateSettings: () => void;
|
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
|
// Lazy-init so each Animated.Value is created once; the setters are never
|
||||||
// called — the values are mutated internally by the native driver.
|
// called — the values are mutated internally by the native driver.
|
||||||
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
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 signOut = useAuthStore((s) => s.signOut);
|
||||||
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={open}
|
visible={open}
|
||||||
@@ -85,11 +89,11 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
>
|
>
|
||||||
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
<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="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">
|
<Avatar
|
||||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
humanId={user?.id}
|
||||||
{initials}
|
humans={user ? [user] : undefined}
|
||||||
</Text>
|
size="md"
|
||||||
</View>
|
/>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className="text-sidebar-foreground text-base font-medium"
|
className="text-sidebar-foreground text-base font-medium"
|
||||||
@@ -114,6 +118,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
onNavigateAccount();
|
onNavigateAccount();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DrawerRow
|
||||||
|
label="Settings"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateSettings();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="border-sidebar-border border-t px-2 py-2">
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
|
|||||||
@@ -8,22 +8,28 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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 { useNetworks } from '@/hooks/use-networks';
|
||||||
|
import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { FlowyLogo } from '@/components/FlowyLogo';
|
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
|
import { CreateNetworkSheet } from './CreateNetworkSheet';
|
||||||
|
|
||||||
export function NetworkListScreen({
|
export function NetworkListScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'NetworkList'>) {
|
}: RootStackScreenProps<'NetworkList'>) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const { data, isLoading, refetch, error } = useNetworks();
|
const { data, isLoading, refetch, error } = useNetworks();
|
||||||
|
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
|
||||||
|
|
||||||
// Local refreshing state — driving RefreshControl from react-query's
|
// Local refreshing state — driving RefreshControl from react-query's
|
||||||
// isRefetching can leave the native spinner visually stuck after the
|
// isRefetching can leave the native spinner visually stuck after the
|
||||||
@@ -32,11 +38,13 @@ export function NetworkListScreen({
|
|||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await refetch();
|
await Promise.all([refetch(), refetchInvitations()]);
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [refetch]);
|
}, [refetch, refetchInvitations]);
|
||||||
|
|
||||||
|
const pendingInvitations = invitations ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -44,14 +52,21 @@ export function NetworkListScreen({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setDrawerOpen(true)}
|
onPress={() => setDrawerOpen(true)}
|
||||||
accessibilityLabel="Open menu"
|
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">
|
<Avatar
|
||||||
{initials}
|
humanId={user?.id}
|
||||||
</Text>
|
humans={user ? [user] : undefined}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<FlowyLogo />
|
<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>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -67,16 +82,21 @@ export function NetworkListScreen({
|
|||||||
<Text className="text-foreground font-medium">Retry</Text>
|
<Text className="text-foreground font-medium">Retry</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : !data || data.length === 0 ? (
|
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState onCreate={() => setCreateOpen(true)} />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
ItemSeparatorComponent={ListSeparator}
|
ItemSeparatorComponent={ListSeparator}
|
||||||
|
ListHeaderComponent={
|
||||||
|
pendingInvitations.length > 0 ? (
|
||||||
|
<InvitationsSection invitations={pendingInvitations} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NetworkCard
|
<NetworkCard
|
||||||
network={item}
|
network={item}
|
||||||
@@ -94,10 +114,65 @@ export function NetworkListScreen({
|
|||||||
onNavigateAccount={() => navigation.navigate('Account')}
|
onNavigateAccount={() => navigation.navigate('Account')}
|
||||||
onNavigateSettings={() => navigation.navigate('Settings')}
|
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateNetworkSheet
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={(networkId) =>
|
||||||
|
navigation.navigate('StreamList', { networkId })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</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({
|
function NetworkCard({
|
||||||
network,
|
network,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -124,15 +199,23 @@ function NetworkCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
<Text className="text-foreground text-lg font-medium text-center">
|
<Text className="text-foreground text-lg font-medium text-center">
|
||||||
You aren’t in any networks yet.
|
You aren’t in any networks yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground mt-2 text-center">
|
<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>
|
</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>
|
</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 { 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 { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||||
const user = useAuthStore((s) => s.user);
|
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 (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</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 ?? '—'} />
|
<Field label="Email" value={user?.email ?? '—'} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</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 { 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';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function SettingsScreen({
|
export function SettingsScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'Settings'>) {
|
}: 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 (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<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 className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="px-4 py-4">
|
||||||
<Text className="text-muted-foreground text-center">
|
<SettingsGroup title="Notifications">
|
||||||
Theme, notifications, and account preferences land here later.
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
</Text>
|
<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>
|
</View>
|
||||||
</SafeAreaView>
|
</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 { useEffect } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import {
|
import { HelpCircle } from 'lucide-react-native';
|
||||||
FileIcon,
|
|
||||||
HelpCircle,
|
|
||||||
ScrollText,
|
|
||||||
BookOpen,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react-native';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
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;
|
const PLACEHOLDER_DURATION_MS = 5000;
|
||||||
|
|
||||||
interface FallbackParticleViewProps {
|
interface FallbackParticleViewProps {
|
||||||
@@ -26,6 +14,10 @@ interface FallbackParticleViewProps {
|
|||||||
onEnded: () => void;
|
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({
|
export function FallbackParticleView({
|
||||||
particle,
|
particle,
|
||||||
networkId,
|
networkId,
|
||||||
@@ -37,25 +29,8 @@ export function FallbackParticleView({
|
|||||||
particle.created_by_human_id,
|
particle.created_by_human_id,
|
||||||
network?.humans,
|
network?.humans,
|
||||||
);
|
);
|
||||||
const meta = TYPE_META[particle.type] ?? {
|
const Icon = HelpCircle;
|
||||||
icon: HelpCircle,
|
const title = particle.type === 'folder' ? particle.properties.name : null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
@@ -70,7 +45,7 @@ export function FallbackParticleView({
|
|||||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="text-white text-base font-semibold">
|
<Text className="text-white text-base font-semibold">
|
||||||
{meta.label}
|
{particle.type}
|
||||||
</Text>
|
</Text>
|
||||||
{title ? (
|
{title ? (
|
||||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
<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 { Plus } from 'lucide-react-native';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
@@ -77,7 +77,6 @@ export function ReactionStack({
|
|||||||
{activeTextKeys.map((text) => {
|
{activeTextKeys.map((text) => {
|
||||||
const reactors = reactions?.[text] ?? [];
|
const reactors = reactions?.[text] ?? [];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
const isMine = reactors.includes(currentHumanId);
|
||||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={text}
|
key={text}
|
||||||
@@ -93,11 +92,7 @@ export function ReactionStack({
|
|||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
|
||||||
<Text className="text-white text-[9px] font-semibold">
|
|
||||||
{firstReactor.initials}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||||
{text}
|
{text}
|
||||||
</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,
|
useStreamComposing,
|
||||||
} from './stream-presence-context';
|
} from './stream-presence-context';
|
||||||
import { TextParticleView } from './TextParticleView';
|
import { TextParticleView } from './TextParticleView';
|
||||||
|
import { TaskParticleView } from './TaskParticleView';
|
||||||
|
import { PaperParticleView } from './PaperParticleView';
|
||||||
|
import { FileParticleView } from './FileParticleView';
|
||||||
import { MediaParticleView } from './MediaParticleView';
|
import { MediaParticleView } from './MediaParticleView';
|
||||||
import { DeletedParticleView } from './DeletedParticleView';
|
import { DeletedParticleView } from './DeletedParticleView';
|
||||||
import { FallbackParticleView } from './FallbackParticleView';
|
import { FallbackParticleView } from './FallbackParticleView';
|
||||||
import { useExitCountdown } from './use-exit-countdown';
|
import { useExitCountdown } from './use-exit-countdown';
|
||||||
import { StreamTopActions } from './StreamTopActions';
|
import { StreamTopActions } from './StreamTopActions';
|
||||||
|
import { StreamStatusPills } from './StreamStatusPills';
|
||||||
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
||||||
import { StreamMembersSheet } from './StreamMembersSheet';
|
import { StreamMembersSheet } from './StreamMembersSheet';
|
||||||
import { RenameStreamSheet } from './RenameStreamSheet';
|
import { RenameStreamSheet } from './RenameStreamSheet';
|
||||||
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
contentFit={videoFit}
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
<FallbackParticleView
|
<FallbackParticleView
|
||||||
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
</View>
|
</View>
|
||||||
</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
|
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
||||||
centered on the canvas; outside the GestureDetector so each pill
|
centered on the canvas; outside the GestureDetector so each pill
|
||||||
tap toggles cleanly without competing with the stream advance/back
|
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 { useEffect, useRef } from 'react';
|
||||||
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MarkdownBody } from '@/components/MarkdownBody';
|
||||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useStreamSafeArea } from './stream-safe-area';
|
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({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
@@ -208,11 +58,6 @@ export function TextParticleView({
|
|||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const safe = useStreamSafeArea();
|
const safe = useStreamSafeArea();
|
||||||
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
|
||||||
renderer: MARKDOWN_RENDERER,
|
|
||||||
theme: MARKDOWN_THEME,
|
|
||||||
styles: MARKDOWN_STYLES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editedLabel = editedAt ? (
|
const editedLabel = editedAt ? (
|
||||||
<View className="mt-3 items-center">
|
<View className="mt-3 items-center">
|
||||||
@@ -290,7 +135,7 @@ export function TextParticleView({
|
|||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
indicatorStyle="white"
|
indicatorStyle="white"
|
||||||
>
|
>
|
||||||
{markdownNodes}
|
<MarkdownBody content={content} />
|
||||||
{editedLabel}
|
{editedLabel}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function NewStreamScreen({
|
|||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={placeholderPath}
|
targetPath={placeholderPath}
|
||||||
silentPresence
|
silentPresence
|
||||||
|
allowTask={false}
|
||||||
submitMedia={submitMedia}
|
submitMedia={submitMedia}
|
||||||
submitText={submitText}
|
submitText={submitText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
|
|||||||
import { Headphones } from 'lucide-react-native';
|
import { Headphones } from 'lucide-react-native';
|
||||||
import type { Particle, StreamProperties } from '@/api/types';
|
import type { Particle, StreamProperties } from '@/api/types';
|
||||||
import { isParticleDeleted } from '@/api/types';
|
import { isParticleDeleted } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
interface StreamCardProps {
|
interface StreamCardProps {
|
||||||
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
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) {
|
if (isDM) {
|
||||||
const otherEntry = particle.visible_to.find(
|
const otherEntry = particle.visible_to.find(
|
||||||
(v) => v !== `human:${userId}`,
|
(v) => v !== `human:${userId}`,
|
||||||
);
|
);
|
||||||
if (otherEntry) {
|
if (otherEntry) return otherEntry.replace('human:', '');
|
||||||
const otherId = otherEntry.replace('human:', '');
|
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return latestChild?.created_by_human_id ?? null;
|
||||||
|
}, [isDM, particle.visible_to, userId, latestChild]);
|
||||||
|
|
||||||
if (latestChild) {
|
const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
|
||||||
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 isUnseen = useMemo(() => {
|
const isUnseen = useMemo(() => {
|
||||||
if (!latestChild) return false;
|
if (!latestChild) return false;
|
||||||
@@ -87,7 +74,7 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
return latestChild.properties.content;
|
return latestChild.properties.content;
|
||||||
case 'file':
|
case 'file':
|
||||||
return latestChild.properties.filename;
|
return latestChild.properties.filename;
|
||||||
case 'quest':
|
case 'task':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
|
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"
|
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
||||||
>
|
>
|
||||||
<View
|
<Avatar
|
||||||
className={cn(
|
humanId={avatarHumanId}
|
||||||
'h-10 w-10 items-center justify-center rounded-full',
|
humans={network?.humans}
|
||||||
isUnseen ? 'bg-primary' : 'bg-muted',
|
size="md"
|
||||||
)}
|
fallbackInitials={fallbackInitials}
|
||||||
>
|
/>
|
||||||
<Text
|
|
||||||
className={cn(
|
|
||||||
'text-xs font-semibold',
|
|
||||||
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Settings as SettingsIcon } from 'lucide-react-native';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
@@ -32,6 +33,9 @@ export function StreamListScreen({
|
|||||||
<Header
|
<Header
|
||||||
title={network?.name ?? 'Streams'}
|
title={network?.name ?? 'Streams'}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => navigation.goBack()}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
navigation.navigate('NetworkSettings', { networkId })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{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 (
|
return (
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</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>
|
</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 'media':
|
||||||
case 'file':
|
case 'file':
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'quest':
|
case 'task':
|
||||||
case 'paper': {
|
case 'paper': {
|
||||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||||
// particles carry `properties.edited_at`, so coerce it if present.
|
// particles carry `properties.edited_at`, so coerce it if present.
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ export async function createTextParticle({
|
|||||||
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
|
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 {
|
function extensionFromMime(mime: string): string {
|
||||||
if (mime === 'video/mp4') return '.mp4';
|
if (mime === 'video/mp4') return '.mp4';
|
||||||
if (mime === 'video/quicktime') return '.mov';
|
if (mime === 'video/quicktime') return '.mov';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
|||||||
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||||
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||||
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||||
|
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
|
||||||
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||||
import { AccountScreen } from '@/features/settings/AccountScreen';
|
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||||
import type { RootStackParamList } from './types';
|
import type { RootStackParamList } from './types';
|
||||||
@@ -54,6 +55,11 @@ export function RootNavigator() {
|
|||||||
component={NewStreamScreen}
|
component={NewStreamScreen}
|
||||||
options={{ animation: 'slide_from_bottom' }}
|
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="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Account" component={AccountScreen} />
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
};
|
};
|
||||||
NewStream: { networkId: string };
|
NewStream: { networkId: string };
|
||||||
|
NetworkSettings: { networkId: string };
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface AuthState {
|
|||||||
requestCode: (email: string) => Promise<void>;
|
requestCode: (email: string) => Promise<void>;
|
||||||
signIn: (email: string, code: string) => Promise<void>;
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
signOut: () => 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
|
* 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
|
* 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 () => {
|
invalidateSession: async () => {
|
||||||
stopPushTokenSync();
|
stopPushTokenSync();
|
||||||
apiClient.setToken(null);
|
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"
|
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||||
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
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:
|
expo-keep-awake@~15.0.8:
|
||||||
version "15.0.8"
|
version "15.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||||
|
|||||||
Reference in New Issue
Block a user