wip(mobile): lint and format

This commit is contained in:
Arjun Patel
2026-06-01 14:42:49 -07:00
parent 52ff92083a
commit 8d898c5183
78 changed files with 3242 additions and 1780 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.expo
ios
android
yarn.lock
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"jsxSingleQuote": false
}
+25
View File
@@ -0,0 +1,25 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
const eslintConfigPrettier = require("eslint-config-prettier");
module.exports = defineConfig([
expoConfig,
// Turn off ESLint rules that conflict with Prettier. Keep this after expoConfig.
eslintConfigPrettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
{
ignores: ["dist/*", ".expo/*"],
},
]);
+6 -1
View File
@@ -9,7 +9,8 @@
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'", "publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
"android": "expo run:android", "android": "expo run:android",
"compile": "tsc --noEmit", "compile": "tsc --noEmit",
"lint": "expo lint" "lint": "expo lint",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
}, },
"packageManager": "[email protected]", "packageManager": "[email protected]",
"dependencies": { "dependencies": {
@@ -53,7 +54,11 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"eslint": "^9",
"eslint-config-expo": "^56.0.4",
"eslint-config-prettier": "^10.1.8",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
"prettier": "^3.8.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.9.0" "typescript": "~5.9.0"
} }
+13 -13
View File
@@ -1,22 +1,22 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { StatusBar } from "expo-status-bar"; import { StatusBar } from 'expo-status-bar';
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from '@tanstack/react-query';
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer } from "@react-navigation/native"; import { NavigationContainer } from '@react-navigation/native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
import { Toaster } from "sonner-native"; import { Toaster } from 'sonner-native';
import { createQueryClient } from "@/lib/query-client"; import { createQueryClient } from '@/lib/query-client';
import { import {
flushPendingNavigation, flushPendingNavigation,
navigationRef, navigationRef,
} from "@/lib/notification-routing"; } from '@/lib/notification-routing';
import { configureNotifications } from "@/lib/push-notifications"; import { configureNotifications } from '@/lib/push-notifications';
import { PusherProvider } from "@/lib/pusher-provider"; import { PusherProvider } from '@/lib/pusher-provider';
import { RootNavigator } from "@/navigation/RootNavigator"; import { RootNavigator } from '@/navigation/RootNavigator';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
const queryClient = createQueryClient(); const queryClient = createQueryClient();
+38 -41
View File
@@ -1,6 +1,6 @@
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';
import { import {
BillingStatusSchema, BillingStatusSchema,
CheckoutSessionResponseSchema, CheckoutSessionResponseSchema,
@@ -15,7 +15,7 @@ import {
PortalSessionResponseSchema, PortalSessionResponseSchema,
PrepareUploadResponseSchema, PrepareUploadResponseSchema,
SignInResponseSchema, SignInResponseSchema,
} from "./types"; } from './types';
import type { import type {
AcceptInvitationRequest, AcceptInvitationRequest,
AddMembersRequest, AddMembersRequest,
@@ -25,7 +25,7 @@ import type {
RequestCodeRequest, RequestCodeRequest,
RevokeInvitationRequest, RevokeInvitationRequest,
SignInRequest, SignInRequest,
} from "./types"; } from './types';
/** /**
* HTTP transport for Orion. Holds the bearer token as private state — the auth * HTTP transport for Orion. Holds the bearer token as private state — the auth
@@ -51,11 +51,11 @@ class ApiClient {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (body) { if (body) {
headers["Content-Type"] = "application/json"; headers['Content-Type'] = 'application/json';
} }
if (this.token) { if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`; headers['Authorization'] = `Bearer ${this.token}`;
} }
const response = await fetch(`${this.baseUrl}${path}`, { const response = await fetch(`${this.baseUrl}${path}`, {
@@ -65,11 +65,11 @@ class ApiClient {
}); });
if (response.status === 401) { if (response.status === 401) {
throw new ApiError(401, "Unauthorized"); throw new ApiError(401, 'Unauthorized');
} }
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => "Unknown error"); const text = await response.text().catch(() => 'Unknown error');
throw new ApiError(response.status, text); throw new ApiError(response.status, text);
} }
@@ -98,35 +98,32 @@ class ApiClient {
// --- Auth --- // --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> { async requestCode(data: RequestCodeRequest): Promise<void> {
await this.requestVoid("POST", "/auth/request-code", data); await this.requestVoid('POST', '/auth/request-code', data);
} }
async signIn(data: SignInRequest) { async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
} }
async me() { async me() {
return this.request(HumanSchema, "GET", "/auth/me"); return this.request(HumanSchema, 'GET', '/auth/me');
} }
async signOut(): Promise<void> { async signOut(): Promise<void> {
await this.requestVoid("POST", "/auth/sign-out"); await this.requestVoid('POST', '/auth/sign-out');
} }
async getFirebaseToken() { async getFirebaseToken() {
return this.request( return this.request(
FirebaseTokenResponseSchema, FirebaseTokenResponseSchema,
"POST", 'POST',
"/auth/firebase-token", '/auth/firebase-token',
); );
} }
// TODO: security: require passing in the particle id once api deprecates this // TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> { async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch( const response = await this.fetch('GET', `/particles/${objectId}/download`);
"GET",
`/particles/${objectId}/download`,
);
const data = await response.json(); const data = await response.json();
return data.url; return data.url;
} }
@@ -136,21 +133,21 @@ class ApiClient {
async updateSettings(data: { async updateSettings(data: {
email_notifications_enabled?: boolean; email_notifications_enabled?: boolean;
}): Promise<void> { }): Promise<void> {
await this.requestVoid("PATCH", "/humans/me/settings", data); await this.requestVoid('PATCH', '/humans/me/settings', data);
} }
// --- Push notification tokens --- // --- Push notification tokens ---
async registerPushToken(data: { async registerPushToken(data: {
token: string; token: string;
platform: "ios" | "android"; platform: 'ios' | 'android';
app_version: string; app_version: string;
}): Promise<void> { }): Promise<void> {
await this.requestVoid("POST", "/humans/me/push-tokens", data); await this.requestVoid('POST', '/humans/me/push-tokens', data);
} }
async unregisterPushToken(token: string): Promise<void> { async unregisterPushToken(token: string): Promise<void> {
await this.requestVoid("DELETE", "/humans/me/push-tokens", { token }); await this.requestVoid('DELETE', '/humans/me/push-tokens', { token });
} }
// --- Depot --- // --- Depot ---
@@ -158,8 +155,8 @@ class ApiClient {
async prepareUpload(data: PrepareUploadRequest) { async prepareUpload(data: PrepareUploadRequest) {
return this.request( return this.request(
PrepareUploadResponseSchema, PrepareUploadResponseSchema,
"POST", 'POST',
"/depot/upload", '/depot/upload',
data, data,
); );
} }
@@ -167,7 +164,7 @@ class ApiClient {
async confirmUpload(objectId: string) { async confirmUpload(objectId: string) {
return this.request( return this.request(
DepotObjectSchema, DepotObjectSchema,
"POST", 'POST',
`/depot/objects/${objectId}/confirm`, `/depot/objects/${objectId}/confirm`,
); );
} }
@@ -175,24 +172,24 @@ class ApiClient {
// --- Networks --- // --- Networks ---
async listNetworks() { async listNetworks() {
return this.request(ListNetworksResponseSchema, "GET", "/networks"); return this.request(ListNetworksResponseSchema, 'GET', '/networks');
} }
async createNetwork(data: CreateNetworkRequest) { async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, "POST", "/networks", data); return this.request(NetworkSchema, 'POST', '/networks', data);
} }
async getNetwork(id: string) { async getNetwork(id: string) {
return this.request(NetworkSchema, "GET", `/networks/${id}`); return this.request(NetworkSchema, 'GET', `/networks/${id}`);
} }
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> { async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid("POST", `/networks/${networkId}/members`, data); await this.requestVoid('POST', `/networks/${networkId}/members`, data);
} }
async removeMember(networkId: string, humanId: string): Promise<void> { async removeMember(networkId: string, humanId: string): Promise<void> {
await this.requestVoid( await this.requestVoid(
"DELETE", 'DELETE',
`/networks/${networkId}/members/${humanId}`, `/networks/${networkId}/members/${humanId}`,
); );
} }
@@ -202,17 +199,17 @@ class ApiClient {
async listNetworkInvitations(networkId: string) { async listNetworkInvitations(networkId: string) {
return this.request( return this.request(
ListInvitationsResponseSchema, ListInvitationsResponseSchema,
"GET", 'GET',
`/networks/${networkId}/invitations`, `/networks/${networkId}/invitations`,
); );
} }
async listMyInvitations() { async listMyInvitations() {
return this.request(ListInvitationsResponseSchema, "GET", "/invitations"); return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
} }
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> { async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
await this.requestVoid("POST", "/invitations/accept", data); await this.requestVoid('POST', '/invitations/accept', data);
} }
async revokeInvitation( async revokeInvitation(
@@ -220,7 +217,7 @@ class ApiClient {
data: RevokeInvitationRequest, data: RevokeInvitationRequest,
): Promise<void> { ): Promise<void> {
await this.requestVoid( await this.requestVoid(
"DELETE", 'DELETE',
`/networks/${networkId}/invitations`, `/networks/${networkId}/invitations`,
data, data,
); );
@@ -231,8 +228,8 @@ class ApiClient {
async getLivekitToken(networkId: string, streamId: string) { async getLivekitToken(networkId: string, streamId: string) {
return this.request( return this.request(
GetLivekitTokenResponseSchema, GetLivekitTokenResponseSchema,
"POST", 'POST',
"/livekit/token", '/livekit/token',
{ network_id: networkId, stream_id: streamId }, { network_id: networkId, stream_id: streamId },
); );
} }
@@ -242,7 +239,7 @@ class ApiClient {
async getNetworkBilling(networkId: string) { async getNetworkBilling(networkId: string) {
return this.request( return this.request(
BillingStatusSchema, BillingStatusSchema,
"GET", 'GET',
`/networks/${networkId}/billing`, `/networks/${networkId}/billing`,
); );
} }
@@ -250,7 +247,7 @@ class ApiClient {
async createCheckoutSession(networkId: string, cadence: BillingCadence) { async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request( return this.request(
CheckoutSessionResponseSchema, CheckoutSessionResponseSchema,
"POST", 'POST',
`/networks/${networkId}/billing/checkout-session`, `/networks/${networkId}/billing/checkout-session`,
{ cadence }, { cadence },
); );
@@ -259,7 +256,7 @@ class ApiClient {
async createPortalSession(networkId: string) { async createPortalSession(networkId: string) {
return this.request( return this.request(
PortalSessionResponseSchema, PortalSessionResponseSchema,
"POST", 'POST',
`/networks/${networkId}/billing/portal-session`, `/networks/${networkId}/billing/portal-session`,
); );
} }
@@ -267,7 +264,7 @@ class ApiClient {
async getNetworkUsage(networkId: string) { async getNetworkUsage(networkId: string) {
return this.request( return this.request(
NetworkUsageSchema, NetworkUsageSchema,
"GET", 'GET',
`/networks/${networkId}/usage`, `/networks/${networkId}/usage`,
); );
} }
+72 -32
View File
@@ -1,4 +1,4 @@
import { z } from "zod"; import { z } from 'zod';
export const HumanSchema = z.object({ export const HumanSchema = z.object({
id: z.string(), id: z.string(),
@@ -25,12 +25,12 @@ export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
// --- Network request/response types --- // --- Network request/response types ---
const CreateNetworkRequestSchema = z.object({ export const CreateNetworkRequestSchema = z.object({
name: z.string(), name: z.string(),
}); });
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>; export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
const AddMembersRequestSchema = z.object({ export const AddMembersRequestSchema = z.object({
email_addresses: z.array(z.string().email()), email_addresses: z.array(z.string().email()),
}); });
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>; export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
@@ -52,7 +52,7 @@ export type RevokeInvitationRequest = { email: string };
// --- Depot types --- // --- Depot types ---
const PrepareUploadRequestSchema = z.object({ export const PrepareUploadRequestSchema = z.object({
network_id: z.string(), network_id: z.string(),
name: z.string(), name: z.string(),
content_type: z.string(), content_type: z.string(),
@@ -122,7 +122,7 @@ export const MediaPropertiesSchema = z.object({
duration_ms: z.number(), duration_ms: z.number(),
size_bytes: z.number(), size_bytes: z.number(),
transcript: TranscriptSchema.optional(), transcript: TranscriptSchema.optional(),
source: z.enum(["camera", "screen"]).optional(), source: z.enum(['camera', 'screen']).optional(),
// Set by the particle processor worker once an iOS-playable MP4/m4a variant // Set by the particle processor worker once an iOS-playable MP4/m4a variant
// has been produced from a non-iOS-playable original (e.g. WebM from desktop). // has been produced from a non-iOS-playable original (e.g. WebM from desktop).
// When present, clients should prefer these over object_id/mime_type for playback. // When present, clients should prefer these over object_id/mime_type for playback.
@@ -162,7 +162,9 @@ export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
// --- Reactions --- // --- Reactions ---
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional(); export const ReactionsSchema = z
.record(z.string(), z.array(z.string()))
.optional();
export type Reactions = z.infer<typeof ReactionsSchema>; export type Reactions = z.infer<typeof ReactionsSchema>;
// --- Tombstone (soft-delete) --- // --- Tombstone (soft-delete) ---
@@ -175,7 +177,15 @@ const TombstoneFields = {
deleted_by_human_id: z.string().optional(), deleted_by_human_id: z.string().optional(),
}; };
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const; export const REACTION_EMOJIS = [
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F525}',
'\u{1F440}',
'\u{2705}',
'\u{2753}',
'\u{1F602}',
] as const;
export interface ParticlePropertiesMap { export interface ParticlePropertiesMap {
stream: StreamProperties; stream: StreamProperties;
@@ -196,9 +206,9 @@ const ParticleBaseSchema = z.object({
updated_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(),
}); });
export const ParticleSchema = z.discriminatedUnion("type", [ export const ParticleSchema = z.discriminatedUnion('type', [
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal("stream"), type: z.literal('stream'),
properties: StreamPropertiesSchema, properties: StreamPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network // e.g. ["network:xywx"] - visible to everyone in the network
@@ -210,27 +220,53 @@ export const ParticleSchema = z.discriminatedUnion("type", [
last_child_created_at: z.coerce.date().optional(), last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks) // Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(), huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(["open", "closed"]).optional(), status: z.enum(['open', 'closed']).optional(),
}), }),
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema, type: z.literal('folder'),
properties: FolderPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network // e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
}), }),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), ParticleBaseSchema.extend({
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }), type: z.literal('media'),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }), properties: MediaPropertiesSchema,
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }), reactions: ReactionsSchema,
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }), ...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('file'),
properties: FilePropertiesSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('text'),
properties: TextPropertiesSchema,
reactions: ReactionsSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('quest'),
properties: QuestPropertiesSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({
type: z.literal('paper'),
properties: PaperPropertiesSchema,
...TombstoneFields,
}),
]); ]);
export type Particle = z.infer<typeof ParticleSchema>; export type Particle = z.infer<typeof ParticleSchema>;
export type ParticleType = Particle["type"]; export type ParticleType = Particle['type'];
/** Container types can have children subcollections */ /** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]); export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
'stream',
'folder',
]);
export function isContainerType(type: ParticleType): boolean { export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type); return CONTAINER_TYPES.has(type);
@@ -238,7 +274,7 @@ export function isContainerType(type: ParticleType): boolean {
/** True when a non-container particle has been soft-deleted (tombstoned). */ /** True when a non-container particle has been soft-deleted (tombstoned). */
export function isParticleDeleted(particle: Particle): boolean { export function isParticleDeleted(particle: Particle): boolean {
return "deleted_at" in particle && particle.deleted_at != null; return 'deleted_at' in particle && particle.deleted_at != null;
} }
// --- LiveKit types --- // --- LiveKit types ---
@@ -247,16 +283,18 @@ export const GetLivekitTokenResponseSchema = z.object({
token: z.string(), token: z.string(),
server_url: z.string(), server_url: z.string(),
}); });
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>; export type GetLivekitTokenResponse = z.infer<
typeof GetLivekitTokenResponseSchema
>;
// --- Auth types --- // --- Auth types ---
const RequestCodeRequestSchema = z.object({ export const RequestCodeRequestSchema = z.object({
email: z.string().email(), email: z.string().email(),
}); });
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>; export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
const SignInRequestSchema = z.object({ export const SignInRequestSchema = z.object({
email: z.string().email(), email: z.string().email(),
code: z.string(), code: z.string(),
}); });
@@ -275,21 +313,21 @@ export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
// --- Billing types --- // --- Billing types ---
export const BillingCadenceSchema = z.enum(["monthly", "annual"]); export const BillingCadenceSchema = z.enum(['monthly', 'annual']);
export type BillingCadence = z.infer<typeof BillingCadenceSchema>; export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
export const NetworkPlanSchema = z.enum(["free", "pro"]); export const NetworkPlanSchema = z.enum(['free', 'pro']);
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>; export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
// Mirrors Stripe subscription.status plus "active" as the default free-tier value. // Mirrors Stripe subscription.status plus "active" as the default free-tier value.
export const BillingPlanStatusSchema = z.enum([ export const BillingPlanStatusSchema = z.enum([
"active", 'active',
"trialing", 'trialing',
"past_due", 'past_due',
"canceled", 'canceled',
"incomplete", 'incomplete',
"incomplete_expired", 'incomplete_expired',
"unpaid", 'unpaid',
]); ]);
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>; export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
@@ -308,7 +346,9 @@ export type BillingStatus = z.infer<typeof BillingStatusSchema>;
export const CheckoutSessionResponseSchema = z.object({ export const CheckoutSessionResponseSchema = z.object({
url: z.string().url(), url: z.string().url(),
}); });
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>; export type CheckoutSessionResponse = z.infer<
typeof CheckoutSessionResponseSchema
>;
export const PortalSessionResponseSchema = z.object({ export const PortalSessionResponseSchema = z.object({
url: z.string().url(), url: z.string().url(),
+12 -12
View File
@@ -1,9 +1,9 @@
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
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';
interface AvatarProps { interface AvatarProps {
humanId: string | null | undefined; humanId: string | null | undefined;
@@ -17,9 +17,9 @@ interface AvatarProps {
} }
const sizeMap: Record<Size, { box: string; text: string; ring: number }> = { 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 },
}; };
/** /**
@@ -30,7 +30,7 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
export function Avatar({ export function Avatar({
humanId, humanId,
humans, humans,
size = "sm", size = 'sm',
online = false, online = false,
stackBg, stackBg,
className, className,
@@ -41,7 +41,7 @@ export function Avatar({
return ( return (
<View <View
className={cn( className={cn(
"bg-black/15 items-center justify-center rounded-full", 'bg-black/15 items-center justify-center rounded-full',
dims.box, dims.box,
className, className,
)} )}
@@ -49,10 +49,10 @@ export function Avatar({
// Online ring is the priority; if not online, show the stack // Online ring is the priority; if not online, show the stack
// separator ring (if requested) so adjacent avatars stay distinct. // separator ring (if requested) so adjacent avatars stay distinct.
borderWidth: online ? dims.ring : stackBg ? dims.ring : 0, borderWidth: online ? dims.ring : stackBg ? dims.ring : 0,
borderColor: online ? "#22c55e" : stackBg ?? "transparent", borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
}} }}
> >
<Text className={cn("text-white font-semibold", dims.text)}> <Text className={cn('text-white font-semibold', dims.text)}>
{initials} {initials}
</Text> </Text>
</View> </View>
+23 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Dimensions, Dimensions,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -6,13 +6,13 @@ import {
Platform, Platform,
Pressable, Pressable,
View, View,
} from "react-native"; } from 'react-native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { import Animated, {
Easing, Easing,
Extrapolation, Extrapolation,
@@ -22,9 +22,9 @@ import Animated, {
useSharedValue, useSharedValue,
withSpring, withSpring,
withTiming, withTiming,
} from "react-native-reanimated"; } from 'react-native-reanimated';
const SCREEN_HEIGHT = Dimensions.get("window").height; const SCREEN_HEIGHT = Dimensions.get('window').height;
const ANIMATION_MS = 240; const ANIMATION_MS = 240;
interface BottomSheetProps { interface BottomSheetProps {
@@ -58,7 +58,7 @@ export function BottomSheet({
onClose, onClose,
onClosed, onClosed,
avoidKeyboard = false, avoidKeyboard = false,
maxHeight = "85%", maxHeight = '85%',
children, children,
}: BottomSheetProps) { }: BottomSheetProps) {
// Mount slightly past `open` so the slide-in animation has its starting // Mount slightly past `open` so the slide-in animation has its starting
@@ -66,6 +66,9 @@ export function BottomSheet({
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const translateY = useSharedValue(SCREEN_HEIGHT); const translateY = useSharedValue(SCREEN_HEIGHT);
// Mount as soon as we open; the close path unmounts after the exit animation.
if (open && !mounted) setMounted(true);
// Latest onClosed in a ref so the worklet→JS bridge always invokes the // Latest onClosed in a ref so the worklet→JS bridge always invokes the
// current callback even if the parent re-rendered with a new closure. // current callback even if the parent re-rendered with a new closure.
const onClosedRef = useRef(onClosed); const onClosedRef = useRef(onClosed);
@@ -80,7 +83,6 @@ export function BottomSheet({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true);
requestAnimationFrame(() => { requestAnimationFrame(() => {
translateY.value = withSpring(0, { translateY.value = withSpring(0, {
damping: 24, damping: 24,
@@ -104,14 +106,18 @@ export function BottomSheet({
.activeOffsetY(10) .activeOffsetY(10)
.failOffsetX([-25, 25]) .failOffsetX([-25, 25])
.onUpdate((e) => { .onUpdate((e) => {
"worklet"; 'worklet';
// Reanimated shared values are mutated by design; react-hooks/immutability
// doesn't model worklets, so the mutations below are flagged spuriously.
// eslint-disable-next-line react-hooks/immutability
translateY.value = Math.max(0, e.translationY); translateY.value = Math.max(0, e.translationY);
}) })
.onEnd((e) => { .onEnd((e) => {
"worklet"; 'worklet';
if (e.translationY > 120 || e.velocityY > 800) { if (e.translationY > 120 || e.velocityY > 800) {
runOnJS(onClose)(); runOnJS(onClose)();
} else { } else {
// eslint-disable-next-line react-hooks/immutability
translateY.value = withSpring(0, { translateY.value = withSpring(0, {
damping: 24, damping: 24,
stiffness: 260, stiffness: 260,
@@ -138,7 +144,7 @@ export function BottomSheet({
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View; const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
const wrapperProps = avoidKeyboard const wrapperProps = avoidKeyboard
? { behavior: Platform.OS === "ios" ? ("padding" as const) : undefined } ? { behavior: Platform.OS === 'ios' ? ('padding' as const) : undefined }
: {}; : {};
return ( return (
@@ -151,9 +157,9 @@ export function BottomSheet({
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Animated.View <Animated.View
pointerEvents={open ? "auto" : "none"} pointerEvents={open ? 'auto' : 'none'}
style={[ style={[
{ position: "absolute", inset: 0, backgroundColor: "black" }, { position: 'absolute', inset: 0, backgroundColor: 'black' },
backdropStyle, backdropStyle,
]} ]}
> >
@@ -162,23 +168,23 @@ export function BottomSheet({
<Wrapper <Wrapper
{...wrapperProps} {...wrapperProps}
style={{ flex: 1, justifyContent: "flex-end" }} style={{ flex: 1, justifyContent: 'flex-end' }}
pointerEvents="box-none" pointerEvents="box-none"
> >
<GestureDetector gesture={sheetPan}> <GestureDetector gesture={sheetPan}>
<Animated.View <Animated.View
style={[ style={[
{ {
backgroundColor: "#1c1c1c", backgroundColor: '#1c1c1c',
borderTopLeftRadius: 22, borderTopLeftRadius: 22,
borderTopRightRadius: 22, borderTopRightRadius: 22,
overflow: "hidden", overflow: 'hidden',
maxHeight, maxHeight,
}, },
sheetStyle, sheetStyle,
]} ]}
> >
<SafeAreaView edges={["bottom"]}> <SafeAreaView edges={['bottom']}>
<View className="px-5 pt-3 items-center"> <View className="px-5 pt-3 items-center">
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" /> <View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
</View> </View>
+13 -13
View File
@@ -1,15 +1,15 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import Animated, { import Animated, {
Easing, Easing,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withRepeat, withRepeat,
withTiming, withTiming,
} from "react-native-reanimated"; } from 'react-native-reanimated';
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import type { ComposingUser } from "@/features/stream-view/stream-presence-context"; import type { ComposingUser } from '@/features/stream-view/stream-presence-context';
interface ComposingIndicatorProps { interface ComposingIndicatorProps {
users: ComposingUser[]; users: ComposingUser[];
@@ -29,13 +29,16 @@ export function ComposingIndicator({
if (users.length === 0) return null; if (users.length === 0) return null;
return ( return (
<View pointerEvents="none" className="flex-row flex-wrap items-center gap-1.5"> <View
pointerEvents="none"
className="flex-row flex-wrap items-center gap-1.5"
>
{users.map((u) => { {users.map((u) => {
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans); const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
const label = const label =
u.mode === "recording" u.mode === 'recording'
? `${displayName} is recording` ? `${displayName} is recording`
: u.mode === "screen" : u.mode === 'screen'
? `${displayName} is sharing` ? `${displayName} is sharing`
: `${displayName} is typing`; : `${displayName} is typing`;
@@ -87,9 +90,6 @@ function Dot({ delay }: { delay: number }) {
})); }));
return ( return (
<Animated.View <Animated.View className="bg-white/85 h-1 w-1 rounded-full" style={style} />
className="bg-white/85 h-1 w-1 rounded-full"
style={style}
/>
); );
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import Svg, { Path } from "react-native-svg"; import Svg, { Path } from 'react-native-svg';
type Props = { type Props = {
height?: number; height?: number;
@@ -7,7 +7,7 @@ type Props = {
const ASPECT_RATIO = 89 / 18; const ASPECT_RATIO = 89 / 18;
export function FlowyLogo({ height = 18, color = "#828282" }: Props) { export function FlowyLogo({ height = 18, color = '#828282' }: Props) {
const width = height * ASPECT_RATIO; const width = height * ASPECT_RATIO;
return ( return (
<Svg width={width} height={height} viewBox="0 0 89 18" fill="none"> <Svg width={width} height={height} viewBox="0 0 89 18" fill="none">
+1 -1
View File
@@ -1,4 +1,4 @@
import { View } from "react-native"; import { View } from 'react-native';
/** /**
* Inset hairline separator for edge-to-edge list rows. Pass to a FlatList * Inset hairline separator for edge-to-edge list rows. Pass to a FlatList
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { Text, type TextProps } from "react-native"; import { Text, type TextProps } from 'react-native';
import { formatDistanceToNow } from "@/lib/time-utils"; import { formatDistanceToNow } from '@/lib/time-utils';
const MINUTE_MS = 60_000; const MINUTE_MS = 60_000;
interface RelativeTimestampProps extends Omit<TextProps, "children"> { interface RelativeTimestampProps extends Omit<TextProps, 'children'> {
date: Date; date: Date;
} }
+21 -21
View File
@@ -1,4 +1,4 @@
import Constants from "expo-constants"; import Constants from 'expo-constants';
// Expo-side equivalent of desktop's __APP_ENV__ build-time replacement // Expo-side equivalent of desktop's __APP_ENV__ build-time replacement
// (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts // (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts
@@ -26,38 +26,38 @@ type AppConfig = {
sentryDsn: string; sentryDsn: string;
}; };
const configs: Record<"dev" | "prod", AppConfig> = { const configs: Record<'dev' | 'prod', AppConfig> = {
dev: { dev: {
orionUrl: "https://orion.dev.flowy.live", orionUrl: 'https://orion.dev.flowy.live',
pusherUrl: "wss://pusher.dev.flowy.live/ws", pusherUrl: 'wss://pusher.dev.flowy.live/ws',
firebase: { firebase: {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", apiKey: 'AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk',
appId: "1:1006580076785:web:e2a0736d60a78e02b15950", appId: '1:1006580076785:web:e2a0736d60a78e02b15950',
authDomain: "flowy-dev-440017.firebaseapp.com", authDomain: 'flowy-dev-440017.firebaseapp.com',
messagingSenderId: "1006580076785", messagingSenderId: '1006580076785',
projectId: "flowy-dev-440017", projectId: 'flowy-dev-440017',
storageBucket: "flowy-dev-440017.firebasestorage.app", storageBucket: 'flowy-dev-440017.firebasestorage.app',
}, },
sentryDsn: sentryDsn:
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528", 'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
}, },
prod: { prod: {
orionUrl: "https://orion.flowy.live", orionUrl: 'https://orion.flowy.live',
pusherUrl: "wss://pusher.flowy.live/ws", pusherUrl: 'wss://pusher.flowy.live/ws',
firebase: { firebase: {
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg", apiKey: 'AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg',
appId: "1:68063426854:web:5054f16f50898f5706e9e7", appId: '1:68063426854:web:5054f16f50898f5706e9e7',
authDomain: "flowy-prod-440017.firebaseapp.com", authDomain: 'flowy-prod-440017.firebaseapp.com',
messagingSenderId: "68063426854", messagingSenderId: '68063426854',
projectId: "flowy-prod-440017", projectId: 'flowy-prod-440017',
storageBucket: "flowy-prod-440017.firebasestorage.app", storageBucket: 'flowy-prod-440017.firebasestorage.app',
}, },
sentryDsn: sentryDsn:
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528", 'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
}, },
}; };
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined) const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
?.appEnv; ?.appEnv;
export const appEnv: "dev" | "prod" = rawEnv === "prod" ? "prod" : "dev"; export const appEnv: 'dev' | 'prod' = rawEnv === 'prod' ? 'prod' : 'dev';
export const appConfig: AppConfig = configs[appEnv]; export const appConfig: AppConfig = configs[appEnv];
+22 -26
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState } from 'react';
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@@ -6,32 +6,32 @@ import {
Text, Text,
TextInput, TextInput,
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 { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
type Step = "email" | "code"; type Step = 'email' | 'code';
export function SignInScreen() { export function SignInScreen() {
const [step, setStep] = useState<Step>("email"); const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState(""); const [email, setEmail] = useState('');
return ( return (
<SafeAreaView className="flex-1 bg-background"> <SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1" className="flex-1"
> >
<View className="flex-1 justify-center px-6"> <View className="flex-1 justify-center px-6">
{step === "email" ? ( {step === 'email' ? (
<EmailStep <EmailStep
onCodeSent={(submittedEmail) => { onCodeSent={(submittedEmail) => {
setEmail(submittedEmail); setEmail(submittedEmail);
setStep("code"); setStep('code');
}} }}
/> />
) : ( ) : (
<CodeStep email={email} onBack={() => setStep("email")} /> <CodeStep email={email} onBack={() => setStep('email')} />
)} )}
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -40,7 +40,7 @@ export function SignInScreen() {
} }
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) { function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState('');
const isRequestingCode = useAuthStore((s) => s.isRequestingCode); const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
const error = useAuthStore((s) => s.error); const error = useAuthStore((s) => s.error);
const requestCode = useAuthStore((s) => s.requestCode); const requestCode = useAuthStore((s) => s.requestCode);
@@ -88,23 +88,21 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
/> />
</View> </View>
{error ? ( {error ? <Text className="text-destructive text-sm">{error}</Text> : null}
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<Pressable <Pressable
onPress={submit} onPress={submit}
disabled={disabled} disabled={disabled}
className={`rounded-lg px-4 py-3.5 items-center ${ className={`rounded-lg px-4 py-3.5 items-center ${
disabled ? "bg-muted" : "bg-primary" disabled ? 'bg-muted' : 'bg-primary'
}`} }`}
> >
<Text <Text
className={`text-base font-semibold ${ className={`text-base font-semibold ${
disabled ? "text-muted-foreground" : "text-primary-foreground" disabled ? 'text-muted-foreground' : 'text-primary-foreground'
}`} }`}
> >
{isRequestingCode ? "Sending..." : "Continue"} {isRequestingCode ? 'Sending...' : 'Continue'}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -112,7 +110,7 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
} }
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) { function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
const [code, setCode] = useState(""); const [code, setCode] = useState('');
const isSigningIn = useAuthStore((s) => s.isSigningIn); const isSigningIn = useAuthStore((s) => s.isSigningIn);
const error = useAuthStore((s) => s.error); const error = useAuthStore((s) => s.error);
const signIn = useAuthStore((s) => s.signIn); const signIn = useAuthStore((s) => s.signIn);
@@ -135,7 +133,7 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
Check your email Check your email
</Text> </Text>
<Text className="text-muted-foreground text-base"> <Text className="text-muted-foreground text-base">
We sent a code to{" "} We sent a code to{' '}
<Text className="text-foreground font-medium">{email}</Text>. <Text className="text-foreground font-medium">{email}</Text>.
</Text> </Text>
</View> </View>
@@ -159,24 +157,22 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
/> />
</View> </View>
{error ? ( {error ? <Text className="text-destructive text-sm">{error}</Text> : null}
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<View className="gap-2"> <View className="gap-2">
<Pressable <Pressable
onPress={submit} onPress={submit}
disabled={disabled} disabled={disabled}
className={`rounded-lg px-4 py-3.5 items-center ${ className={`rounded-lg px-4 py-3.5 items-center ${
disabled ? "bg-muted" : "bg-primary" disabled ? 'bg-muted' : 'bg-primary'
}`} }`}
> >
<Text <Text
className={`text-base font-semibold ${ className={`text-base font-semibold ${
disabled ? "text-muted-foreground" : "text-primary-foreground" disabled ? 'text-muted-foreground' : 'text-primary-foreground'
}`} }`}
> >
{isSigningIn ? "Signing in..." : "Sign in"} {isSigningIn ? 'Signing in...' : 'Sign in'}
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
@@ -1,16 +1,16 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react';
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Mic } from "lucide-react-native"; import { Mic } from 'lucide-react-native';
import { import {
RecordingPresets, RecordingPresets,
useAudioRecorder, useAudioRecorder,
useAudioRecorderState, useAudioRecorderState,
} from "expo-audio"; } from 'expo-audio';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
import { import {
acquireRecordingAudioSession, acquireRecordingAudioSession,
releaseRecordingAudioSession, releaseRecordingAudioSession,
} from "@/lib/recording-audio-session"; } from '@/lib/recording-audio-session';
const MAX_DURATION_S = 60; const MAX_DURATION_S = 60;
@@ -36,7 +36,7 @@ export function AudioRecordingOverlay({
if (!active) return; if (!active) return;
recorder.record(); recorder.record();
} catch (err) { } catch (err) {
logError(err, { scope: "compose.audio.start" }); logError(err, { scope: 'compose.audio.start' });
if (active) onCancel(); if (active) onCancel();
} }
})(); })();
@@ -48,7 +48,7 @@ export function AudioRecordingOverlay({
recorder.stop().catch(() => {}); recorder.stop().catch(() => {});
} }
void releaseRecordingAudioSession().catch((err) => void releaseRecordingAudioSession().catch((err) =>
logError(err, { scope: "compose.audio.exit" }), logError(err, { scope: 'compose.audio.exit' }),
); );
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -56,23 +56,16 @@ export function AudioRecordingOverlay({
const elapsedMs = state.durationMillis ?? 0; const elapsedMs = state.durationMillis ?? 0;
useEffect(() => { const finish = async (kind: 'commit' | 'cancel') => {
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
void finish("commit");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elapsedMs]);
const finish = async (kind: "commit" | "cancel") => {
if (finalizedRef.current) return; if (finalizedRef.current) return;
finalizedRef.current = true; finalizedRef.current = true;
const durationMs = state.durationMillis ?? 0; const durationMs = state.durationMillis ?? 0;
try { try {
await recorder.stop(); await recorder.stop();
} catch (err) { } catch (err) {
logError(err, { scope: "compose.audio.stop" }); logError(err, { scope: 'compose.audio.stop' });
} }
if (kind === "cancel") { if (kind === 'cancel') {
onCancel(); onCancel();
return; return;
} }
@@ -84,6 +77,14 @@ export function AudioRecordingOverlay({
onComplete({ uri, durationMs }); onComplete({ uri, durationMs });
}; };
// Auto-commit when we hit the max duration.
useEffect(() => {
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
void finish('commit');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elapsedMs]);
const elapsedSec = Math.floor(elapsedMs / 1000); const elapsedSec = Math.floor(elapsedMs / 1000);
return ( return (
@@ -97,22 +98,22 @@ export function AudioRecordingOverlay({
</View> </View>
</View> </View>
<Text className="text-white mt-6 text-lg font-semibold"> <Text className="text-white mt-6 text-lg font-semibold">
{state.isRecording ? "Recording" : "Starting…"} {state.isRecording ? 'Recording' : 'Starting…'}
</Text> </Text>
<Text className="text-white/60 mt-1 text-sm"> <Text className="text-white/60 mt-1 text-sm">
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s {elapsedSec.toString().padStart(2, '0')}s · max {MAX_DURATION_S}s
</Text> </Text>
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8"> <View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable <Pressable
onPress={() => void finish("cancel")} onPress={() => void finish('cancel')}
accessibilityLabel="Cancel recording" accessibilityLabel="Cancel recording"
className="rounded-full bg-white/15 px-6 py-3" className="rounded-full bg-white/15 px-6 py-3"
> >
<Text className="text-white text-base font-medium">Cancel</Text> <Text className="text-white text-base font-medium">Cancel</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => void finish("commit")} onPress={() => void finish('commit')}
accessibilityLabel="Stop recording" accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3" className="rounded-full bg-white px-7 py-3"
> >
+66 -85
View File
@@ -1,43 +1,37 @@
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 { 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 { toast } from 'sonner-native';
import { cn } from '@/lib/utils';
import { useEvent } from '@/hooks/use-event';
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
import { useAuthStore } from '@/stores/auth-store';
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
import type { ParticlePath } from '@/lib/particle-path';
import { import {
useCameraPermissions, useStreamComposingBroadcastOptional,
useMicrophonePermissions,
} from "expo-camera";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { useEvent } from "@/hooks/use-event";
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { useAuthStore } from "@/stores/auth-store";
import {
createTextParticle,
uploadMediaParticle,
} from "@/lib/upload";
import type { ParticlePath } from "@/lib/particle-path";
import {
useStreamComposingBroadcast,
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 { VideoRecordingOverlay } from "./VideoRecordingOverlay"; import { VideoRecordingOverlay } from './VideoRecordingOverlay';
import { AudioRecordingOverlay } from "./AudioRecordingOverlay"; import { AudioRecordingOverlay } from './AudioRecordingOverlay';
import { ReviewSheet } from "./ReviewSheet"; import { ReviewSheet } from './ReviewSheet';
type RecordingMode = "video" | "audio"; type RecordingMode = 'video' | 'audio';
type ComposeUiState = type ComposeUiState =
| { kind: "idle" } | { kind: 'idle' }
| { kind: "recording"; mode: RecordingMode } | { kind: 'recording'; mode: RecordingMode }
| { | {
kind: "review"; kind: 'review';
mode: RecordingMode; mode: RecordingMode;
uri: string; uri: string;
durationMs: number; durationMs: number;
} }
| { | {
kind: "uploading"; kind: 'uploading';
mode: RecordingMode; mode: RecordingMode;
uri: string; uri: string;
durationMs: number; durationMs: number;
@@ -47,7 +41,7 @@ interface SubmitMediaParams {
fileUri: string; fileUri: string;
mimeType: string; mimeType: string;
durationMs: number; durationMs: number;
source: "camera" | "screen"; source: 'camera' | 'screen';
} }
interface ComposeDockProps { interface ComposeDockProps {
@@ -75,8 +69,8 @@ export function ComposeDock({
}: ComposeDockProps) { }: ComposeDockProps) {
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
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 [camPerm, requestCamPerm] = useCameraPermissions(); const [camPerm, requestCamPerm] = useCameraPermissions();
@@ -85,7 +79,7 @@ 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;
useEffect(() => { useEffect(() => {
setComposing(isComposing); setComposing(isComposing);
return () => setComposing(false); return () => setComposing(false);
@@ -98,13 +92,13 @@ export function ComposeDock({
if (forVideo) { if (forVideo) {
const cam = camPerm?.granted ? camPerm : await requestCamPerm(); const cam = camPerm?.granted ? camPerm : await requestCamPerm();
if (!cam.granted) { if (!cam.granted) {
toast.error("Camera permission is required to record video."); toast.error('Camera permission is required to record video.');
return false; return false;
} }
} }
const mic = micPerm?.granted ? micPerm : await requestMicPerm(); const mic = micPerm?.granted ? micPerm : await requestMicPerm();
if (!mic.granted) { if (!mic.granted) {
toast.error("Microphone permission is required to record."); toast.error('Microphone permission is required to record.');
return false; return false;
} }
return true; return true;
@@ -113,46 +107,45 @@ export function ComposeDock({
); );
const startRecording = useEvent(async () => { const startRecording = useEvent(async () => {
if (ui.kind !== "idle") return; if (ui.kind !== 'idle') return;
const ok = await ensurePermissions(mode === "video"); const ok = await ensurePermissions(mode === 'video');
if (!ok) return; if (!ok) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setUi({ kind: "recording", mode }); setUi({ kind: 'recording', mode });
}); });
const handleRecordingComplete = useCallback( const handleRecordingComplete = useCallback(
({ uri, durationMs }: { uri: string; durationMs: number }) => { ({ uri, durationMs }: { uri: string; durationMs: number }) => {
void Haptics.selectionAsync(); void Haptics.selectionAsync();
setUi((prev) => { setUi((prev) => {
const m = "mode" in prev ? prev.mode : mode; const m = 'mode' in prev ? prev.mode : mode;
return { kind: "review", mode: m, uri, durationMs }; return { kind: 'review', mode: m, uri, durationMs };
}); });
}, },
[mode], [mode],
); );
const handleRecordingCancel = useCallback(() => { const handleRecordingCancel = useCallback(() => {
setUi({ kind: "idle" }); setUi({ kind: 'idle' });
}, []); }, []);
const sendReview = useEvent(async () => { const sendReview = useEvent(async () => {
if (ui.kind !== "review" || !userId) return; if (ui.kind !== 'review' || !userId) return;
const captured = ui; const captured = ui;
setUi({ setUi({
kind: "uploading", kind: 'uploading',
mode: captured.mode, mode: captured.mode,
uri: captured.uri, uri: captured.uri,
durationMs: captured.durationMs, durationMs: captured.durationMs,
}); });
try { try {
const mimeType = const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4';
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
if (submitMedia) { if (submitMedia) {
await submitMedia({ await submitMedia({
fileUri: captured.uri, fileUri: captured.uri,
mimeType, mimeType,
durationMs: captured.durationMs, durationMs: captured.durationMs,
source: "camera", source: 'camera',
}); });
} else { } else {
const particleId = await uploadMediaParticle({ const particleId = await uploadMediaParticle({
@@ -161,13 +154,13 @@ export function ComposeDock({
fileUri: captured.uri, fileUri: captured.uri,
mimeType, mimeType,
durationMs: captured.durationMs, durationMs: captured.durationMs,
source: "camera", source: 'camera',
createdByHumanId: userId, createdByHumanId: userId,
}); });
onParticleCreated?.(particleId); onParticleCreated?.(particleId);
} }
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setUi({ kind: "idle" }); setUi({ kind: 'idle' });
} catch (err) { } catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setUi(captured); setUi(captured);
@@ -175,11 +168,11 @@ export function ComposeDock({
} }
}); });
const retake = useCallback(() => setUi({ kind: "idle" }), []); const retake = useCallback(() => setUi({ kind: 'idle' }), []);
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []); const cancelReview = useCallback(() => setUi({ kind: 'idle' }), []);
const submitText = useEvent(async (content: string) => { const submitText = useEvent(async (content: string) => {
if (!userId) throw new Error("Not signed in."); if (!userId) throw new Error('Not signed in.');
if (submitTextOverride) { if (submitTextOverride) {
await submitTextOverride(content); await submitTextOverride(content);
} else { } else {
@@ -195,9 +188,7 @@ export function ComposeDock({
}); });
const dockHidden = const dockHidden =
ui.kind === "review" || ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
ui.kind === "uploading" ||
ui.kind === "recording";
return ( return (
<> <>
@@ -209,18 +200,18 @@ export function ComposeDock({
> >
<Pressable <Pressable
onPress={() => onPress={() =>
setMode((m) => (m === "video" ? "audio" : "video")) setMode((m) => (m === 'video' ? 'audio' : 'video'))
} }
disabled={ui.kind !== "idle"} disabled={ui.kind !== 'idle'}
accessibilityLabel={`Switch to ${ accessibilityLabel={`Switch to ${
mode === "video" ? "audio" : "video" mode === 'video' ? 'audio' : 'video'
} mode`} } mode`}
className={cn( className={cn(
"h-11 w-11 items-center justify-center rounded-full bg-white/15", 'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== "idle" && "opacity-40", ui.kind !== 'idle' && 'opacity-40',
)} )}
> >
{mode === "video" ? ( {mode === 'video' ? (
<VideoIcon color="white" size={20} strokeWidth={1.6} /> <VideoIcon color="white" size={20} strokeWidth={1.6} />
) : ( ) : (
<Mic color="white" size={20} strokeWidth={1.6} /> <Mic color="white" size={20} strokeWidth={1.6} />
@@ -230,24 +221,22 @@ export function ComposeDock({
<View className="items-center"> <View className="items-center">
<Pressable <Pressable
onPress={startRecording} onPress={startRecording}
disabled={ui.kind !== "idle"} disabled={ui.kind !== 'idle'}
accessibilityLabel={`Record ${mode}`} accessibilityLabel={`Record ${mode}`}
className="h-20 w-20 items-center justify-center rounded-full bg-white" className="h-20 w-20 items-center justify-center rounded-full bg-white"
> >
<View className="h-6 w-6 rounded bg-black" /> <View className="h-6 w-6 rounded bg-black" />
</Pressable> </Pressable>
<Text className="text-white/60 mt-2 text-xs"> <Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
Tap to record
</Text>
</View> </View>
<Pressable <Pressable
onPress={() => setTextOpen(true)} onPress={() => setTextOpen(true)}
disabled={ui.kind !== "idle"} disabled={ui.kind !== 'idle'}
accessibilityLabel="Compose text" accessibilityLabel="Compose text"
className={cn( className={cn(
"h-11 w-11 items-center justify-center rounded-full bg-white/15", 'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== "idle" && "opacity-40", ui.kind !== 'idle' && 'opacity-40',
)} )}
> >
<TypeIcon color="white" size={20} strokeWidth={1.6} /> <TypeIcon color="white" size={20} strokeWidth={1.6} />
@@ -256,8 +245,8 @@ export function ComposeDock({
</View> </View>
) : null} ) : null}
{ui.kind === "recording" ? ( {ui.kind === 'recording' ? (
ui.mode === "video" ? ( ui.mode === 'video' ? (
<VideoRecordingOverlay <VideoRecordingOverlay
onComplete={handleRecordingComplete} onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel} onCancel={handleRecordingCancel}
@@ -271,17 +260,13 @@ export function ComposeDock({
) : null} ) : null}
<ReviewSheet <ReviewSheet
open={ui.kind === "review" || ui.kind === "uploading"} open={ui.kind === 'review' || ui.kind === 'uploading'}
uri={ uri={ui.kind === 'review' || ui.kind === 'uploading' ? ui.uri : null}
ui.kind === "review" || ui.kind === "uploading" ? ui.uri : null mode={ui.kind === 'review' || ui.kind === 'uploading' ? ui.mode : null}
}
mode={
ui.kind === "review" || ui.kind === "uploading" ? ui.mode : null
}
durationMs={ durationMs={
ui.kind === "review" || ui.kind === "uploading" ? ui.durationMs : 0 ui.kind === 'review' || ui.kind === 'uploading' ? ui.durationMs : 0
} }
sending={ui.kind === "uploading"} sending={ui.kind === 'uploading'}
onSend={sendReview} onSend={sendReview}
onRetake={retake} onRetake={retake}
onCancel={cancelReview} onCancel={cancelReview}
@@ -305,15 +290,11 @@ function useComposingBroadcast({
textOpen: boolean; textOpen: boolean;
silent: boolean; silent: boolean;
}) { }) {
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null; // null when the dock is rendered outside a stream (no presence provider).
try { const broadcast = useStreamComposingBroadcastOptional();
broadcast = useStreamComposingBroadcast();
} catch {
broadcast = null;
}
const mode: ComposingMode | null = const mode: ComposingMode | null =
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null; ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
useEffect(() => { useEffect(() => {
if (silent || !broadcast) return; if (silent || !broadcast) return;
+101 -101
View File
@@ -1,21 +1,21 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { ActivityIndicator, Modal, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Modal, Pressable, Text, View } from 'react-native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
import { useVideoPlayer, VideoView } from "expo-video"; import { useVideoPlayer, VideoView } from 'expo-video';
import { Mic } from "lucide-react-native"; import { Mic } from 'lucide-react-native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
interface ReviewSheetProps { interface ReviewSheetProps {
open: boolean; open: boolean;
/** Local file URI from the recorder. */ /** Local file URI from the recorder. */
uri: string | null; uri: string | null;
mode: "video" | "audio" | null; mode: 'video' | 'audio' | null;
durationMs: number; durationMs: number;
/** /**
* True once the parent has flipped to the uploading state. The sheet stays * True once the parent has flipped to the uploading state. The sheet stays
@@ -43,10 +43,10 @@ export function ReviewSheet({
onRetake, onRetake,
onCancel, onCancel,
}: ReviewSheetProps) { }: ReviewSheetProps) {
const player = useVideoPlayer(uri ?? "", (p) => { const player = useVideoPlayer(uri ?? '', (p) => {
p.loop = true; p.loop = true;
p.muted = false; p.muted = false;
p.audioMixingMode = "mixWithOthers"; p.audioMixingMode = 'mixWithOthers';
}); });
useEffect(() => { useEffect(() => {
@@ -77,102 +77,102 @@ export function ReviewSheet({
onRequestClose={sending ? undefined : onCancel} onRequestClose={sending ? undefined : onCancel}
> >
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View className="flex-1 bg-black"> <View className="flex-1 bg-black">
{uri ? ( {uri ? (
mode === "audio" ? ( mode === 'audio' ? (
<View className="flex-1 items-center justify-center px-8"> <View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full"> <View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} /> <Mic color="white" size={42} strokeWidth={1.5} />
</View>
<Text className="text-white mt-6 text-lg font-semibold">
Voice message · {seconds}s
</Text>
<Text className="text-white/50 mt-2 text-sm">
Tap send to share, or retake.
</Text>
<View className="absolute" style={{ width: 1, height: 1 }}>
<VideoView
style={{ width: 1, height: 1 }}
player={player}
nativeControls={false}
/>
</View>
</View> </View>
<Text className="text-white mt-6 text-lg font-semibold"> ) : (
Voice message · {seconds}s <VideoView
</Text> style={{ flex: 1 }}
<Text className="text-white/50 mt-2 text-sm"> player={player}
Tap send to share, or retake. nativeControls={false}
</Text> contentFit="cover"
<View className="absolute" style={{ width: 1, height: 1 }}> allowsFullscreen={false}
<VideoView allowsPictureInPicture={false}
style={{ width: 1, height: 1 }} />
player={player} )
nativeControls={false} ) : null}
/>
</View>
</View>
) : (
<VideoView
style={{ flex: 1 }}
player={player}
nativeControls={false}
contentFit="cover"
allowsFullscreen={false}
allowsPictureInPicture={false}
/>
)
) : null}
<SafeAreaView <SafeAreaView
edges={["top"]} edges={['top']}
className="absolute top-0 left-0 right-0" className="absolute top-0 left-0 right-0"
> >
<View className="px-4 pt-3"> <View className="px-4 pt-3">
<Pressable <Pressable
onPress={onCancel} onPress={onCancel}
disabled={sending} disabled={sending}
hitSlop={12} hitSlop={12}
accessibilityLabel="Cancel" accessibilityLabel="Cancel"
>
<Text
className={cn(
"text-base",
sending ? "text-white/30" : "text-white/80",
)}
> >
Cancel <Text
</Text> className={cn(
</Pressable> 'text-base',
</View> sending ? 'text-white/30' : 'text-white/80',
</SafeAreaView> )}
>
Cancel
</Text>
</Pressable>
</View>
</SafeAreaView>
<SafeAreaView <SafeAreaView
edges={["bottom"]} edges={['bottom']}
className="absolute bottom-0 left-0 right-0" className="absolute bottom-0 left-0 right-0"
> >
<View className="flex-row items-center justify-between px-6 pb-4 pt-3"> <View className="flex-row items-center justify-between px-6 pb-4 pt-3">
<Pressable <Pressable
onPress={onRetake} onPress={onRetake}
disabled={sending} disabled={sending}
className={cn( className={cn(
"rounded-full bg-white/15 px-5 py-3", 'rounded-full bg-white/15 px-5 py-3',
sending && "opacity-40", sending && 'opacity-40',
)} )}
accessibilityLabel="Retake" accessibilityLabel="Retake"
> >
<Text className="text-white text-base font-medium">Retake</Text> <Text className="text-white text-base font-medium">Retake</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleSend} onPress={handleSend}
disabled={sending} disabled={sending}
className={cn( className={cn(
"rounded-full px-7 py-3", 'rounded-full px-7 py-3',
sending ? "bg-white/40" : "bg-white", sending ? 'bg-white/40' : 'bg-white',
)} )}
accessibilityLabel="Send" accessibilityLabel="Send"
> >
<Text className="text-black text-base font-semibold"> <Text className="text-black text-base font-semibold">
{sending ? "Sending..." : "Send"} {sending ? 'Sending...' : 'Send'}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
</SafeAreaView> </SafeAreaView>
{sending ? ( {sending ? (
<View className="absolute inset-0 items-center justify-center bg-black/85"> <View className="absolute inset-0 items-center justify-center bg-black/85">
<ActivityIndicator color="white" /> <ActivityIndicator color="white" />
<Text className="text-white/70 mt-4 text-sm">Sending...</Text> <Text className="text-white/70 mt-4 text-sm">Sending...</Text>
</View> </View>
) : null} ) : null}
</View> </View>
</SafeAreaProvider> </SafeAreaProvider>
</Modal> </Modal>
); );
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Modal, Modal,
@@ -7,26 +7,23 @@ import {
Text, Text,
TextInput, TextInput,
View, View,
} from "react-native"; } from 'react-native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
const IMMERSIVE_CHAR_LIMIT = 120; const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveStyle(length: number) { function getImmersiveStyle(length: number) {
if (length === 0) if (length === 0) return { className: 'text-3xl font-semibold leading-snug' };
return { className: "text-3xl font-semibold leading-snug" }; if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
if (length < 30) if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
return { className: "text-5xl font-semibold leading-tight" }; return { className: 'text-2xl font-normal leading-snug' };
if (length < 70)
return { className: "text-3xl font-semibold leading-snug" };
return { className: "text-2xl font-normal leading-snug" };
} }
interface TextComposeModalProps { interface TextComposeModalProps {
@@ -50,20 +47,26 @@ export function TextComposeModal({
onClose, onClose,
onSubmit, onSubmit,
}: TextComposeModalProps) { }: TextComposeModalProps) {
const [content, setContent] = useState(""); const [content, setContent] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
// Reset whenever the modal opens fresh. // Reset whenever the modal opens fresh.
useEffect(() => { const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) { if (open) {
setContent(""); setContent('');
setSubmitting(false); setSubmitting(false);
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
// the modal animation is mid-flight.
const t = setTimeout(() => inputRef.current?.focus(), 60);
return () => clearTimeout(t);
} }
}
// Re-focus on next tick; iOS occasionally drops the autoFocus call when the
// modal animation is mid-flight.
useEffect(() => {
if (!open) return;
const t = setTimeout(() => inputRef.current?.focus(), 60);
return () => clearTimeout(t);
}, [open]); }, [open]);
const trimmed = content.trim(); const trimmed = content.trim();
@@ -92,60 +95,60 @@ export function TextComposeModal({
onRequestClose={onClose} onRequestClose={onClose}
> >
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}> <SafeAreaView className="flex-1 bg-black" edges={['top', 'bottom']}>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1" className="flex-1"
> >
<View className="flex-row items-center justify-between px-4 py-3"> <View className="flex-row items-center justify-between px-4 py-3">
<Pressable <Pressable
onPress={onClose} onPress={onClose}
accessibilityLabel="Cancel" accessibilityLabel="Cancel"
hitSlop={12} hitSlop={12}
>
<Text className="text-white/70 text-base">Cancel</Text>
</Pressable>
<Pressable
onPress={handleSend}
disabled={!canSend}
hitSlop={12}
accessibilityLabel="Send"
>
<Text
className={cn(
"text-base font-semibold",
canSend ? "text-white" : "text-white/30",
)}
> >
{submitting ? "Sending..." : "Send"} <Text className="text-white/70 text-base">Cancel</Text>
</Text> </Pressable>
</Pressable> <Pressable
</View> onPress={handleSend}
disabled={!canSend}
hitSlop={12}
accessibilityLabel="Send"
>
<Text
className={cn(
'text-base font-semibold',
canSend ? 'text-white' : 'text-white/30',
)}
>
{submitting ? 'Sending...' : 'Send'}
</Text>
</Pressable>
</View>
<View className="flex-1 justify-center px-6 pb-6"> <View className="flex-1 justify-center px-6 pb-6">
<TextInput <TextInput
ref={inputRef} ref={inputRef}
value={content} value={content}
onChangeText={setContent} onChangeText={setContent}
placeholder="Type a message" placeholder="Type a message"
placeholderTextColor="rgba(255,255,255,0.4)" placeholderTextColor="rgba(255,255,255,0.4)"
multiline multiline
autoFocus autoFocus
autoCorrect autoCorrect
autoCapitalize="sentences" autoCapitalize="sentences"
editable={!submitting} editable={!submitting}
scrollEnabled={!isImmersive} scrollEnabled={!isImmersive}
textAlignVertical={isImmersive ? "center" : "top"} textAlignVertical={isImmersive ? 'center' : 'top'}
style={{ style={{
color: "white", color: 'white',
textAlign: isImmersive ? "center" : "left", textAlign: isImmersive ? 'center' : 'left',
maxHeight: isImmersive ? undefined : 540, maxHeight: isImmersive ? undefined : 540,
}} }}
className={cn("text-white", style.className)} className={cn('text-white', style.className)}
/> />
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
</SafeAreaProvider> </SafeAreaProvider>
</Modal> </Modal>
); );
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { CameraView, type CameraType } from "expo-camera"; import { CameraView, type CameraType } from 'expo-camera';
import { SwitchCamera } from "lucide-react-native"; import { SwitchCamera } from 'lucide-react-native';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
import { import {
acquireRecordingAudioSession, acquireRecordingAudioSession,
releaseRecordingAudioSession, releaseRecordingAudioSession,
} from "@/lib/recording-audio-session"; } from '@/lib/recording-audio-session';
const MAX_DURATION_S = 60; const MAX_DURATION_S = 60;
const VIDEO_BITRATE_BPS = 1_200_000; const VIDEO_BITRATE_BPS = 1_200_000;
@@ -24,7 +24,7 @@ export function VideoRecordingOverlay({
const [cameraReady, setCameraReady] = useState(false); const [cameraReady, setCameraReady] = useState(false);
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0); const [elapsedMs, setElapsedMs] = useState(0);
const [facing, setFacing] = useState<CameraType>("front"); const [facing, setFacing] = useState<CameraType>('front');
const startedAtRef = useRef<number | null>(null); const startedAtRef = useRef<number | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
@@ -32,7 +32,7 @@ export function VideoRecordingOverlay({
return () => { return () => {
cancelledRef.current = true; cancelledRef.current = true;
void releaseRecordingAudioSession().catch((err) => void releaseRecordingAudioSession().catch((err) =>
logError(err, { scope: "compose.video.exit" }), logError(err, { scope: 'compose.video.exit' }),
); );
}; };
}, []); }, []);
@@ -44,7 +44,7 @@ export function VideoRecordingOverlay({
try { try {
await acquireRecordingAudioSession(); await acquireRecordingAudioSession();
} catch (err) { } catch (err) {
logError(err, { scope: "compose.video.audioSession" }); logError(err, { scope: 'compose.video.audioSession' });
onCancel(); onCancel();
return; return;
} }
@@ -56,16 +56,16 @@ export function VideoRecordingOverlay({
try { try {
result = await cam.recordAsync({ result = await cam.recordAsync({
maxDuration: MAX_DURATION_S, maxDuration: MAX_DURATION_S,
...(Platform.OS === "ios" ? { codec: "hvc1" as const } : {}), ...(Platform.OS === 'ios' ? { codec: 'hvc1' as const } : {}),
}); });
} catch (err) { } catch (err) {
if (cancelledRef.current) return; if (cancelledRef.current) return;
logError(err, { scope: "compose.video.recordAsync" }); logError(err, { scope: 'compose.video.recordAsync' });
onCancel(); onCancel();
return; return;
} finally { } finally {
void releaseRecordingAudioSession().catch((err) => void releaseRecordingAudioSession().catch((err) =>
logError(err, { scope: "compose.video.release" }), logError(err, { scope: 'compose.video.release' }),
); );
} }
if (cancelledRef.current) return; if (cancelledRef.current) return;
@@ -118,9 +118,7 @@ export function VideoRecordingOverlay({
{!recording && cameraReady ? ( {!recording && cameraReady ? (
<View className="absolute top-0 right-0 pt-14 pr-5"> <View className="absolute top-0 right-0 pt-14 pr-5">
<Pressable <Pressable
onPress={() => onPress={() => setFacing((f) => (f === 'front' ? 'back' : 'front'))}
setFacing((f) => (f === "front" ? "back" : "front"))
}
accessibilityLabel="Flip camera" accessibilityLabel="Flip camera"
className="h-11 w-11 items-center justify-center rounded-full bg-white/15" className="h-11 w-11 items-center justify-center rounded-full bg-white/15"
> >
@@ -137,7 +135,7 @@ export function VideoRecordingOverlay({
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5"> <View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
<View className="h-2 w-2 rounded-full bg-white" /> <View className="h-2 w-2 rounded-full bg-white" />
<Text className="text-white text-xs font-semibold tracking-wide"> <Text className="text-white text-xs font-semibold tracking-wide">
REC · {elapsedSec.toString().padStart(2, "0")}s REC · {elapsedSec.toString().padStart(2, '0')}s
</Text> </Text>
</View> </View>
</View> </View>
@@ -166,8 +164,8 @@ export function VideoRecordingOverlay({
accessibilityLabel="Start recording" accessibilityLabel="Start recording"
className={ className={
cameraReady cameraReady
? "h-20 w-20 items-center justify-center rounded-full bg-white" ? 'h-20 w-20 items-center justify-center rounded-full bg-white'
: "h-20 w-20 items-center justify-center rounded-full bg-white/40" : 'h-20 w-20 items-center justify-center rounded-full bg-white/40'
} }
> >
<View className="h-16 w-16 rounded-full bg-red-500" /> <View className="h-16 w-16 rounded-full bg-red-500" />
+38 -44
View File
@@ -1,14 +1,8 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from 'react';
import { import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
Alert, import type { Human } from '@/api/types';
Dimensions, import { SafeAreaView } from 'react-native-safe-area-context';
Pressable, import { StatusBar } from 'expo-status-bar';
Text,
View,
} from "react-native";
import type { Human } from "@/api/types";
import { SafeAreaView } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { import {
AudioSession, AudioSession,
LiveKitRoom, LiveKitRoom,
@@ -17,14 +11,14 @@ import {
useLocalParticipant, useLocalParticipant,
useRoomContext, useRoomContext,
useTracks, useTracks,
} from "@livekit/react-native"; } from '@livekit/react-native';
import type { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
import { Track } from "livekit-client"; import { Track } from 'livekit-client';
import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native"; import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide-react-native';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import type { RootStackScreenProps } from "@/navigation/types"; import type { RootStackScreenProps } from '@/navigation/types';
/** /**
* Mobile huddle screen LiveKit room with a tile grid, basic mic/camera * Mobile huddle screen LiveKit room with a tile grid, basic mic/camera
@@ -35,7 +29,7 @@ import type { RootStackScreenProps } from "@/navigation/types";
export function HuddleScreen({ export function HuddleScreen({
route, route,
navigation, navigation,
}: RootStackScreenProps<"Huddle">) { }: RootStackScreenProps<'Huddle'>) {
const { token, serverUrl, streamName, networkId } = route.params; const { token, serverUrl, streamName, networkId } = route.params;
// iOS in particular requires us to bracket the room session with // iOS in particular requires us to bracket the room session with
@@ -66,10 +60,10 @@ export function HuddleScreen({
connect={true} connect={true}
audio={true} audio={true}
video={false} video={false}
options={{ adaptiveStream: { pixelDensity: "screen" } }} options={{ adaptiveStream: { pixelDensity: 'screen' } }}
onDisconnected={leave} onDisconnected={leave}
onError={(err) => { onError={(err) => {
Alert.alert("Huddle error", err.message ?? "Failed to connect."); Alert.alert('Huddle error', err.message ?? 'Failed to connect.');
leave(); leave();
}} }}
> >
@@ -119,15 +113,18 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
}, [room, onLeave]); }, [room, onLeave]);
return ( return (
<SafeAreaView className="flex-1" edges={["top", "bottom"]}> <SafeAreaView className="flex-1" edges={['top', 'bottom']}>
<View className="flex-row items-center justify-between px-4 pt-2 pb-3"> <View className="flex-row items-center justify-between px-4 pt-2 pb-3">
<View className="flex-1"> <View className="flex-1">
<Text className="text-white text-base font-semibold" numberOfLines={1}> <Text
className="text-white text-base font-semibold"
numberOfLines={1}
>
{streamName} {streamName}
</Text> </Text>
<Text className="text-white/60 text-xs mt-0.5"> <Text className="text-white/60 text-xs mt-0.5">
{tracks.length === 1 {tracks.length === 1
? "1 participant" ? '1 participant'
: `${tracks.length} participants`} : `${tracks.length} participants`}
</Text> </Text>
</View> </View>
@@ -139,7 +136,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
<View className="flex-row items-center justify-center gap-4 px-4 py-4"> <View className="flex-row items-center justify-center gap-4 px-4 py-4">
<ControlButton <ControlButton
label={isMicrophoneEnabled ? "Mute" : "Unmute"} label={isMicrophoneEnabled ? 'Mute' : 'Unmute'}
active={isMicrophoneEnabled} active={isMicrophoneEnabled}
onPress={toggleMic} onPress={toggleMic}
icon={ icon={
@@ -151,7 +148,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
} }
/> />
<ControlButton <ControlButton
label={isCameraEnabled ? "Stop video" : "Start video"} label={isCameraEnabled ? 'Stop video' : 'Start video'}
active={isCameraEnabled} active={isCameraEnabled}
onPress={toggleCamera} onPress={toggleCamera}
icon={ icon={
@@ -182,7 +179,7 @@ function TileGrid({ tiles, humans }: TileGridProps) {
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col, // Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen. // 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
const columns = tiles.length <= 1 ? 1 : 2; const columns = tiles.length <= 1 ? 1 : 2;
const { width, height } = Dimensions.get("window"); const { width, height } = Dimensions.get('window');
const rows = Math.max(1, Math.ceil(tiles.length / columns)); const rows = Math.max(1, Math.ceil(tiles.length / columns));
const tileWidth = (width - 16) / columns - 8; const tileWidth = (width - 16) / columns - 8;
// Subtract approx chrome height (header + control bar ≈ 200px). This is a // Subtract approx chrome height (header + control bar ≈ 200px). This is a
@@ -229,16 +226,12 @@ function Tile({
return ( return (
<View <View
className={cn( className={cn(
"flex-1 overflow-hidden rounded-2xl bg-neutral-900", 'flex-1 overflow-hidden rounded-2xl bg-neutral-900',
isSpeaking && "border-2 border-emerald-400", isSpeaking && 'border-2 border-emerald-400',
)} )}
> >
{hasVideo ? ( {hasVideo ? (
<VideoTrack <VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
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"> <View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
@@ -261,7 +254,9 @@ function Tile({
} }
function trackKey(tile: TrackReferenceOrPlaceholder): string { function trackKey(tile: TrackReferenceOrPlaceholder): string {
const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder"; const sid = isTrackReference(tile)
? tile.publication.trackSid
: 'placeholder';
return `${tile.participant.identity}:${tile.source}:${sid}`; return `${tile.participant.identity}:${tile.source}:${sid}`;
} }
@@ -270,7 +265,7 @@ interface ControlButtonProps {
label: string; label: string;
onPress: () => void; onPress: () => void;
active?: boolean; active?: boolean;
tone?: "default" | "danger"; tone?: 'default' | 'danger';
} }
function ControlButton({ function ControlButton({
@@ -278,7 +273,7 @@ function ControlButton({
label, label,
onPress, onPress,
active = false, active = false,
tone = "default", tone = 'default',
}: ControlButtonProps) { }: ControlButtonProps) {
// Used purely for the visual state — destructive tone always wins so // Used purely for the visual state — destructive tone always wins so
// "Leave" is unmistakable regardless of toggle state. // "Leave" is unmistakable regardless of toggle state.
@@ -287,16 +282,15 @@ function ControlButton({
onPress={onPress} onPress={onPress}
accessibilityLabel={label} accessibilityLabel={label}
className={cn( className={cn(
"h-14 w-14 items-center justify-center rounded-full", 'h-14 w-14 items-center justify-center rounded-full',
tone === "danger" tone === 'danger'
? "bg-red-600 active:bg-red-700" ? 'bg-red-600 active:bg-red-700'
: active : active
? "bg-white/20 active:bg-white/30" ? 'bg-white/20 active:bg-white/30'
: "bg-white/10 active:bg-white/20", : 'bg-white/10 active:bg-white/20',
)} )}
> >
{icon} {icon}
</Pressable> </Pressable>
); );
} }
@@ -1,10 +1,10 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from '@react-navigation/native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import type { RootStackParamList } from "@/navigation/types"; import type { RootStackParamList } from '@/navigation/types';
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
/** /**
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) fetch a fresh * Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) fetch a fresh
@@ -26,7 +26,7 @@ export function useOpenHuddle() {
networkId, networkId,
streamId, streamId,
); );
navigation.navigate("Huddle", { navigation.navigate('Huddle', {
networkId, networkId,
streamId, streamId,
streamName, streamName,
+75 -77
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useState } from 'react';
import { import {
Animated, Animated,
Dimensions, Dimensions,
@@ -7,15 +7,15 @@ import {
Pressable, Pressable,
Text, Text,
View, View,
} from "react-native"; } from 'react-native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
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;
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82)); const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
const ANIM_MS = 220; const ANIM_MS = 220;
@@ -26,13 +26,11 @@ interface DrawerProps {
onNavigateSettings: () => void; onNavigateSettings: () => void;
} }
export function Drawer({ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
open, // Lazy-init so each Animated.Value is created once; the setters are never
onClose, // called — the values are mutated internally by the native driver.
onNavigateAccount, const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
}: DrawerProps) { const [backdropOpacity] = useState(() => new Animated.Value(0));
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => { useEffect(() => {
Animated.parallel([ Animated.parallel([
@@ -55,7 +53,7 @@ export function Drawer({
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() ?? "??"; const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
return ( return (
<Modal <Modal
@@ -69,68 +67,68 @@ export function Drawer({
inside reports {0,0,0,0} on the first frame and content snaps from inside reports {0,0,0,0} on the first frame and content snaps from
the status bar down to the safe area once metrics resolve. */} the status bar down to the safe area once metrics resolve. */}
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View className="flex-1"> <View className="flex-1">
<Animated.View <Animated.View
pointerEvents={open ? "auto" : "none"} pointerEvents={open ? 'auto' : 'none'}
style={{ opacity: backdropOpacity }} style={{ opacity: backdropOpacity }}
className="absolute inset-0 bg-black" className="absolute inset-0 bg-black"
> >
<Pressable className="flex-1" onPress={onClose} /> <Pressable className="flex-1" onPress={onClose} />
</Animated.View> </Animated.View>
<Animated.View <Animated.View
style={{ style={{
width: DRAWER_WIDTH, width: DRAWER_WIDTH,
transform: [{ translateX }], transform: [{ translateX }],
}} }}
className="absolute left-0 top-0 bottom-0 bg-sidebar" className="absolute left-0 top-0 bottom-0 bg-sidebar"
> >
<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"> <View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
<Text className="text-sidebar-accent-foreground text-sm font-semibold"> <Text className="text-sidebar-accent-foreground text-sm font-semibold">
{initials} {initials}
</Text> </Text>
</View>
<View className="flex-1">
<Text
className="text-sidebar-foreground text-base font-medium"
numberOfLines={1}
>
{user?.email_prefix ?? ''}
</Text>
<Text
className="text-muted-foreground text-xs"
numberOfLines={1}
>
{user?.email ?? ''}
</Text>
</View>
</View> </View>
<View className="flex-1">
<Text <View className="flex-1 py-2">
className="text-sidebar-foreground text-base font-medium" <DrawerRow
numberOfLines={1} label="Account"
> onPress={() => {
{user?.email_prefix ?? ""} onClose();
</Text> onNavigateAccount();
<Text }}
className="text-muted-foreground text-xs" />
numberOfLines={1}
>
{user?.email ?? ""}
</Text>
</View> </View>
</View>
<View className="flex-1 py-2"> <View className="border-sidebar-border border-t px-2 py-2">
<DrawerRow <DrawerRow
label="Account" label={isSigningOut ? 'Signing out...' : 'Sign out'}
onPress={() => { disabled={isSigningOut}
onClose(); onPress={() => {
onNavigateAccount(); void signOut();
}} }}
/> tone="destructive"
</View> />
</View>
<View className="border-sidebar-border border-t px-2 py-2"> </SafeAreaView>
<DrawerRow </Animated.View>
label={isSigningOut ? "Signing out..." : "Sign out"} </View>
disabled={isSigningOut}
onPress={() => {
void signOut();
}}
tone="destructive"
/>
</View>
</SafeAreaView>
</Animated.View>
</View>
</SafeAreaProvider> </SafeAreaProvider>
</Modal> </Modal>
); );
@@ -140,12 +138,12 @@ function DrawerRow({
label, label,
onPress, onPress,
disabled, disabled,
tone = "default", tone = 'default',
}: { }: {
label: string; label: string;
onPress: () => void; onPress: () => void;
disabled?: boolean; disabled?: boolean;
tone?: "default" | "destructive"; tone?: 'default' | 'destructive';
}) { }) {
return ( return (
<Pressable <Pressable
@@ -155,10 +153,10 @@ function DrawerRow({
> >
<Text <Text
className={`text-base font-medium ${ className={`text-base font-medium ${
tone === "destructive" tone === 'destructive'
? "text-destructive" ? 'text-destructive'
: "text-sidebar-foreground" : 'text-sidebar-foreground'
} ${disabled ? "opacity-50" : ""}`} } ${disabled ? 'opacity-50' : ''}`}
> >
{label} {label}
</Text> </Text>
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
@@ -6,24 +6,24 @@ import {
RefreshControl, RefreshControl,
Text, Text,
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 type { Network } from '@/api/types';
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from '@/hooks/use-networks';
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 { 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';
export function NetworkListScreen({ export function NetworkListScreen({
navigation, navigation,
}: RootStackScreenProps<"NetworkList">) { }: RootStackScreenProps<'NetworkList'>) {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const { data, isLoading, refetch, error } = useNetworks(); const { data, isLoading, refetch, error } = useNetworks();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??"; 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
@@ -39,7 +39,7 @@ export function NetworkListScreen({
}, [refetch]); }, [refetch]);
return ( return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}> <SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border"> <View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Pressable <Pressable
onPress={() => setDrawerOpen(true)} onPress={() => setDrawerOpen(true)}
@@ -81,7 +81,7 @@ export function NetworkListScreen({
<NetworkCard <NetworkCard
network={item} network={item}
onPress={() => onPress={() =>
navigation.navigate("StreamList", { networkId: item.id }) navigation.navigate('StreamList', { networkId: item.id })
} }
/> />
)} )}
@@ -91,8 +91,8 @@ export function NetworkListScreen({
<Drawer <Drawer
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
onNavigateAccount={() => navigation.navigate("Account")} onNavigateAccount={() => navigation.navigate('Account')}
onNavigateSettings={() => navigation.navigate("Settings")} onNavigateSettings={() => navigation.navigate('Settings')}
/> />
</SafeAreaView> </SafeAreaView>
); );
@@ -115,8 +115,8 @@ function NetworkCard({
{network.name} {network.name}
</Text> </Text>
<Text className="text-muted-foreground text-sm"> <Text className="text-muted-foreground text-sm">
{network.humans.length}{" "} {network.humans.length}{' '}
{network.humans.length === 1 ? "member" : "members"} {network.humans.length === 1 ? 'member' : 'members'}
</Text> </Text>
</View> </View>
<Text className="text-muted-foreground text-xl"></Text> <Text className="text-muted-foreground text-xl"></Text>
@@ -128,7 +128,7 @@ function EmptyState() {
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 arent 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. Ask a friend for an invite, or create one on desktop.
@@ -1,13 +1,13 @@
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
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);
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">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1"> <Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text> <Text className="text-foreground text-2xl"></Text>
@@ -19,7 +19,7 @@ export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
</View> </View>
<View className="px-6 py-6 gap-4"> <View className="px-6 py-6 gap-4">
<Field label="Email" value={user?.email ?? "—"} /> <Field label="Email" value={user?.email ?? '—'} />
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
@@ -1,12 +1,12 @@
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from 'react-native-safe-area-context';
import type { RootStackScreenProps } from "@/navigation/types"; import type { RootStackScreenProps } from '@/navigation/types';
export function SettingsScreen({ export function SettingsScreen({
navigation, navigation,
}: RootStackScreenProps<"Settings">) { }: RootStackScreenProps<'Settings'>) {
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">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1"> <Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text> <Text className="text-foreground text-2xl"></Text>
@@ -1,9 +1,9 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import { Trash2 } from "lucide-react-native"; import { Trash2 } 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';
// How long to linger on a tombstone before auto-advancing. Same cadence as // How long to linger on a tombstone before auto-advancing. Same cadence as
// desktop — a beat long enough to read "this was deleted," not so long it // desktop — a beat long enough to read "this was deleted," not so long it
@@ -25,7 +25,9 @@ export function DeletedParticleView({
}: DeletedParticleViewProps) { }: DeletedParticleViewProps) {
const network = useNetwork(networkId); const network = useNetwork(networkId);
const deleterId = const deleterId =
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined; 'deleted_by_human_id' in particle
? particle.deleted_by_human_id
: undefined;
const deleter = deleterId const deleter = deleterId
? resolveHumanDisplay(deleterId, network?.humans) ? resolveHumanDisplay(deleterId, network?.humans)
: null; : null;
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react"; import { useState } from 'react';
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { editTextParticleContent } from "@/lib/firestore-particles"; import { editTextParticleContent } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { BottomSheet } from "@/components/BottomSheet"; import { BottomSheet } from '@/components/BottomSheet';
interface EditParticleSheetProps { interface EditParticleSheetProps {
open: boolean; open: boolean;
@@ -25,17 +25,20 @@ export function EditParticleSheet({
particleId, particleId,
currentContent, currentContent,
}: EditParticleSheetProps) { }: EditParticleSheetProps) {
useSuspendPlayback(open, "edit-particle"); useSuspendPlayback(open, 'edit-particle');
const [content, setContent] = useState(currentContent); const [content, setContent] = useState(currentContent);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { // Reset the editor each time the sheet opens fresh.
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) { if (open) {
setContent(currentContent); setContent(currentContent);
setSaving(false); setSaving(false);
} }
}, [open, currentContent]); }
const trimmed = content.trim(); const trimmed = content.trim();
const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent; const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent;
@@ -65,11 +68,11 @@ export function EditParticleSheet({
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}> <Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
<Text <Text
className={cn( className={cn(
"text-base font-semibold", 'text-base font-semibold',
canSave ? "text-white" : "text-white/30", canSave ? 'text-white' : 'text-white/30',
)} )}
> >
{saving ? "Saving..." : "Save"} {saving ? 'Saving...' : 'Save'}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -1,20 +1,20 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import { import {
FileIcon, FileIcon,
HelpCircle, HelpCircle,
ScrollText, ScrollText,
BookOpen, BookOpen,
type LucideIcon, type LucideIcon,
} from "lucide-react-native"; } 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 }> = { const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
quest: { icon: ScrollText, label: "Quest" }, quest: { icon: ScrollText, label: 'Quest' },
paper: { icon: BookOpen, label: "Paper" }, paper: { icon: BookOpen, label: 'Paper' },
file: { icon: FileIcon, label: "File" }, file: { icon: FileIcon, label: 'File' },
}; };
const PLACEHOLDER_DURATION_MS = 5000; const PLACEHOLDER_DURATION_MS = 5000;
@@ -44,13 +44,13 @@ export function FallbackParticleView({
const Icon = meta.icon; const Icon = meta.icon;
const title = (() => { const title = (() => {
switch (particle.type) { switch (particle.type) {
case "quest": case 'quest':
return particle.properties.title; return particle.properties.title;
case "paper": case 'paper':
return particle.properties.title; return particle.properties.title;
case "file": case 'file':
return particle.properties.filename; return particle.properties.filename;
case "folder": case 'folder':
return particle.properties.name; return particle.properties.name;
default: default:
return null; return null;
@@ -1,17 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from 'react-native';
import { Mic, Video as VideoIcon } from "lucide-react-native"; import { Mic, Video as VideoIcon } from 'lucide-react-native';
import { useEventListener } from "expo"; import { useEventListener } from 'expo';
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video"; import { useVideoPlayer, VideoView, type VideoPlayerStatus } from 'expo-video';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
import { useEvent } from "@/hooks/use-event"; import { useEvent } from '@/hooks/use-event';
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback"; import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
import { TranscriptOverlay } from "./TranscriptOverlay"; import { TranscriptOverlay } from './TranscriptOverlay';
import { useStreamSafeArea } from "./stream-safe-area"; import { useStreamSafeArea } from './stream-safe-area';
type MediaParticle = Extract<Particle, { type: "media" }>; type MediaParticle = Extract<Particle, { type: 'media' }>;
interface MediaParticleViewProps { interface MediaParticleViewProps {
particle: MediaParticle; particle: MediaParticle;
@@ -19,7 +19,7 @@ interface MediaParticleViewProps {
onEnded: () => void; onEnded: () => void;
onProgress: (ratio: number) => void; onProgress: (ratio: number) => void;
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */ /** "cover" fills the screen (may crop); "contain" fits the whole frame. */
contentFit?: "cover" | "contain"; contentFit?: 'cover' | 'contain';
} }
const TICK_MS = 150; const TICK_MS = 150;
@@ -42,13 +42,13 @@ export function MediaParticleView({
paused, paused,
onEnded, onEnded,
onProgress, onProgress,
contentFit = "cover", contentFit = 'cover',
}: MediaParticleViewProps) { }: MediaParticleViewProps) {
const activeObjectId = const activeObjectId =
particle.properties.transcoded_object_id ?? particle.properties.object_id; particle.properties.transcoded_object_id ?? particle.properties.object_id;
const activeMime = const activeMime =
particle.properties.transcoded_mime_type ?? particle.properties.mime_type; particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
const isAudio = activeMime.startsWith("audio/"); const isAudio = activeMime.startsWith('audio/');
const isPlayable = isPlayableMime(activeMime); const isPlayable = isPlayableMime(activeMime);
// Reset progress as the active particle changes — independent of playback // Reset progress as the active particle changes — independent of playback
@@ -89,7 +89,7 @@ function PlayableMediaView({
paused: boolean; paused: boolean;
onEnded: () => void; onEnded: () => void;
onProgress: (ratio: number) => void; onProgress: (ratio: number) => void;
contentFit: "cover" | "contain"; contentFit: 'cover' | 'contain';
}) { }) {
const [sourceUri, setSourceUri] = useState<string | null>(null); const [sourceUri, setSourceUri] = useState<string | null>(null);
const [resolveError, setResolveError] = useState<Error | null>(null); const [resolveError, setResolveError] = useState<Error | null>(null);
@@ -101,24 +101,26 @@ function PlayableMediaView({
// resolves a new active object id. // resolves a new active object id.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setSourceUri(null);
setResolveError(null);
setCurrentTime(0);
apiClient apiClient
.getParticleDownloadUrl(activeObjectId) .getParticleDownloadUrl(activeObjectId)
.then((url) => { .then((url) => {
if (!cancelled) setSourceUri(url); if (!cancelled) setSourceUri(url);
}) })
.catch((err) => { .catch((err) => {
logError(err, { scope: "media.download-url" }); logError(err, { scope: 'media.download-url' });
if (!cancelled) setResolveError(err as Error); if (!cancelled) setResolveError(err as Error);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
// Reset on teardown so the next object starts from a clean slate while
// its signed URL resolves, rather than flashing the previous video.
setSourceUri(null);
setResolveError(null);
setCurrentTime(0);
}; };
}, [activeObjectId, particle.id]); }, [activeObjectId, particle.id]);
const player = useVideoPlayer(sourceUri ?? "", (p) => { const player = useVideoPlayer(sourceUri ?? '', (p) => {
p.loop = false; p.loop = false;
p.muted = false; p.muted = false;
p.timeUpdateEventInterval = 0.15; p.timeUpdateEventInterval = 0.15;
@@ -126,7 +128,7 @@ function PlayableMediaView({
// the player blocks expo-camera from acquiring the session for video // the player blocks expo-camera from acquiring the session for video
// recording (audio works because expo-audio deactivates other sessions // recording (audio works because expo-audio deactivates other sessions
// natively before claiming the session). // natively before claiming the session).
p.audioMixingMode = "mixWithOthers"; p.audioMixingMode = 'mixWithOthers';
}); });
// Drive play/pause from the suspender store. The player itself is forgiving // Drive play/pause from the suspender store. The player itself is forgiving
@@ -142,8 +144,8 @@ function PlayableMediaView({
// End-of-clip → advance. We listen to status flips rather than computing // End-of-clip → advance. We listen to status flips rather than computing
// duration ratios because video duration may be 0 for the first frame or two. // duration ratios because video duration may be 0 for the first frame or two.
useEventListener(player, "statusChange", ({ status }) => { useEventListener(player, 'statusChange', ({ status }) => {
if (status === ("idle" satisfies VideoPlayerStatus)) { if (status === ('idle' satisfies VideoPlayerStatus)) {
// ignored — happens during source swap // ignored — happens during source swap
} }
}); });
@@ -151,7 +153,7 @@ function PlayableMediaView({
// Drive caption highlighting from the player's own timeUpdate cadence // Drive caption highlighting from the player's own timeUpdate cadence
// (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which // (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which
// naturally freezes the active word/sentence — no extra plumbing needed. // naturally freezes the active word/sentence — no extra plumbing needed.
useEventListener(player, "timeUpdate", ({ currentTime: t }) => { useEventListener(player, 'timeUpdate', ({ currentTime: t }) => {
setCurrentTime(t); setCurrentTime(t);
}); });
@@ -190,7 +192,7 @@ function PlayableMediaView({
return ( return (
<View className="flex-1 items-center justify-center px-8"> <View className="flex-1 items-center justify-center px-8">
<Text className="text-white/80 text-base text-center"> <Text className="text-white/80 text-base text-center">
Couldn't load this {isAudio ? "voice message" : "video"}. Couldnt load this {isAudio ? 'voice message' : 'video'}.
</Text> </Text>
<Text className="text-white/50 text-sm text-center mt-2"> <Text className="text-white/50 text-sm text-center mt-2">
Tap forward to continue. Tap forward to continue.
@@ -284,12 +286,10 @@ function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) {
)} )}
</View> </View>
<Text className="text-white mt-6 text-lg font-medium"> <Text className="text-white mt-6 text-lg font-medium">
{isAudio ? "Voice message" : "Video message"} {isAudio ? 'Voice message' : 'Video message'}
</Text> </Text>
<View className="flex-row items-center mt-3"> <View className="flex-row items-center mt-3">
<Text className="text-white/60 ml-3 text-sm"> <Text className="text-white/60 ml-3 text-sm">View on desktop</Text>
View on desktop
</Text>
</View> </View>
<Text className="text-white/40 mt-2 text-xs text-center"> <Text className="text-white/40 mt-2 text-xs text-center">
Please view this on desktop only. Please view this on desktop only.
@@ -302,11 +302,11 @@ function isPlayableMime(mime: string): boolean {
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a. // expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
// WebM/VP9 (the legacy desktop format) is not decodable. // WebM/VP9 (the legacy desktop format) is not decodable.
return ( return (
mime === "video/mp4" || mime === 'video/mp4' ||
mime === "video/quicktime" || mime === 'video/quicktime' ||
mime === "audio/mp4" || mime === 'audio/mp4' ||
mime === "audio/aac" || mime === 'audio/aac' ||
mime === "audio/x-m4a" || mime === 'audio/x-m4a' ||
mime === "audio/mpeg" mime === 'audio/mpeg'
); );
} }
@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import Animated, { import Animated, {
Easing, Easing,
@@ -7,7 +7,7 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from 'react-native-reanimated';
interface PlaybackPageIndicatorProps { interface PlaybackPageIndicatorProps {
total: number; total: number;
@@ -78,7 +78,7 @@ export function PlaybackPageIndicator({
{paginated && current >= 0 && ( {paginated && current >= 0 && (
<Text <Text
className="pt-1 text-center font-medium text-white/40" className="pt-1 text-center font-medium text-white/40"
style={{ fontSize: 10, fontVariant: ["tabular-nums"] }} style={{ fontSize: 10, fontVariant: ['tabular-nums'] }}
> >
{current + 1} / {total} {current + 1} / {total}
</Text> </Text>
@@ -95,7 +95,11 @@ function GhostStub({ visible }: { visible: boolean }) {
return ( return (
<View <View
className="overflow-hidden rounded-full bg-white/15" className="overflow-hidden rounded-full bg-white/15"
style={{ width: STUB_WIDTH, height: SEGMENT_HEIGHT, alignSelf: "flex-end" }} style={{
width: STUB_WIDTH,
height: SEGMENT_HEIGHT,
alignSelf: 'flex-end',
}}
/> />
); );
} }
@@ -117,7 +121,10 @@ function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
useEffect(() => { useEffect(() => {
if (isPast) { if (isPast) {
cancelAnimation(fill); cancelAnimation(fill);
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }); fill.value = withTiming(1, {
duration: 120,
easing: Easing.out(Easing.cubic),
});
return; return;
} }
if (!isActive) { if (!isActive) {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react';
import { import {
Dimensions, Dimensions,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -8,18 +8,15 @@ import {
Text, Text,
TextInput, TextInput,
View, View,
} from "react-native"; } from 'react-native';
import { import {
initialWindowMetrics, initialWindowMetrics,
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from "react-native-safe-area-context"; } from 'react-native-safe-area-context';
import { Send, X } from "lucide-react-native"; import { Send, X } from 'lucide-react-native';
import * as Haptics from "expo-haptics"; import * as Haptics from 'expo-haptics';
import { import { Gesture, GestureDetector } from 'react-native-gesture-handler';
Gesture,
GestureDetector,
} from "react-native-gesture-handler";
import Animated, { import Animated, {
Easing, Easing,
Extrapolation, Extrapolation,
@@ -29,16 +26,15 @@ import Animated, {
useSharedValue, useSharedValue,
withSpring, withSpring,
withTiming, withTiming,
} from "react-native-reanimated"; } from 'react-native-reanimated';
import { REACTION_EMOJIS, type Reactions } from "@/api/types"; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import type { Human } from "@/api/types"; import { resolveHumanDisplay } from '@/lib/humans';
import { resolveHumanDisplay } from "@/lib/humans"; import { sanitizeReactionText } from '@/lib/firestore-particles';
import { sanitizeReactionText } from "@/lib/firestore-particles"; import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
const TEXT_REACTION_MAX = 40; const TEXT_REACTION_MAX = 40;
const SCREEN_HEIGHT = Dimensions.get("window").height; const SCREEN_HEIGHT = Dimensions.get('window').height;
const ANIMATION_MS = 240; const ANIMATION_MS = 240;
const EMOJI_SET = new Set<string>(REACTION_EMOJIS); const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -75,7 +71,7 @@ export function ReactionSheet({
}: ReactionSheetProps) { }: ReactionSheetProps) {
// Suspend playback whenever the sheet is mounted-and-open. The Modal // Suspend playback whenever the sheet is mounted-and-open. The Modal
// controls visibility so we tie the suspender to `open` directly. // controls visibility so we tie the suspender to `open` directly.
useSuspendPlayback(open, "reactions-sheet"); useSuspendPlayback(open, 'reactions-sheet');
// We mount the modal slightly delayed from `open` so the slide-up animation // We mount the modal slightly delayed from `open` so the slide-up animation
// has its starting position rendered. Using local `mounted` state lets us // has its starting position rendered. Using local `mounted` state lets us
@@ -85,8 +81,7 @@ export function ReactionSheet({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true); // Schedule the slide-in after the modal mounts (handled at render time).
// Schedule animation after the modal mounts
requestAnimationFrame(() => { requestAnimationFrame(() => {
translateY.value = withSpring(0, { translateY.value = withSpring(0, {
damping: 24, damping: 24,
@@ -115,14 +110,18 @@ export function ReactionSheet({
.activeOffsetY(10) .activeOffsetY(10)
.failOffsetX([-25, 25]) .failOffsetX([-25, 25])
.onUpdate((e) => { .onUpdate((e) => {
"worklet"; 'worklet';
// Reanimated shared values are mutated by design; react-hooks/immutability
// doesn't model worklets, so the mutations below are flagged spuriously.
// eslint-disable-next-line react-hooks/immutability
translateY.value = Math.max(0, e.translationY); translateY.value = Math.max(0, e.translationY);
}) })
.onEnd((e) => { .onEnd((e) => {
"worklet"; 'worklet';
if (e.translationY > 120 || e.velocityY > 800) { if (e.translationY > 120 || e.velocityY > 800) {
runOnJS(dismiss)(); runOnJS(dismiss)();
} else { } else {
// eslint-disable-next-line react-hooks/immutability
translateY.value = withSpring(0, { translateY.value = withSpring(0, {
damping: 24, damping: 24,
stiffness: 260, stiffness: 260,
@@ -152,25 +151,31 @@ export function ReactionSheet({
const activeTextKeys = useMemo( const activeTextKeys = useMemo(
() => () =>
Object.keys(reactions ?? {}).filter( Object.keys(reactions ?? {}).filter(
(k) => (k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
!EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
), ),
[reactions], [reactions],
); );
// --- Text reaction input --- // --- Text reaction input ---
const [text, setText] = useState(""); const [text, setText] = useState('');
useEffect(() => { // Mount on open (staying mounted through the exit animation) and clear the
if (open) setText(""); // input. Render-time adjustment avoids a setState-in-effect cascade.
}, [open]); const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setMounted(true);
setText('');
}
}
const submitText = () => { const submitText = () => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return; if (!trimmed) return;
void Haptics.selectionAsync(); void Haptics.selectionAsync();
onToggle(trimmed.slice(0, TEXT_REACTION_MAX)); onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
setText(""); setText('');
onClose(); onClose();
}; };
@@ -190,180 +195,184 @@ export function ReactionSheet({
onRequestClose={dismiss} onRequestClose={dismiss}
> >
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Animated.View <Animated.View
pointerEvents={open ? "auto" : "none"} pointerEvents={open ? 'auto' : 'none'}
style={[ style={[
{ position: "absolute", inset: 0, backgroundColor: "black" }, { position: 'absolute', inset: 0, backgroundColor: 'black' },
backdropStyle, backdropStyle,
]} ]}
> >
<Pressable style={{ flex: 1 }} onPress={dismiss} /> <Pressable style={{ flex: 1 }} onPress={dismiss} />
</Animated.View> </Animated.View>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1, justifyContent: "flex-end" }} style={{ flex: 1, justifyContent: 'flex-end' }}
pointerEvents="box-none" pointerEvents="box-none"
> >
<GestureDetector gesture={sheetPan}> <GestureDetector gesture={sheetPan}>
<Animated.View <Animated.View
style={[ style={[
{ {
backgroundColor: "#1c1c1c", backgroundColor: '#1c1c1c',
borderTopLeftRadius: 22, borderTopLeftRadius: 22,
borderTopRightRadius: 22, borderTopRightRadius: 22,
overflow: "hidden", overflow: 'hidden',
}, },
sheetStyle, sheetStyle,
]} ]}
> >
<SafeAreaView edges={["bottom"]}> <SafeAreaView edges={['bottom']}>
<View className="px-5 pt-3 pb-2 items-center"> <View className="px-5 pt-3 pb-2 items-center">
{/* Drag handle — affords downward dismissal at a glance. */} {/* Drag handle — affords downward dismissal at a glance. */}
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" /> <View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
<View className="flex-row items-center justify-between w-full"> <View className="flex-row items-center justify-between w-full">
<Text className="text-white text-base font-semibold"> <Text className="text-white text-base font-semibold">
React React
</Text> </Text>
<Pressable <Pressable
onPress={dismiss} onPress={dismiss}
hitSlop={12} hitSlop={12}
accessibilityLabel="Close reactions" accessibilityLabel="Close reactions"
> >
<X color="rgba(255,255,255,0.6)" size={20} /> <X color="rgba(255,255,255,0.6)" size={20} />
</Pressable> </Pressable>
</View>
</View> </View>
</View>
{/* Existing reactions row — tap a pill to toggle yours. */} {/* Existing reactions row — tap a pill to toggle yours. */}
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? ( {activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
<View className="px-5 pb-3 flex-row flex-wrap gap-2"> <View className="px-5 pb-3 flex-row flex-wrap gap-2">
{activeEmojis.map((emoji) => { {activeEmojis.map((emoji) => {
const reactors = reactions![emoji]; const reactors = reactions![emoji];
const isMine = reactors.includes(currentHumanId); const isMine = reactors.includes(currentHumanId);
return (
<Pressable
key={emoji}
onPress={() => handleEmoji(emoji)}
className={cn(
'flex-row items-center gap-1.5 rounded-full px-3 py-1.5',
isMine
? 'bg-white/25 border border-white/40'
: 'bg-white/10',
)}
>
<Text className="text-base">{emoji}</Text>
<Text className="text-white/85 text-xs font-semibold">
{reactors.length}
</Text>
</Pressable>
);
})}
{activeTextKeys.map((key) => {
const reactors = reactions![key];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(
reactors[0],
humans,
);
return (
<Pressable
key={key}
onPress={() => handleEmoji(key)}
className={cn(
'flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]',
isMine
? 'bg-white/25 border border-white/40'
: 'bg-white/10',
)}
>
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Text
className="text-white/90 text-xs"
numberOfLines={1}
>
{key}
</Text>
{reactors.length > 1 ? (
<Text className="text-white/60 text-xs font-medium">
{reactors.length}
</Text>
) : null}
</Pressable>
);
})}
</View>
) : null}
{/* Quick-pick emoji palette — six big tappable buttons. */}
<View className="px-3 pt-2 pb-4 flex-row justify-around">
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
const isMine =
reactions?.[emoji]?.includes(currentHumanId) ?? false;
return ( return (
<Pressable <Pressable
key={emoji} key={emoji}
onPress={() => handleEmoji(emoji)} onPress={() => handleEmoji(emoji)}
accessibilityLabel={`React with ${emoji}`}
className={cn( className={cn(
"flex-row items-center gap-1.5 rounded-full px-3 py-1.5", 'h-14 w-14 items-center justify-center rounded-full',
isMine isMine ? 'bg-white/25' : 'bg-white/10',
? "bg-white/25 border border-white/40"
: "bg-white/10",
)} )}
> >
<Text className="text-base">{emoji}</Text> <Text style={{ fontSize: 28 }}>{emoji}</Text>
<Text className="text-white/85 text-xs font-semibold">
{reactors.length}
</Text>
</Pressable>
);
})}
{activeTextKeys.map((key) => {
const reactors = reactions![key];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(
reactors[0],
humans,
);
return (
<Pressable
key={key}
onPress={() => handleEmoji(key)}
className={cn(
"flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]",
isMine
? "bg-white/25 border border-white/40"
: "bg-white/10",
)}
>
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Text
className="text-white/90 text-xs"
numberOfLines={1}
>
{key}
</Text>
{reactors.length > 1 ? (
<Text className="text-white/60 text-xs font-medium">
{reactors.length}
</Text>
) : null}
</Pressable> </Pressable>
); );
})} })}
</View> </View>
) : null}
{/* Quick-pick emoji palette — six big tappable buttons. */} {/* Text reaction input — 40-char cap matches desktop. */}
<View className="px-3 pt-2 pb-4 flex-row justify-around"> <View className="px-4 pb-4 flex-row items-center gap-2">
{REACTION_EMOJIS.slice(0, 6).map((emoji) => { <View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
const isMine = <TextInput
reactions?.[emoji]?.includes(currentHumanId) ?? false; value={text}
return ( onChangeText={(v) =>
<Pressable setText(
key={emoji} sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX),
onPress={() => handleEmoji(emoji)} )
accessibilityLabel={`React with ${emoji}`} }
className={cn( placeholder="Send a quick reply..."
"h-14 w-14 items-center justify-center rounded-full", placeholderTextColor="rgba(255,255,255,0.4)"
isMine ? "bg-white/25" : "bg-white/10", maxLength={TEXT_REACTION_MAX}
)} autoCapitalize="none"
> autoCorrect={false}
<Text style={{ fontSize: 28 }}>{emoji}</Text> onSubmitEditing={submitText}
</Pressable> returnKeyType="send"
); className="text-white text-base"
})} />
</View> </View>
<Pressable
{/* Text reaction input — 40-char cap matches desktop. */} onPress={submitText}
<View className="px-4 pb-4 flex-row items-center gap-2"> disabled={text.trim().length === 0}
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5"> accessibilityLabel="Send text reaction"
<TextInput className={cn(
value={text} 'h-11 w-11 items-center justify-center rounded-full',
onChangeText={(v) => text.trim().length === 0 ? 'bg-white/10' : 'bg-white',
setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX)) )}
} >
placeholder="Send a quick reply..." <Send
placeholderTextColor="rgba(255,255,255,0.4)" color={
maxLength={TEXT_REACTION_MAX} text.trim().length === 0
autoCapitalize="none" ? 'rgba(255,255,255,0.3)'
autoCorrect={false} : 'black'
onSubmitEditing={submitText} }
returnKeyType="send" size={18}
className="text-white text-base" strokeWidth={2}
/> />
</Pressable>
</View> </View>
<Pressable </SafeAreaView>
onPress={submitText} </Animated.View>
disabled={text.trim().length === 0} </GestureDetector>
accessibilityLabel="Send text reaction" </KeyboardAvoidingView>
className={cn( </View>
"h-11 w-11 items-center justify-center rounded-full",
text.trim().length === 0
? "bg-white/10"
: "bg-white",
)}
>
<Send
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
size={18}
strokeWidth={2}
/>
</Pressable>
</View>
</SafeAreaView>
</Animated.View>
</GestureDetector>
</KeyboardAvoidingView>
</View>
</SafeAreaProvider> </SafeAreaProvider>
</Modal> </Modal>
); );
@@ -1,11 +1,10 @@
import { useMemo } from "react"; import { useMemo } from 'react';
import { Pressable, Text, View } from "react-native"; 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 } from "@/api/types"; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import type { Human } from "@/api/types"; import { resolveHumanDisplay } from '@/lib/humans';
import { resolveHumanDisplay } from "@/lib/humans"; 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);
@@ -58,12 +57,12 @@ export function ReactionStack({
key={emoji} key={emoji}
onPress={() => handleToggle(emoji)} onPress={() => handleToggle(emoji)}
className={cn( className={cn(
"flex-row items-center gap-1 rounded-full px-2 py-1", 'flex-row items-center gap-1 rounded-full px-2 py-1',
isMine ? "bg-white/25" : "bg-black/45", isMine ? 'bg-white/25' : 'bg-black/45',
)} )}
style={ style={
isMine isMine
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" } ? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
: undefined : undefined
} }
> >
@@ -84,13 +83,13 @@ export function ReactionStack({
key={text} key={text}
onPress={() => handleToggle(text)} onPress={() => handleToggle(text)}
className={cn( className={cn(
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5", 'flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5',
isMine ? "bg-white/25" : "bg-black/45", isMine ? 'bg-white/25' : 'bg-black/45',
)} )}
style={[ style={[
{ maxWidth: 200 }, { maxWidth: 200 },
isMine isMine
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" } ? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
: null, : null,
]} ]}
> >
@@ -99,10 +98,7 @@ export function ReactionStack({
{firstReactor.initials} {firstReactor.initials}
</Text> </Text>
</View> </View>
<Text <Text className="text-white/90 text-xs" numberOfLines={1}>
className="text-white/90 text-xs"
numberOfLines={1}
>
{text} {text}
</Text> </Text>
{reactors.length > 1 ? ( {reactors.length > 1 ? (
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react"; import { useState } from 'react';
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { updateParticleProperties } from "@/lib/firestore-particles"; import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { BottomSheet } from "@/components/BottomSheet"; import { BottomSheet } from '@/components/BottomSheet';
interface RenameStreamSheetProps { interface RenameStreamSheetProps {
open: boolean; open: boolean;
@@ -23,17 +23,20 @@ export function RenameStreamSheet({
streamId, streamId,
currentName, currentName,
}: RenameStreamSheetProps) { }: RenameStreamSheetProps) {
useSuspendPlayback(open, "rename-stream"); useSuspendPlayback(open, 'rename-stream');
const [name, setName] = useState(currentName); const [name, setName] = useState(currentName);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { // Reset the field each time the sheet opens fresh.
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) { if (open) {
setName(currentName); setName(currentName);
setSaving(false); setSaving(false);
} }
}, [open, currentName]); }
const trimmed = name.trim(); const trimmed = name.trim();
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName; const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
@@ -43,7 +46,7 @@ export function RenameStreamSheet({
setSaving(true); setSaving(true);
try { try {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId])); const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
await updateParticleProperties<"stream">(docPath, { name: trimmed }); await updateParticleProperties<'stream'>(docPath, { name: trimmed });
onClose(); onClose();
} catch (err) { } catch (err) {
toast.error(toUserMessage(err)); toast.error(toUserMessage(err));
@@ -58,18 +61,14 @@ export function RenameStreamSheet({
<Text className="text-white/70 text-base">Cancel</Text> <Text className="text-white/70 text-base">Cancel</Text>
</Pressable> </Pressable>
<Text className="text-white text-base font-semibold">Rename</Text> <Text className="text-white text-base font-semibold">Rename</Text>
<Pressable <Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
onPress={handleSave}
disabled={!canSave}
hitSlop={12}
>
<Text <Text
className={cn( className={cn(
"text-base font-semibold", 'text-base font-semibold',
canSave ? "text-white" : "text-white/30", canSave ? 'text-white' : 'text-white/30',
)} )}
> >
{saving ? "Saving..." : "Save"} {saving ? 'Saving...' : 'Save'}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
@@ -1,27 +1,27 @@
import { useState } from "react"; import { useState } from 'react';
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from 'react-native';
import { import {
CircleCheckBig, CircleCheckBig,
CircleDot, CircleDot,
Pencil, Pencil,
Trash2, Trash2,
Users, Users,
} from "lucide-react-native"; } from 'lucide-react-native';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { BottomSheet } from "@/components/BottomSheet"; import { BottomSheet } from '@/components/BottomSheet';
export type StreamActionId = export type StreamActionId =
| "toggle-status" | 'toggle-status'
| "rename" | 'rename'
| "members" | 'members'
| "edit-particle" | 'edit-particle'
| "delete-particle"; | 'delete-particle';
interface StreamActionsSheetProps { interface StreamActionsSheetProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSelect: (action: StreamActionId) => void; onSelect: (action: StreamActionId) => void;
streamStatus: "open" | "closed"; streamStatus: 'open' | 'closed';
isCreator: boolean; isCreator: boolean;
/** True when the *current* particle is a text particle this user authored. */ /** True when the *current* particle is a text particle this user authored. */
canEditParticle: boolean; canEditParticle: boolean;
@@ -63,34 +63,32 @@ export function StreamActionsSheet({
<View className="py-2"> <View className="py-2">
<ActionRow <ActionRow
icon={ icon={
streamStatus === "open" ? ( streamStatus === 'open' ? (
<CircleCheckBig color="white" size={20} /> <CircleCheckBig color="white" size={20} />
) : ( ) : (
<CircleDot color="#22c55e" size={20} /> <CircleDot color="#22c55e" size={20} />
) )
} }
label={ label={streamStatus === 'open' ? 'Close stream' : 'Reopen stream'}
streamStatus === "open" ? "Close stream" : "Reopen stream" onPress={() => choose('toggle-status')}
}
onPress={() => choose("toggle-status")}
/> />
<ActionRow <ActionRow
icon={<Users color="white" size={20} />} icon={<Users color="white" size={20} />}
label="Members" label="Members"
onPress={() => choose("members")} onPress={() => choose('members')}
/> />
{isCreator ? ( {isCreator ? (
<ActionRow <ActionRow
icon={<Pencil color="white" size={20} />} icon={<Pencil color="white" size={20} />}
label="Rename stream" label="Rename stream"
onPress={() => choose("rename")} onPress={() => choose('rename')}
/> />
) : null} ) : null}
{canEditParticle ? ( {canEditParticle ? (
<ActionRow <ActionRow
icon={<Pencil color="white" size={20} />} icon={<Pencil color="white" size={20} />}
label="Edit particle" label="Edit particle"
onPress={() => choose("edit-particle")} onPress={() => choose('edit-particle')}
/> />
) : null} ) : null}
{canDeleteParticle ? ( {canDeleteParticle ? (
@@ -98,7 +96,7 @@ export function StreamActionsSheet({
icon={<Trash2 color="#ef4444" size={20} />} icon={<Trash2 color="#ef4444" size={20} />}
label="Delete particle" label="Delete particle"
tone="destructive" tone="destructive"
onPress={() => choose("delete-particle")} onPress={() => choose('delete-particle')}
/> />
) : null} ) : null}
</View> </View>
@@ -119,12 +117,12 @@ function ActionRow({
icon, icon,
label, label,
onPress, onPress,
tone = "default", tone = 'default',
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
onPress: () => void; onPress: () => void;
tone?: "default" | "destructive"; tone?: 'default' | 'destructive';
}) { }) {
return ( return (
<Pressable <Pressable
@@ -134,8 +132,8 @@ function ActionRow({
<View className="w-6 items-center">{icon}</View> <View className="w-6 items-center">{icon}</View>
<Text <Text
className={cn( className={cn(
"text-base", 'text-base',
tone === "destructive" ? "text-red-400" : "text-white", tone === 'destructive' ? 'text-red-400' : 'text-white',
)} )}
> >
{label} {label}
@@ -1,28 +1,28 @@
import { useMemo } from "react"; import { useMemo } from 'react';
import { Pressable, ScrollView, Text, View } from "react-native"; import { Pressable, ScrollView, Text, View } from 'react-native';
import { Globe, Lock, X } from "lucide-react-native"; import { Globe, Lock, X } from 'lucide-react-native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import { updateParticleVisibleTo } from "@/lib/firestore-particles"; import { updateParticleVisibleTo } from '@/lib/firestore-particles';
import { import {
buildCustomVisibility, buildCustomVisibility,
buildNetworkVisibility, buildNetworkVisibility,
parseVisibleTo, parseVisibleTo,
} from "@/lib/stream-visibility"; } from '@/lib/stream-visibility';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { BottomSheet } from "@/components/BottomSheet"; import { BottomSheet } from '@/components/BottomSheet';
import { Avatar } from "@/components/Avatar"; import { Avatar } from '@/components/Avatar';
import { useStreamPresence } from "./stream-presence-context"; import { useStreamPresence } from './stream-presence-context';
interface StreamMembersSheetProps { interface StreamMembersSheetProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
networkId: string; networkId: string;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
isCreator: boolean; isCreator: boolean;
} }
@@ -38,7 +38,7 @@ export function StreamMembersSheet({
streamParticle, streamParticle,
isCreator, isCreator,
}: StreamMembersSheetProps) { }: StreamMembersSheetProps) {
useSuspendPlayback(open, "stream-members"); useSuspendPlayback(open, 'stream-members');
const { onlineHumanIds } = useStreamPresence(); const { onlineHumanIds } = useStreamPresence();
const network = useNetwork(networkId); const network = useNetwork(networkId);
@@ -52,7 +52,7 @@ export function StreamMembersSheet({
); );
const memberIds = const memberIds =
visibility.mode === "network" visibility.mode === 'network'
? humans.map((h) => h.id) ? humans.map((h) => h.id)
: visibility.humanIds; : visibility.humanIds;
const memberSet = new Set(memberIds); const memberSet = new Set(memberIds);
@@ -70,7 +70,7 @@ export function StreamMembersSheet({
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId])); const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
const removeMember = (id: string) => { const removeMember = (id: string) => {
if (visibility.mode !== "custom") return; if (visibility.mode !== 'custom') return;
if (id === creatorId) return; if (id === creatorId) return;
const next = visibility.humanIds.filter((x) => x !== id); const next = visibility.humanIds.filter((x) => x !== id);
if (next.length === 0) return; if (next.length === 0) return;
@@ -78,7 +78,7 @@ export function StreamMembersSheet({
}; };
const addMember = (id: string) => { const addMember = (id: string) => {
if (visibility.mode !== "custom") return; if (visibility.mode !== 'custom') return;
void apply(buildCustomVisibility([...visibility.humanIds, id])); void apply(buildCustomVisibility([...visibility.humanIds, id]));
}; };
@@ -99,13 +99,13 @@ export function StreamMembersSheet({
{isCreator ? ( {isCreator ? (
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1"> <View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
<ModePill <ModePill
active={visibility.mode === "network"} active={visibility.mode === 'network'}
icon={<Globe color="white" size={14} />} icon={<Globe color="white" size={14} />}
label="Network-wide" label="Network-wide"
onPress={setNetworkWide} onPress={setNetworkWide}
/> />
<ModePill <ModePill
active={visibility.mode === "custom"} active={visibility.mode === 'custom'}
icon={<Lock color="white" size={14} />} icon={<Lock color="white" size={14} />}
label="Specific people" label="Specific people"
onPress={setCustomOnlyCreator} onPress={setCustomOnlyCreator}
@@ -113,19 +113,19 @@ export function StreamMembersSheet({
</View> </View>
) : ( ) : (
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
{visibility.mode === "network" ? ( {visibility.mode === 'network' ? (
<> <>
<Globe color="rgba(255,255,255,0.5)" size={14} /> <Globe color="rgba(255,255,255,0.5)" size={14} />
<Text className="text-white/70 text-sm"> <Text className="text-white/70 text-sm">
Everyone in {network?.name ?? "network"} Everyone in {network?.name ?? 'network'}
</Text> </Text>
</> </>
) : ( ) : (
<> <>
<Lock color="rgba(255,255,255,0.5)" size={14} /> <Lock color="rgba(255,255,255,0.5)" size={14} />
<Text className="text-white/70 text-sm"> <Text className="text-white/70 text-sm">
{memberIds.length} specific{" "} {memberIds.length} specific{' '}
{memberIds.length === 1 ? "person" : "people"} {memberIds.length === 1 ? 'person' : 'people'}
</Text> </Text>
</> </>
)} )}
@@ -136,19 +136,16 @@ export function StreamMembersSheet({
<ScrollView contentContainerClassName="pb-4"> <ScrollView contentContainerClassName="pb-4">
<View className="px-5 pt-2"> <View className="px-5 pt-2">
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2"> <Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
{visibility.mode === "network" ? "Has access" : "People"} ·{" "} {visibility.mode === 'network' ? 'Has access' : 'People'} ·{' '}
{memberIds.length} {memberIds.length}
</Text> </Text>
{memberIds.map((id) => { {memberIds.map((id) => {
const display = resolveHumanDisplay(id, humans); const display = resolveHumanDisplay(id, humans);
const isCreatorRow = id === creatorId; const isCreatorRow = id === creatorId;
const canRemove = const canRemove =
isCreator && visibility.mode === "custom" && !isCreatorRow; isCreator && visibility.mode === 'custom' && !isCreatorRow;
return ( return (
<View <View key={id} className="flex-row items-center gap-3 py-2.5">
key={id}
className="flex-row items-center gap-3 py-2.5"
>
<Avatar <Avatar
humanId={id} humanId={id}
humans={humans} humans={humans}
@@ -159,18 +156,15 @@ export function StreamMembersSheet({
<Text <Text
className={ className={
display.exists display.exists
? "text-white text-sm font-medium" ? 'text-white text-sm font-medium'
: "text-white/50 italic text-sm font-medium" : 'text-white/50 italic text-sm font-medium'
} }
numberOfLines={1} numberOfLines={1}
> >
{display.displayName} {display.displayName}
</Text> </Text>
{display.exists ? ( {display.exists ? (
<Text <Text className="text-white/40 text-xs" numberOfLines={1}>
className="text-white/40 text-xs"
numberOfLines={1}
>
{display.email} {display.email}
</Text> </Text>
) : null} ) : null}
@@ -194,7 +188,7 @@ export function StreamMembersSheet({
</View> </View>
{isCreator && {isCreator &&
visibility.mode === "custom" && visibility.mode === 'custom' &&
availableToAdd.length > 0 ? ( availableToAdd.length > 0 ? (
<View className="px-5 pt-4 mt-2 border-t border-white/5"> <View className="px-5 pt-4 mt-2 border-t border-white/5">
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2"> <Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
@@ -221,10 +215,7 @@ export function StreamMembersSheet({
> >
{display.displayName} {display.displayName}
</Text> </Text>
<Text <Text className="text-white/40 text-xs" numberOfLines={1}>
className="text-white/40 text-xs"
numberOfLines={1}
>
{display.email} {display.email}
</Text> </Text>
</View> </View>
@@ -254,16 +245,14 @@ function ModePill({
<Pressable <Pressable
onPress={onPress} onPress={onPress}
className={ className={
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " + 'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 ' +
(active ? "bg-white/15" : "") (active ? 'bg-white/15' : '')
} }
> >
{icon} {icon}
<Text <Text
className={ className={
active active ? 'text-white text-xs font-semibold' : 'text-white/60 text-xs'
? "text-white text-xs font-semibold"
: "text-white/60 text-xs"
} }
> >
{label} {label}
@@ -1,9 +1,9 @@
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import type { Network, Particle } from "@/api/types"; import type { Network, Particle } from '@/api/types';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from "@/components/RelativeTimestamp"; import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { Avatar } from "@/components/Avatar"; import { Avatar } from '@/components/Avatar';
import { useStreamPresence } from "./stream-presence-context"; import { useStreamPresence } from './stream-presence-context';
interface StreamMetadataHeaderProps { interface StreamMetadataHeaderProps {
particle: Particle | null; particle: Particle | null;
@@ -26,7 +26,7 @@ export function StreamMetadataHeader({
); );
const editedAt = const editedAt =
particle.type === "text" ? particle.properties.edited_at : undefined; particle.type === 'text' ? particle.properties.edited_at : undefined;
const isOnline = particle.created_by_human_id const isOnline = particle.created_by_human_id
? onlineHumanIds.has(particle.created_by_human_id) ? onlineHumanIds.has(particle.created_by_human_id)
: false; : false;
@@ -40,10 +40,7 @@ export function StreamMetadataHeader({
online={isOnline} online={isOnline}
/> />
<View className="flex-1"> <View className="flex-1">
<Text <Text className="text-white text-sm font-semibold" numberOfLines={1}>
className="text-white text-sm font-semibold"
numberOfLines={1}
>
{display.displayName} {display.displayName}
</Text> </Text>
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
@@ -53,7 +50,7 @@ export function StreamMetadataHeader({
/> />
{editedAt ? ( {editedAt ? (
<Text className="text-white/40 text-xs"> <Text className="text-white/40 text-xs">
· edited{" "} · edited{' '}
<RelativeTimestamp date={editedAt} className="text-white/40" /> <RelativeTimestamp date={editedAt} className="text-white/40" />
</Text> </Text>
) : null} ) : null}
@@ -1,23 +1,23 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from 'react-native';
import { import {
EllipsisVertical, EllipsisVertical,
Globe, Globe,
Headphones, Headphones,
Maximize2, Maximize2,
Minimize2, Minimize2,
} from "lucide-react-native"; } from 'lucide-react-native';
import type { Human, Particle } from "@/api/types"; import type { Human, Particle } from '@/api/types';
import { parseVisibleTo } from "@/lib/stream-visibility"; import { parseVisibleTo } from '@/lib/stream-visibility';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { Avatar } from "@/components/Avatar"; import { Avatar } from '@/components/Avatar';
import { useOpenHuddle } from "@/features/huddle/use-open-huddle"; import { useOpenHuddle } from '@/features/huddle/use-open-huddle';
import { useStreamPresence } from "./stream-presence-context"; import { useStreamPresence } from './stream-presence-context';
interface StreamTopActionsProps { interface StreamTopActionsProps {
networkId: string; networkId: string;
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
humans: Human[]; humans: Human[];
videoFit: "cover" | "contain"; videoFit: 'cover' | 'contain';
onToggleVideoFit: () => void; onToggleVideoFit: () => void;
onOpenMembers: () => void; onOpenMembers: () => void;
onOpenActions: () => void; onOpenActions: () => void;
@@ -48,7 +48,7 @@ export function StreamTopActions({
const huddleActive = huddleCount > 0; const huddleActive = huddleCount > 0;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId); const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
const memberIds = const memberIds =
visibility.mode === "network" visibility.mode === 'network'
? humans.map((h) => h.id) ? humans.map((h) => h.id)
: visibility.humanIds; : visibility.humanIds;
const shown = memberIds.slice(0, MAX_AVATARS); const shown = memberIds.slice(0, MAX_AVATARS);
@@ -61,15 +61,12 @@ export function StreamTopActions({
accessibilityLabel="Stream members" accessibilityLabel="Stream members"
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1" className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
> >
{visibility.mode === "network" && memberIds.length === 0 ? ( {visibility.mode === 'network' && memberIds.length === 0 ? (
<Globe color="rgba(255,255,255,0.85)" size={14} /> <Globe color="rgba(255,255,255,0.85)" size={14} />
) : ( ) : (
<View className="flex-row"> <View className="flex-row">
{shown.map((id, idx) => ( {shown.map((id, idx) => (
<View <View key={id} style={{ marginLeft: idx === 0 ? 0 : -8 }}>
key={id}
style={{ marginLeft: idx === 0 ? 0 : -8 }}
>
{/* The stack ring matches the chrome's translucent bg so it {/* The stack ring matches the chrome's translucent bg so it
reads as a separator without painting hard black halos. */} reads as a separator without painting hard black halos. */}
<Avatar <Avatar
@@ -98,12 +95,12 @@ export function StreamTopActions({
) )
} }
disabled={huddleLoading} disabled={huddleLoading}
accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"} accessibilityLabel={huddleActive ? 'Join huddle' : 'Start huddle'}
className={cn( className={cn(
"h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1", 'h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1',
huddleActive huddleActive
? "bg-red-500/90 active:bg-red-600" ? 'bg-red-500/90 active:bg-red-600'
: "bg-white/10 active:bg-white/20", : 'bg-white/10 active:bg-white/20',
)} )}
> >
{huddleLoading ? ( {huddleLoading ? (
@@ -124,14 +121,16 @@ export function StreamTopActions({
<Pressable <Pressable
onPress={onToggleVideoFit} onPress={onToggleVideoFit}
accessibilityLabel={ accessibilityLabel={
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video" videoFit === 'cover'
? 'Fit video to screen'
: 'Fill screen with video'
} }
className={cn( className={cn(
"h-8 w-8 items-center justify-center rounded-full", 'h-8 w-8 items-center justify-center rounded-full',
"bg-white/10 active:bg-white/20", 'bg-white/10 active:bg-white/20',
)} )}
> >
{videoFit === "cover" ? ( {videoFit === 'cover' ? (
<Minimize2 color="white" size={15} strokeWidth={1.8} /> <Minimize2 color="white" size={15} strokeWidth={1.8} />
) : ( ) : (
<Maximize2 color="white" size={15} strokeWidth={1.8} /> <Maximize2 color="white" size={15} strokeWidth={1.8} />
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from 'react';
import { Alert, Dimensions, Pressable, Text, View } from "react-native"; import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
import { useIsFocused } from "@react-navigation/native"; import { useIsFocused } from '@react-navigation/native';
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import * as Haptics from "expo-haptics";
import { ChevronDown } from "lucide-react-native";
import { import {
Gesture, SafeAreaView,
GestureDetector, useSafeAreaInsets,
} from "react-native-gesture-handler"; } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import * as Haptics from 'expo-haptics';
import { ChevronDown } from 'lucide-react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { import Animated, {
Extrapolation, Extrapolation,
interpolate, interpolate,
@@ -17,54 +17,54 @@ import Animated, {
useSharedValue, useSharedValue,
withSpring, withSpring,
withTiming, withTiming,
} from "react-native-reanimated"; } from 'react-native-reanimated';
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
import { isParticleDeleted, type Particle } from "@/api/types"; import { isParticleDeleted, type Particle } from '@/api/types';
import { import {
parseParticlePath, parseParticlePath,
particlePath, particlePath,
toFirestoreDocPath, toFirestoreDocPath,
type ParticlePath, type ParticlePath,
} from "@/lib/particle-path"; } from '@/lib/particle-path';
import { import {
softDeleteParticle, softDeleteParticle,
toggleParticleReaction, toggleParticleReaction,
updateStreamStatus, updateStreamStatus,
} from "@/lib/firestore-particles"; } from '@/lib/firestore-particles';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useStreamPlayback } from '@/hooks/use-stream-playback';
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { import {
selectIsComposing, selectIsComposing,
selectIsPaused, selectIsPaused,
usePlaybackPauseStore, usePlaybackPauseStore,
} from "@/stores/playback-pause-store"; } from '@/stores/playback-pause-store';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { ComposeDock } from "@/features/compose/ComposeDock"; import { ComposeDock } from '@/features/compose/ComposeDock';
import { ComposingIndicator } from "@/components/ComposingIndicator"; import { ComposingIndicator } from '@/components/ComposingIndicator';
import { PlaybackPageIndicator } from "./PlaybackPageIndicator"; import { PlaybackPageIndicator } from './PlaybackPageIndicator';
import { ReactionSheet } from "./ReactionSheet"; import { ReactionSheet } from './ReactionSheet';
import { StreamMetadataHeader } from "./StreamMetadataHeader"; import { StreamMetadataHeader } from './StreamMetadataHeader';
import { StreamSafeAreaProvider } from "./stream-safe-area"; import { StreamSafeAreaProvider } from './stream-safe-area';
import { import {
StreamPresenceProvider, StreamPresenceProvider,
useStreamComposing, useStreamComposing,
} from "./stream-presence-context"; } from './stream-presence-context';
import { TextParticleView } from "./TextParticleView"; import { TextParticleView } from './TextParticleView';
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 { 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';
import { EditParticleSheet } from "./EditParticleSheet"; import { EditParticleSheet } from './EditParticleSheet';
import { ReactionStack } from "./ReactionStack"; import { ReactionStack } from './ReactionStack';
const SCREEN_HEIGHT = Dimensions.get("window").height; const SCREEN_HEIGHT = Dimensions.get('window').height;
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the // Tap-zone split: left 28% goes back, right 72% goes forward — matching the
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward. // asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
const PREV_ZONE_RATIO = 0.28; const PREV_ZONE_RATIO = 0.28;
@@ -80,7 +80,7 @@ const REACTIONS_VELOCITY = 600;
const COMPOSE_DOCK_HEIGHT = 50; const COMPOSE_DOCK_HEIGHT = 50;
interface StreamViewProps { interface StreamViewProps {
streamParticle: Particle & { type: "stream" }; streamParticle: Particle & { type: 'stream' };
path: ParticlePath; path: ParticlePath;
onExit: () => void; onExit: () => void;
} }
@@ -118,19 +118,26 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
const paused = usePlaybackPauseStore(selectIsPaused); const paused = usePlaybackPauseStore(selectIsPaused);
const composing = usePlaybackPauseStore(selectIsComposing); const composing = usePlaybackPauseStore(selectIsComposing);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const userId = useAuthStore((s) => s.user?.id) ?? ""; const userId = useAuthStore((s) => s.user?.id) ?? '';
// Reset progress whenever the active particle changes.
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
if (currentParticle?.id !== prevParticleId) {
setPrevParticleId(currentParticle?.id);
setProgress(0);
}
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS // Local hold state drives the "touch-hold" pause suspender. We wrap the JS
// setter inside a runOnJS callback dispatched from the worklet thread. // setter inside a runOnJS callback dispatched from the worklet thread.
const [holdActive, setHoldActive] = useState(false); const [holdActive, setHoldActive] = useState(false);
useSuspendPlayback(holdActive, "touch-hold"); useSuspendPlayback(holdActive, 'touch-hold');
// Suspend playback whenever another screen (Huddle, NewStream, modals // Suspend playback whenever another screen (Huddle, NewStream, modals
// routed as screens) is on top. Native stack keeps StreamView mounted, so // routed as screens) is on top. Native stack keeps StreamView mounted, so
// without this the stream would keep advancing — and the exit countdown // without this the stream would keep advancing — and the exit countdown
// would fire — behind the huddle. // would fire — behind the huddle.
const isFocused = useIsFocused(); const isFocused = useIsFocused();
useSuspendPlayback(!isFocused, "screen-unfocused"); useSuspendPlayback(!isFocused, 'screen-unfocused');
// Reaction sheet — opens via swipe-up on the canvas. // Reaction sheet — opens via swipe-up on the canvas.
const [reactionsOpen, setReactionsOpen] = useState(false); const [reactionsOpen, setReactionsOpen] = useState(false);
@@ -142,32 +149,32 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
const [membersOpen, setMembersOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover"); const [videoFit, setVideoFit] = useState<'cover' | 'contain'>('cover');
const isCreator = !!userId && userId === streamParticle.created_by_human_id; const isCreator = !!userId && userId === streamParticle.created_by_human_id;
const canDeleteCurrentParticle = const canDeleteCurrentParticle =
!!currentParticle && !!currentParticle &&
!!userId && !!userId &&
currentParticle.created_by_human_id === userId && currentParticle.created_by_human_id === userId &&
currentParticle.type !== "stream" && currentParticle.type !== 'stream' &&
currentParticle.type !== "folder" && currentParticle.type !== 'folder' &&
!isParticleDeleted(currentParticle); !isParticleDeleted(currentParticle);
const canEditCurrentParticle = const canEditCurrentParticle =
!!currentParticle && !!currentParticle &&
!!userId && !!userId &&
currentParticle.created_by_human_id === userId && currentParticle.created_by_human_id === userId &&
currentParticle.type === "text" && currentParticle.type === 'text' &&
!isParticleDeleted(currentParticle); !isParticleDeleted(currentParticle);
const editableTextParticle = const editableTextParticle =
canEditCurrentParticle && currentParticle && currentParticle.type === "text" canEditCurrentParticle && currentParticle && currentParticle.type === 'text'
? currentParticle ? currentParticle
: null; : null;
const showFitToggle = const showFitToggle =
!!currentParticle && !!currentParticle &&
!isParticleDeleted(currentParticle) && !isParticleDeleted(currentParticle) &&
currentParticle.type === "media" && currentParticle.type === 'media' &&
!currentParticle.properties.mime_type.startsWith("audio/"); !currentParticle.properties.mime_type.startsWith('audio/');
const handleStreamAction = useCallback( const handleStreamAction = useCallback(
async (action: StreamActionId) => { async (action: StreamActionId) => {
@@ -175,38 +182,38 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
particlePath(networkId, [streamParticle.id]), particlePath(networkId, [streamParticle.id]),
); );
switch (action) { switch (action) {
case "toggle-status": { case 'toggle-status': {
try { try {
await updateStreamStatus( await updateStreamStatus(
streamDocPath, streamDocPath,
streamParticle.status === "open" ? "closed" : "open", streamParticle.status === 'open' ? 'closed' : 'open',
); );
} catch (err) { } catch (err) {
toast.error(toUserMessage(err)); toast.error(toUserMessage(err));
} }
return; return;
} }
case "rename": case 'rename':
setRenameOpen(true); setRenameOpen(true);
return; return;
case "members": case 'members':
setMembersOpen(true); setMembersOpen(true);
return; return;
case "edit-particle": case 'edit-particle':
if (!canEditCurrentParticle) return; if (!canEditCurrentParticle) return;
setEditOpen(true); setEditOpen(true);
return; return;
case "delete-particle": { case 'delete-particle': {
if (!currentParticle || !userId) return; if (!currentParticle || !userId) return;
if (!canDeleteCurrentParticle) return; if (!canDeleteCurrentParticle) return;
Alert.alert( Alert.alert(
"Delete this particle?", 'Delete this particle?',
"This cannot be undone. Other viewers will see a \"deleted\" message in its place.", 'This cannot be undone. Other viewers will see a "deleted" message in its place.',
[ [
{ text: "Cancel", style: "cancel" }, { text: 'Cancel', style: 'cancel' },
{ {
text: "Delete", text: 'Delete',
style: "destructive", style: 'destructive',
onPress: async () => { onPress: async () => {
try { try {
const docPath = toFirestoreDocPath( const docPath = toFirestoreDocPath(
@@ -240,7 +247,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
const reactionsOnCurrent = const reactionsOnCurrent =
currentParticle && !isParticleDeleted(currentParticle) currentParticle && !isParticleDeleted(currentParticle)
? currentParticle.type === "media" || currentParticle.type === "text" ? currentParticle.type === 'media' || currentParticle.type === 'text'
? currentParticle.reactions ? currentParticle.reactions
: undefined : undefined
: undefined; : undefined;
@@ -252,12 +259,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
const docPath = toFirestoreDocPath( const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id, currentParticle.id]), particlePath(networkId, [streamParticle.id, currentParticle.id]),
); );
void toggleParticleReaction( void toggleParticleReaction(docPath, key, userId, reactionsOnCurrent);
docPath,
key,
userId,
reactionsOnCurrent,
);
}, },
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent], [userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
); );
@@ -280,11 +282,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
[children.length, currentIndex, goToParticle], [children.length, currentIndex, goToParticle],
); );
// Reset progress whenever the active particle changes.
useEffect(() => {
setProgress(0);
}, [currentParticle?.id]);
const handleTap = useCallback( const handleTap = useCallback(
(xRatio: number) => { (xRatio: number) => {
if (xRatio < PREV_ZONE_RATIO) { if (xRatio < PREV_ZONE_RATIO) {
@@ -303,7 +300,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
// --- Swipe-down dismiss --- // --- Swipe-down dismiss ---
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
const screenWidth = Dimensions.get("window").width; const screenWidth = Dimensions.get('window').width;
const exit = useCallback(() => { const exit = useCallback(() => {
onExit(); onExit();
@@ -314,15 +311,12 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
.failOffsetX([-30, 30]) .failOffsetX([-30, 30])
.failOffsetY(-20) .failOffsetY(-20)
.onUpdate((e) => { .onUpdate((e) => {
"worklet"; 'worklet';
translateY.value = Math.max(0, e.translationY); translateY.value = Math.max(0, e.translationY);
}) })
.onEnd((e) => { .onEnd((e) => {
"worklet"; 'worklet';
if ( if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) {
e.translationY > DISMISS_DISTANCE ||
e.velocityY > DISMISS_VELOCITY
) {
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 }); translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
runOnJS(exit)(); runOnJS(exit)();
} else { } else {
@@ -341,7 +335,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
.failOffsetX([-30, 30]) .failOffsetX([-30, 30])
.failOffsetY(20) .failOffsetY(20)
.onEnd((e) => { .onEnd((e) => {
"worklet"; 'worklet';
if ( if (
e.translationY < -REACTIONS_DISTANCE || e.translationY < -REACTIONS_DISTANCE ||
e.velocityY < -REACTIONS_VELOCITY e.velocityY < -REACTIONS_VELOCITY
@@ -355,7 +349,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
.maxDuration(180) .maxDuration(180)
.maxDistance(15) .maxDistance(15)
.onEnd((e, success) => { .onEnd((e, success) => {
"worklet"; 'worklet';
if (!success) return; if (!success) return;
const ratio = e.x / screenWidth; const ratio = e.x / screenWidth;
runOnJS(handleTap)(ratio); runOnJS(handleTap)(ratio);
@@ -366,15 +360,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
.minDuration(180) .minDuration(180)
.maxDistance(15) .maxDistance(15)
.onStart(() => { .onStart(() => {
"worklet"; 'worklet';
runOnJS(setHoldActive)(true); runOnJS(setHoldActive)(true);
}) })
.onTouchesUp(() => { .onTouchesUp(() => {
"worklet"; 'worklet';
runOnJS(setHoldActive)(false); runOnJS(setHoldActive)(false);
}) })
.onFinalize(() => { .onFinalize(() => {
"worklet"; 'worklet';
runOnJS(setHoldActive)(false); runOnJS(setHoldActive)(false);
}); });
@@ -441,7 +435,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
); );
} }
switch (particle.type) { switch (particle.type) {
case "text": case 'text':
return ( return (
<TextParticleView <TextParticleView
key={particle.id} key={particle.id}
@@ -451,7 +445,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
onProgress={setProgress} onProgress={setProgress}
/> />
); );
case "media": case 'media':
return ( return (
<MediaParticleView <MediaParticleView
key={particle.id} key={particle.id}
@@ -612,7 +606,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
humans={network?.humans ?? []} humans={network?.humans ?? []}
videoFit={videoFit} videoFit={videoFit}
onToggleVideoFit={() => onToggleVideoFit={() =>
setVideoFit((v) => (v === "cover" ? "contain" : "cover")) setVideoFit((v) => (v === 'cover' ? 'contain' : 'cover'))
} }
onOpenMembers={() => setMembersOpen(true)} onOpenMembers={() => setMembersOpen(true)}
onOpenActions={() => setActionsOpen(true)} onOpenActions={() => setActionsOpen(true)}
@@ -637,15 +631,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
{currentParticle && {currentParticle &&
!composing && !composing &&
!isParticleDeleted(currentParticle) && !isParticleDeleted(currentParticle) &&
(currentParticle.type === "media" || (currentParticle.type === 'media' ||
currentParticle.type === "text") ? ( currentParticle.type === 'text') ? (
<View <View
pointerEvents="box-none" pointerEvents="box-none"
className="absolute right-3" className="absolute right-3"
style={{ style={{
top: insets.top + 100, top: insets.top + 100,
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40, bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
justifyContent: "center", justifyContent: 'center',
}} }}
> >
<ReactionStack <ReactionStack
@@ -660,7 +654,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
{/* Safe-area sentinel for top notch kept outside GestureDetector so {/* Safe-area sentinel for top notch kept outside GestureDetector so
iOS's status-bar tap doesn't fight our gestures. */} iOS's status-bar tap doesn't fight our gestures. */}
<SafeAreaView edges={["top"]} pointerEvents="none" /> <SafeAreaView edges={['top']} pointerEvents="none" />
{/* Compose dock + recording overlays. Sits above the GestureDetector {/* Compose dock + recording overlays. Sits above the GestureDetector
so its hold-FAB pan gesture isn't competed-with by the StreamView so its hold-FAB pan gesture isn't competed-with by the StreamView
@@ -686,7 +680,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
open={actionsOpen} open={actionsOpen}
onClose={() => setActionsOpen(false)} onClose={() => setActionsOpen(false)}
onSelect={(action) => void handleStreamAction(action)} onSelect={(action) => void handleStreamAction(action)}
streamStatus={streamParticle.status ?? "open"} streamStatus={streamParticle.status ?? 'open'}
isCreator={isCreator} isCreator={isCreator}
canEditParticle={canEditCurrentParticle} canEditParticle={canEditCurrentParticle}
canDeleteParticle={canDeleteCurrentParticle} canDeleteParticle={canDeleteCurrentParticle}
@@ -1,14 +1,14 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { ActivityIndicator, Pressable, Text, View } from 'react-native';
import { StatusBar } from "expo-status-bar"; import { StatusBar } from 'expo-status-bar';
import type { RootStackScreenProps } from "@/navigation/types"; import type { RootStackScreenProps } from '@/navigation/types';
import { particlePath } from "@/lib/particle-path"; import { particlePath } from '@/lib/particle-path';
import { useLiveParticle } from "@/hooks/use-particle"; import { useLiveParticle } from '@/hooks/use-particle';
import { StreamView } from "./StreamView"; import { StreamView } from './StreamView';
export function StreamViewScreen({ export function StreamViewScreen({
navigation, navigation,
route, route,
}: RootStackScreenProps<"StreamView">) { }: RootStackScreenProps<'StreamView'>) {
const { networkId, streamId } = route.params; const { networkId, streamId } = route.params;
const streamPath = particlePath(networkId, [streamId]); const streamPath = particlePath(networkId, [streamId]);
const { particle, isLoading, error } = useLiveParticle(streamPath); const { particle, isLoading, error } = useLiveParticle(streamPath);
@@ -22,16 +22,19 @@ export function StreamViewScreen({
); );
} }
if (error || !particle || particle.type !== "stream") { if (error || !particle || particle.type !== 'stream') {
return ( return (
<View className="flex-1 bg-black items-center justify-center px-8"> <View className="flex-1 bg-black items-center justify-center px-8">
<StatusBar style="light" hidden /> <StatusBar style="light" hidden />
<Text className="text-white/70 text-center"> <Text className="text-white/70 text-center">
{error {error
? "Couldn't load this stream." ? "Couldn't load this stream."
: "This stream is no longer available."} : 'This stream is no longer available.'}
</Text> </Text>
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2"> <Pressable
onPress={() => navigation.goBack()}
className="mt-6 px-4 py-2"
>
<Text className="text-white/60">Close</Text> <Text className="text-white/60">Close</Text>
</Pressable> </Pressable>
</View> </View>
@@ -1,12 +1,12 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from 'react';
import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native"; import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked"; 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 { RelativeTimestamp } from "@/components/RelativeTimestamp"; import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useStreamSafeArea } from "./stream-safe-area"; import { useStreamSafeArea } from './stream-safe-area';
type TextParticle = Extract<Particle, { type: "text" }>; type TextParticle = Extract<Particle, { type: 'text' }>;
interface TextParticleViewProps { interface TextParticleViewProps {
particle: TextParticle; particle: TextParticle;
@@ -31,11 +31,9 @@ function computeReadDuration(text: string): number {
} }
function getImmersiveStyle(length: number) { function getImmersiveStyle(length: number) {
if (length < 30) if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
return { className: "text-5xl font-semibold leading-tight" }; if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
if (length < 70) return { className: 'text-2xl font-normal leading-snug' };
return { className: "text-3xl font-semibold leading-snug" };
return { className: "text-2xl font-normal leading-snug" };
} }
// Mirrors desktop's text-particle-view: short plain notes get the immersive // Mirrors desktop's text-particle-view: short plain notes get the immersive
@@ -59,7 +57,7 @@ function withTaskCheckboxes(markdown: string): string {
return markdown.replace( return markdown.replace(
TASK_ITEM_RE, TASK_ITEM_RE,
(_match, indent: string, mark: string) => (_match, indent: string, mark: string) =>
`${indent}${mark === " " ? "☐" : "☑"} `, `${indent}${mark === ' ' ? '☐' : '☑'} `,
); );
} }
@@ -72,11 +70,11 @@ function withTaskCheckboxes(markdown: string): string {
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe // Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They // uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1. // render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = "rgba(255,255,255,0.92)"; const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = "#60a5fa"; const ACCENT = '#60a5fa';
const SURFACE = "rgba(24,24,28,0.96)"; const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = "rgba(255,255,255,0.2)"; const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
const MARKDOWN_THEME = { const MARKDOWN_THEME = {
colors: { colors: {
@@ -90,26 +88,88 @@ const MARKDOWN_THEME = {
const MARKDOWN_STYLES: MarkedStyles = { const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: "700" }, strong: { fontWeight: '700' },
em: { fontStyle: "italic" }, em: { fontStyle: 'italic' },
strikethrough: { strikethrough: {
textDecorationLine: "line-through", textDecorationLine: 'line-through',
color: "rgba(255,255,255,0.6)", color: 'rgba(255,255,255,0.6)',
}, },
// fontStyle "normal" cancels react-native-marked's italic-by-default for // fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic). // links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: "normal" }, link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule, // borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have. // which desktop's headings don't have.
h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 }, h1: {
h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 }, color: '#ffffff',
h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 }, fontSize: 28,
h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 }, lineHeight: 34,
h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 }, fontWeight: '700',
h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 }, marginTop: 8,
codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" }, marginBottom: 8,
code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 }, borderBottomWidth: 0,
blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 }, },
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 // hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE). // themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 }, table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
@@ -187,7 +247,10 @@ export function TextParticleView({
// Immersive (short, plain): centered, large type — feels like a lock-screen // Immersive (short, plain): centered, large type — feels like a lock-screen
// note. Short messages that contain markdown fall through to the rendered // note. Short messages that contain markdown fall through to the rendered
// card so formatting isn't shown as raw syntax. // card so formatting isn't shown as raw syntax.
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasMarkdownFormatting(content)) { if (
content.length < IMMERSIVE_CHAR_LIMIT &&
!hasMarkdownFormatting(content)
) {
const style = getImmersiveStyle(content.length); const style = getImmersiveStyle(content.length);
return ( return (
<View <View
@@ -198,7 +261,7 @@ export function TextParticleView({
}} }}
> >
<Text <Text
className={cn("text-white text-center max-w-xl", style.className)} className={cn('text-white text-center max-w-xl', style.className)}
> >
{content} {content}
</Text> </Text>
@@ -1,9 +1,9 @@
import { useMemo, useRef } from "react"; import { useMemo, useState } from 'react';
import { Text, View } from "react-native"; import { Text, View } from 'react-native';
import type { Transcript } from "@/api/types"; import type { Transcript } from '@/api/types';
type Sentence = Transcript["paragraphs"][number]["sentences"][number]; type Sentence = Transcript['paragraphs'][number]['sentences'][number];
type Word = Transcript["words"][number]; type Word = Transcript['words'][number];
const CHUNK_SIZE = 9; const CHUNK_SIZE = 9;
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
const activeWord = const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null; activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
const lastSpokenWordRef = useRef<Word | null>(null); // Remember the last spoken word so highlights hold during pauses.
if (activeWord) { const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
lastSpokenWordRef.current = activeWord; if (activeWord && activeWord !== lastSpokenWord) {
setLastSpokenWord(activeWord);
} }
const highlightWord = activeWord ?? lastSpokenWordRef.current; const highlightWord = activeWord ?? lastSpokenWord;
const lastChunkRef = useRef<Word[] | null>(null); // The chunk currently being spoken (null during a pause or if not found).
const spokenChunk = useMemo(() => {
const activeChunk = useMemo(() => { if (!activeWord) return null;
if (activeWord) { return (
for (const chunk of chunks) { chunks.find((chunk) =>
if ( chunk.some(
chunk.some( (w) => w.start === activeWord.start && w.end === activeWord.end,
(w) => w.start === activeWord.start && w.end === activeWord.end, ),
) ) ?? null
) { );
lastChunkRef.current = chunk;
return chunk;
}
}
}
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
return lastChunkRef.current;
}
const fallback = chunks[0] ?? null;
lastChunkRef.current = fallback;
return fallback;
}, [chunks, activeWord]); }, [chunks, activeWord]);
// Resolve which chunk to display: the spoken one, else hold the last one while
// it's still part of the current sentence, else fall back to the first chunk.
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
let activeChunk: Word[] | null;
if (spokenChunk) {
activeChunk = spokenChunk;
} else if (lastChunk && chunks.includes(lastChunk)) {
activeChunk = lastChunk;
} else {
activeChunk = chunks[0] ?? null;
}
if (activeChunk !== lastChunk) {
setLastChunk(activeChunk);
}
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null; if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return ( return (
@@ -87,10 +92,10 @@ export function TranscriptOverlay({
<Text <Text
key={`${word.start}-${i}`} key={`${word.start}-${i}`}
className={ className={
isSpoken ? "text-white font-medium" : "text-white/40" isSpoken ? 'text-white font-medium' : 'text-white/40'
} }
> >
{i > 0 ? " " : ""} {i > 0 ? ' ' : ''}
{word.word} {word.word}
</Text> </Text>
); );
@@ -7,11 +7,11 @@ import {
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from 'react';
import { useChannel } from "@/hooks/use-channel"; import { useChannel } from '@/hooks/use-channel';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
export type ComposingMode = "recording" | "typing" | "screen"; export type ComposingMode = 'recording' | 'typing' | 'screen';
export interface ComposingUser { export interface ComposingUser {
humanId: string; humanId: string;
@@ -74,14 +74,14 @@ export function StreamPresenceProvider({
if (!payload?.type) continue; if (!payload?.type) continue;
if (msg.humanId === currentUserId) continue; if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) { if (payload.type === 'composing_start' && payload.mode) {
map.set(msg.humanId, { map.set(msg.humanId, {
humanId: msg.humanId, humanId: msg.humanId,
mode: payload.mode as ComposingMode, mode: payload.mode as ComposingMode,
lastSeen: Date.now(), lastSeen: Date.now(),
}); });
changed = true; changed = true;
} else if (payload.type === "composing_stop") { } else if (payload.type === 'composing_stop') {
if (map.delete(msg.humanId)) changed = true; if (map.delete(msg.humanId)) changed = true;
} }
} }
@@ -139,10 +139,10 @@ export function StreamPresenceProvider({
const startComposing = useCallback( const startComposing = useCallback(
(mode: ComposingMode) => { (mode: ComposingMode) => {
sendMessage({ type: "composing_start", mode }); sendMessage({ type: 'composing_start', mode });
if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (heartbeatRef.current) clearInterval(heartbeatRef.current);
heartbeatRef.current = setInterval(() => { heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode }); sendMessage({ type: 'composing_start', mode });
}, COMPOSING_HEARTBEAT_MS); }, COMPOSING_HEARTBEAT_MS);
}, },
[sendMessage], [sendMessage],
@@ -151,7 +151,7 @@ export function StreamPresenceProvider({
const stopComposing = useCallback(() => { const stopComposing = useCallback(() => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (heartbeatRef.current) clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined; heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" }); sendMessage({ type: 'composing_stop' });
}, [sendMessage]); }, [sendMessage]);
useEffect(() => { useEffect(() => {
@@ -181,7 +181,7 @@ function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext); const ctx = useContext(StreamPresenceContext);
if (!ctx) { if (!ctx) {
throw new Error( throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider", 'useStreamPresence must be used within a StreamPresenceProvider',
); );
} }
return ctx; return ctx;
@@ -201,3 +201,17 @@ export function useStreamComposingBroadcast() {
const { startComposing, stopComposing } = useStreamPresenceContext(); const { startComposing, stopComposing } = useStreamPresenceContext();
return { startComposing, stopComposing }; return { startComposing, stopComposing };
} }
/**
* Like {@link useStreamComposingBroadcast}, but returns null instead of throwing
* when rendered outside a provider for callers (e.g. the compose dock) that
* can appear both inside and outside a stream.
*/
export function useStreamComposingBroadcastOptional() {
const ctx = useContext(StreamPresenceContext);
if (!ctx) return null;
return {
startComposing: ctx.startComposing,
stopComposing: ctx.stopComposing,
};
}
@@ -1,4 +1,4 @@
import { createContext, useContext, type ReactNode } from "react"; import { createContext, useContext, type ReactNode } from 'react';
interface StreamSafeArea { interface StreamSafeArea {
/** Pixels from the screen top reserved for the segmented bar + metadata. */ /** Pixels from the screen top reserved for the segmented bar + metadata. */
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { useEvent } from "@/hooks/use-event"; import { useEvent } from '@/hooks/use-event';
export const EXIT_DELAY_MS = 5000; export const EXIT_DELAY_MS = 5000;
export const EXIT_TICK_MS = 100; export const EXIT_TICK_MS = 100;
type PlaybackStatus = "idle" | "playing" | "ended"; type PlaybackStatus = 'idle' | 'playing' | 'ended';
/** /**
* Returns the remaining ms when the stream has ended, or null otherwise. * Returns the remaining ms when the stream has ended, or null otherwise.
@@ -16,18 +16,19 @@ export function useExitCountdown(
onExit: () => void, onExit: () => void,
): number | null { ): number | null {
const [remainingMs, setRemainingMs] = useState<number | null>(null); const [remainingMs, setRemainingMs] = useState<number | null>(null);
const [prevStatus, setPrevStatus] = useState(status);
const handleExit = useEvent(onExit); const handleExit = useEvent(onExit);
useEffect(() => { // Start the countdown when playback ends; clear it on any other transition.
if (status === "ended") { if (status !== prevStatus) {
setRemainingMs(EXIT_DELAY_MS); setPrevStatus(status);
} else { setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
setRemainingMs(null); }
}
}, [status]); const isCounting = remainingMs !== null && remainingMs > 0;
useEffect(() => { useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || paused) return; if (!isCounting || paused) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setRemainingMs((prev) => { setRemainingMs((prev) => {
if (prev === null) return null; if (prev === null) return null;
@@ -36,7 +37,7 @@ export function useExitCountdown(
}); });
}, EXIT_TICK_MS); }, EXIT_TICK_MS);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]); }, [isCounting, paused]);
useEffect(() => { useEffect(() => {
if (remainingMs !== null && remainingMs <= 0) { if (remainingMs !== null && remainingMs <= 0) {
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react';
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@@ -6,24 +6,24 @@ import {
Text, Text,
TextInput, TextInput,
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 { StatusBar } from "expo-status-bar"; import { StatusBar } from 'expo-status-bar';
import { ChevronRight, Globe, Lock, X } from "lucide-react-native"; import { ChevronRight, Globe, Lock, X } from 'lucide-react-native';
import { toast } from "sonner-native"; import { toast } from 'sonner-native';
import { ComposeDock } from "@/features/compose/ComposeDock"; import { ComposeDock } from '@/features/compose/ComposeDock';
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 { generateRandomName } from "@/lib/random-name"; import { generateRandomName } from '@/lib/random-name';
import { createStreamWithFirstParticle } from "@/lib/upload"; import { createStreamWithFirstParticle } from '@/lib/upload';
import { toUserMessage } from "@/lib/errors"; import { toUserMessage } from '@/lib/errors';
import { import {
buildNetworkVisibility, buildNetworkVisibility,
parseVisibleTo, parseVisibleTo,
} from "@/lib/stream-visibility"; } from '@/lib/stream-visibility';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import type { RootStackScreenProps } from "@/navigation/types"; import type { RootStackScreenProps } from '@/navigation/types';
import { VisibilityPickerSheet } from "./VisibilityPickerSheet"; import { VisibilityPickerSheet } from './VisibilityPickerSheet';
const STREAM_NAME_MAX = 60; const STREAM_NAME_MAX = 60;
@@ -35,13 +35,13 @@ const STREAM_NAME_MAX = 60;
export function NewStreamScreen({ export function NewStreamScreen({
route, route,
navigation, navigation,
}: RootStackScreenProps<"NewStream">) { }: RootStackScreenProps<'NewStream'>) {
const { networkId } = route.params; const { networkId } = route.params;
const network = useNetwork(networkId); const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
const suggestion = useMemo(() => generateRandomName(), []); const suggestion = useMemo(() => generateRandomName(), []);
const [name, setName] = useState(""); const [name, setName] = useState('');
const [visibleTo, setVisibleTo] = useState<string[]>(() => const [visibleTo, setVisibleTo] = useState<string[]>(() =>
buildNetworkVisibility(networkId), buildNetworkVisibility(networkId),
); );
@@ -50,18 +50,18 @@ export function NewStreamScreen({
const effectiveName = name.trim() || suggestion; const effectiveName = name.trim() || suggestion;
const handleStreamCreated = (streamId: string) => { const handleStreamCreated = (streamId: string) => {
navigation.replace("StreamView", { networkId, streamId }); navigation.replace('StreamView', { networkId, streamId });
}; };
const submitText = async (content: string) => { const submitText = async (content: string) => {
if (!userId) throw new Error("Not signed in."); if (!userId) throw new Error('Not signed in.');
try { try {
const { streamId } = await createStreamWithFirstParticle({ const { streamId } = await createStreamWithFirstParticle({
networkId, networkId,
name: effectiveName, name: effectiveName,
visibleTo, visibleTo,
createdByHumanId: userId, createdByHumanId: userId,
firstParticle: { type: "text", content }, firstParticle: { type: 'text', content },
}); });
handleStreamCreated(streamId); handleStreamCreated(streamId);
} catch (err) { } catch (err) {
@@ -79,9 +79,9 @@ export function NewStreamScreen({
fileUri: string; fileUri: string;
mimeType: string; mimeType: string;
durationMs: number; durationMs: number;
source: "camera" | "screen"; source: 'camera' | 'screen';
}) => { }) => {
if (!userId) throw new Error("Not signed in."); if (!userId) throw new Error('Not signed in.');
try { try {
const { streamId } = await createStreamWithFirstParticle({ const { streamId } = await createStreamWithFirstParticle({
networkId, networkId,
@@ -89,7 +89,7 @@ export function NewStreamScreen({
visibleTo, visibleTo,
createdByHumanId: userId, createdByHumanId: userId,
firstParticle: { firstParticle: {
type: "media", type: 'media',
fileUri, fileUri,
mimeType, mimeType,
durationMs, durationMs,
@@ -107,17 +107,17 @@ export function NewStreamScreen({
const visibility = parseVisibleTo(visibleTo, networkId); const visibility = parseVisibleTo(visibleTo, networkId);
const visibleSummary = const visibleSummary =
visibility.mode === "network" visibility.mode === 'network'
? `Everyone in ${network?.name ?? "this network"}` ? `Everyone in ${network?.name ?? 'this network'}`
: `${visibility.humanIds.length} ${ : `${visibility.humanIds.length} ${
visibility.humanIds.length === 1 ? "person" : "people" visibility.humanIds.length === 1 ? 'person' : 'people'
}`; }`;
return ( return (
<View className="flex-1 bg-black"> <View className="flex-1 bg-black">
<StatusBar style="light" /> <StatusBar style="light" />
<SafeAreaView edges={["top"]}> <SafeAreaView edges={['top']}>
<View className="flex-row items-center justify-between px-4 pt-3 pb-2"> <View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Pressable <Pressable
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
@@ -126,15 +126,13 @@ export function NewStreamScreen({
> >
<X color="white" size={22} strokeWidth={1.8} /> <X color="white" size={22} strokeWidth={1.8} />
</Pressable> </Pressable>
<Text className="text-white text-base font-semibold"> <Text className="text-white text-base font-semibold">New stream</Text>
New stream
</Text>
<View style={{ width: 22 }} /> <View style={{ width: 22 }} />
</View> </View>
</SafeAreaView> </SafeAreaView>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1" className="flex-1"
> >
<View className="flex-1 px-6 pt-4"> <View className="flex-1 px-6 pt-4">
@@ -159,8 +157,12 @@ export function NewStreamScreen({
onPress={() => setPickerOpen(true)} onPress={() => setPickerOpen(true)}
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3" className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
> >
{visibility.mode === "network" ? ( {visibility.mode === 'network' ? (
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} /> <Globe
color="rgba(255,255,255,0.7)"
size={18}
strokeWidth={1.6}
/>
) : ( ) : (
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} /> <Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
)} )}
@@ -176,7 +178,7 @@ export function NewStreamScreen({
<View className="mt-6 px-1"> <View className="mt-6 px-1">
<Text className="text-white/50 text-sm"> <Text className="text-white/50 text-sm">
Hold the button below to record a voice or video message that's Hold the button below to record a voice or video message thats
the first particle in your new stream. the first particle in your new stream.
</Text> </Text>
</View> </View>
+35 -35
View File
@@ -1,17 +1,17 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from 'react';
import { Pressable, Text, View } from "react-native"; 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 { 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, getInitials } from '@/lib/utils';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
interface StreamCardProps { interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: 'stream'; properties: StreamProperties };
networkId: string; networkId: string;
onPress: () => void; onPress: () => void;
} }
@@ -28,12 +28,12 @@ export const StreamCard = memo(function StreamCard({
}: StreamCardProps) { }: StreamCardProps) {
const streamPath = particlePath(networkId, [particle.id]); const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? ""; const userId = useAuthStore((s) => s.user?.id) ?? '';
const network = useNetwork(networkId); const network = useNetwork(networkId);
const isDM = const isDM =
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(() => { const initials = useMemo(() => {
if (isDM) { if (isDM) {
@@ -41,7 +41,7 @@ export const StreamCard = memo(function StreamCard({
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace("human:", ""); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email); if (otherHuman) return getInitials(otherHuman.email);
} }
@@ -73,45 +73,45 @@ export const StreamCard = memo(function StreamCard({
}, [latestChild, particle.playback_markers, userId]); }, [latestChild, particle.playback_markers, userId]);
const previewLabel = useMemo(() => { const previewLabel = useMemo(() => {
if (!latestChild) return "No messages yet"; if (!latestChild) return 'No messages yet';
if (isParticleDeleted(latestChild)) return "Message deleted"; if (isParticleDeleted(latestChild)) return 'Message deleted';
switch (latestChild.type) { switch (latestChild.type) {
case "media": { case 'media': {
const mime = latestChild.properties.mime_type; const mime = latestChild.properties.mime_type;
if (mime.startsWith("image/")) return "Photo"; if (mime.startsWith('image/')) return 'Photo';
const transcriptText = latestChild.properties.transcript?.transcript; const transcriptText = latestChild.properties.transcript?.transcript;
if (transcriptText) return transcriptText; if (transcriptText) return transcriptText;
return mime.startsWith("audio/") ? "Voice note" : "Video clip"; return mime.startsWith('audio/') ? 'Voice note' : 'Video clip';
} }
case "text": case 'text':
return latestChild.properties.content; return latestChild.properties.content;
case "file": case 'file':
return latestChild.properties.filename; return latestChild.properties.filename;
case "quest": case 'quest':
return latestChild.properties.title; return latestChild.properties.title;
case "paper": case 'paper':
return latestChild.properties.title; return latestChild.properties.title;
default: default:
return "Update"; return 'Update';
} }
}, [latestChild]); }, [latestChild]);
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
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 <View
className={cn( className={cn(
"h-10 w-10 items-center justify-center rounded-full", 'h-10 w-10 items-center justify-center rounded-full',
isUnseen ? "bg-primary" : "bg-muted", isUnseen ? 'bg-primary' : 'bg-muted',
)} )}
> >
<Text <Text
className={cn( className={cn(
"text-xs font-semibold", 'text-xs font-semibold',
isUnseen ? "text-primary-foreground" : "text-muted-foreground", isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
)} )}
> >
{initials} {initials}
@@ -122,10 +122,10 @@ export const StreamCard = memo(function StreamCard({
<Text <Text
numberOfLines={1} numberOfLines={1}
className={cn( className={cn(
"text-base", 'text-base',
isUnseen isUnseen
? "text-foreground font-semibold" ? 'text-foreground font-semibold'
: "text-foreground font-medium", : 'text-foreground font-medium',
)} )}
> >
{particle.properties.name} {particle.properties.name}
@@ -143,8 +143,8 @@ export const StreamCard = memo(function StreamCard({
<RelativeTimestamp <RelativeTimestamp
date={latestChild.created_at} date={latestChild.created_at}
className={cn( className={cn(
"text-xs", 'text-xs',
isUnseen ? "text-primary" : "text-muted-foreground", isUnseen ? 'text-primary' : 'text-muted-foreground',
)} )}
/> />
) : null} ) : null}
@@ -4,33 +4,33 @@ import {
Pressable, Pressable,
Text, Text,
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 { 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';
import { useNetwork } from "@/hooks/use-networks"; import { useNetwork } from '@/hooks/use-networks';
import { useStreamParticles } from "@/hooks/use-stream-particles"; import { useStreamParticles } from '@/hooks/use-stream-particles';
import type { RootStackScreenProps } from "@/navigation/types"; import type { RootStackScreenProps } from '@/navigation/types';
import { StreamCard } from "./StreamCard"; import { StreamCard } from './StreamCard';
export function StreamListScreen({ export function StreamListScreen({
route, route,
navigation, navigation,
}: RootStackScreenProps<"StreamList">) { }: RootStackScreenProps<'StreamList'>) {
const { networkId } = route.params; const { networkId } = route.params;
const network = useNetwork(networkId); const network = useNetwork(networkId);
const path = particlePath(networkId, []); const path = particlePath(networkId, []);
const { streams, isLoading, error } = useStreamParticles(path, { const { streams, isLoading, error } = useStreamParticles(path, {
status: "open", status: 'open',
}); });
return ( return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}> <SafeAreaView className="flex-1 bg-background" edges={['top']}>
<Header <Header
title={network?.name ?? "Streams"} title={network?.name ?? 'Streams'}
onBack={() => navigation.goBack()} onBack={() => navigation.goBack()}
/> />
@@ -50,7 +50,7 @@ export function StreamListScreen({
particle={item} particle={item}
networkId={networkId} networkId={networkId}
onPress={() => onPress={() =>
navigation.navigate("StreamView", { navigation.navigate('StreamView', {
networkId, networkId,
streamId: item.id, streamId: item.id,
}) })
@@ -61,19 +61,13 @@ export function StreamListScreen({
)} )}
<ComposeFab <ComposeFab
onPress={() => navigation.navigate("NewStream", { networkId })} onPress={() => navigation.navigate('NewStream', { networkId })}
/> />
</SafeAreaView> </SafeAreaView>
); );
} }
function Header({ function Header({ title, onBack }: { title: string; onBack: () => void }) {
title,
onBack,
}: {
title: string;
onBack: () => 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
@@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from 'react';
import { Pressable, ScrollView, Text, View } from "react-native"; import { Pressable, ScrollView, Text, View } from 'react-native';
import { Check, Globe, Lock, X } from "lucide-react-native"; import { Check, Globe, Lock, X } from 'lucide-react-native';
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { resolveHumanDisplay } from "@/lib/humans"; import { resolveHumanDisplay } from '@/lib/humans';
import { import {
buildCustomVisibility, buildCustomVisibility,
buildNetworkVisibility, buildNetworkVisibility,
parseVisibleTo, parseVisibleTo,
} from "@/lib/stream-visibility"; } from '@/lib/stream-visibility';
import { BottomSheet } from "@/components/BottomSheet"; import { BottomSheet } from '@/components/BottomSheet';
import { Avatar } from "@/components/Avatar"; import { Avatar } from '@/components/Avatar';
interface VisibilityPickerSheetProps { interface VisibilityPickerSheetProps {
open: boolean; open: boolean;
@@ -43,18 +43,20 @@ export function VisibilityPickerSheet({
[visibleTo, networkId], [visibleTo, networkId],
); );
const [mode, setMode] = useState<"network" | "custom">(initial.mode); const [mode, setMode] = useState<'network' | 'custom'>(initial.mode);
const [selected, setSelected] = useState<Set<string>>( const [selected, setSelected] = useState<Set<string>>(
() => new Set(initial.mode === "custom" ? initial.humanIds : []), () => new Set(initial.mode === 'custom' ? initial.humanIds : []),
); );
useEffect(() => { // Re-seed from the committed value each time the sheet opens fresh.
if (!open) return; const [prevOpen, setPrevOpen] = useState(open);
setMode(initial.mode); if (open !== prevOpen) {
setSelected( setPrevOpen(open);
new Set(initial.mode === "custom" ? initial.humanIds : []), if (open) {
); setMode(initial.mode);
}, [open, initial]); setSelected(new Set(initial.mode === 'custom' ? initial.humanIds : []));
}
}
const others = humans.filter((h) => h.id !== selfHumanId); const others = humans.filter((h) => h.id !== selfHumanId);
@@ -68,7 +70,7 @@ export function VisibilityPickerSheet({
}; };
const commit = () => { const commit = () => {
if (mode === "network") { if (mode === 'network') {
onChange(buildNetworkVisibility(networkId)); onChange(buildNetworkVisibility(networkId));
} else { } else {
const ids = selfHumanId const ids = selfHumanId
@@ -80,7 +82,7 @@ export function VisibilityPickerSheet({
}; };
const customCount = selected.size + (selfHumanId ? 1 : 0); const customCount = selected.size + (selfHumanId ? 1 : 0);
const canCommit = mode === "network" || customCount >= 2; const canCommit = mode === 'network' || customCount >= 2;
return ( return (
<BottomSheet open={open} onClose={onClose} maxHeight="80%"> <BottomSheet open={open} onClose={onClose} maxHeight="80%">
@@ -92,8 +94,8 @@ export function VisibilityPickerSheet({
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}> <Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
<Text <Text
className={cn( className={cn(
"text-base font-semibold", 'text-base font-semibold',
canCommit ? "text-white" : "text-white/30", canCommit ? 'text-white' : 'text-white/30',
)} )}
> >
Done Done
@@ -104,31 +106,31 @@ export function VisibilityPickerSheet({
<View className="px-5 pb-3"> <View className="px-5 pb-3">
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1"> <View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
<ModePill <ModePill
active={mode === "network"} active={mode === 'network'}
icon={<Globe color="white" size={14} />} icon={<Globe color="white" size={14} />}
label="Everyone" label="Everyone"
onPress={() => setMode("network")} onPress={() => setMode('network')}
/> />
<ModePill <ModePill
active={mode === "custom"} active={mode === 'custom'}
icon={<Lock color="white" size={14} />} icon={<Lock color="white" size={14} />}
label="Specific people" label="Specific people"
onPress={() => setMode("custom")} onPress={() => setMode('custom')}
/> />
</View> </View>
</View> </View>
{mode === "network" ? ( {mode === 'network' ? (
<View className="px-5 pb-6"> <View className="px-5 pb-6">
<Text className="text-white/60 text-sm"> <Text className="text-white/60 text-sm">
Everyone in {networkName ?? "this network"} can see this stream. Everyone in {networkName ?? 'this network'} can see this stream.
</Text> </Text>
</View> </View>
) : ( ) : (
<ScrollView contentContainerClassName="px-2 pb-4"> <ScrollView contentContainerClassName="px-2 pb-4">
{others.length === 0 ? ( {others.length === 0 ? (
<Text className="text-white/50 text-sm px-3 py-4"> <Text className="text-white/50 text-sm px-3 py-4">
You're the only member of this network. Invite people on desktop, Youre the only member of this network. Invite people on desktop,
then come back to choose specific viewers. then come back to choose specific viewers.
</Text> </Text>
) : ( ) : (
@@ -140,15 +142,11 @@ export function VisibilityPickerSheet({
key={human.id} key={human.id}
onPress={() => toggle(human.id)} onPress={() => toggle(human.id)}
className={cn( className={cn(
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg", 'flex-row items-center gap-3 px-3 py-2.5 rounded-lg',
isSelected ? "bg-white/10" : "active:bg-white/5", isSelected ? 'bg-white/10' : 'active:bg-white/5',
)} )}
> >
<Avatar <Avatar humanId={human.id} humans={humans} size="sm" />
humanId={human.id}
humans={humans}
size="sm"
/>
<View className="flex-1"> <View className="flex-1">
<Text <Text
className="text-white text-sm font-medium" className="text-white text-sm font-medium"
@@ -156,19 +154,14 @@ export function VisibilityPickerSheet({
> >
{display.displayName} {display.displayName}
</Text> </Text>
<Text <Text className="text-white/40 text-xs" numberOfLines={1}>
className="text-white/40 text-xs"
numberOfLines={1}
>
{display.email} {display.email}
</Text> </Text>
</View> </View>
<View <View
className={cn( className={cn(
"h-6 w-6 items-center justify-center rounded-full border", 'h-6 w-6 items-center justify-center rounded-full border',
isSelected isSelected ? 'bg-white border-white' : 'border-white/30',
? "bg-white border-white"
: "border-white/30",
)} )}
> >
{isSelected ? ( {isSelected ? (
@@ -200,15 +193,15 @@ function ModePill({
<Pressable <Pressable
onPress={onPress} onPress={onPress}
className={cn( className={cn(
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2", 'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2',
active ? "bg-white/15" : "", active ? 'bg-white/15' : '',
)} )}
> >
{icon} {icon}
<Text <Text
className={cn( className={cn(
"text-xs", 'text-xs',
active ? "text-white font-semibold" : "text-white/60", active ? 'text-white font-semibold' : 'text-white/60',
)} )}
> >
{label} {label}
+5 -5
View File
@@ -1,4 +1,4 @@
import { initializeApp } from "firebase/app"; import { initializeApp } from 'firebase/app';
import { import {
initializeAuth, initializeAuth,
// `getReactNativePersistence` is documented Firebase RN setup but Firebase // `getReactNativePersistence` is documented Firebase RN setup but Firebase
@@ -7,10 +7,10 @@ import {
// platform; this is the workaround the Firebase docs themselves use. // platform; this is the workaround the Firebase docs themselves use.
// @ts-expect-error — RN-only symbol missing from public Firebase types. // @ts-expect-error — RN-only symbol missing from public Firebase types.
getReactNativePersistence, getReactNativePersistence,
} from "firebase/auth"; } from 'firebase/auth';
import { initializeFirestore } from "firebase/firestore"; import { initializeFirestore } from 'firebase/firestore';
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from '@react-native-async-storage/async-storage';
import { appConfig } from "@/config/env"; import { appConfig } from '@/config/env';
export const firebaseApp = initializeApp(appConfig.firebase); export const firebaseApp = initializeApp(appConfig.firebase);
+15 -16
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from 'react';
import { usePusherClient } from "@/lib/pusher-provider"; import { usePusherClient } from '@/lib/pusher-provider';
import type { ChannelMessage } from "@/lib/pusher-client"; import type { ChannelMessage } from '@/lib/pusher-client';
interface UseChannelResult { interface UseChannelResult {
/** Current set of humanIds present in the channel */ /** Current set of humanIds present in the channel */
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
const [messages, setMessages] = useState<ChannelMessage[]>([]); const [messages, setMessages] = useState<ChannelMessage[]>([]);
useEffect(() => { useEffect(() => {
if (!client || !channelId) { if (!client || !channelId) return;
setPresence([]);
setMessages([]);
return;
}
client.subscribe(channelId); client.subscribe(channelId);
@@ -58,17 +54,20 @@ export function useChannel(channelId: string | null): UseChannelResult {
} }
}; };
client.on(channelId, "subscribed", onSubscribed); client.on(channelId, 'subscribed', onSubscribed);
client.on(channelId, "join", onJoin); client.on(channelId, 'join', onJoin);
client.on(channelId, "leave", onLeave); client.on(channelId, 'leave', onLeave);
client.on(channelId, "message", onMessage); client.on(channelId, 'message', onMessage);
return () => { return () => {
client.off(channelId, "subscribed", onSubscribed); client.off(channelId, 'subscribed', onSubscribed);
client.off(channelId, "join", onJoin); client.off(channelId, 'join', onJoin);
client.off(channelId, "leave", onLeave); client.off(channelId, 'leave', onLeave);
client.off(channelId, "message", onMessage); client.off(channelId, 'message', onMessage);
client.unsubscribe(channelId); client.unsubscribe(channelId);
// Clear on teardown so a new channel doesn't briefly show stale data.
setPresence([]);
setMessages([]);
}; };
}, [client, channelId]); }, [client, channelId]);
+1 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useRef } from "react"; import { useCallback, useLayoutEffect, useRef } from 'react';
// Polyfill for React's `useEffectEvent` (canary). The returned function has a // Polyfill for React's `useEffectEvent` (canary). The returned function has a
// stable identity but always sees the latest closure — exactly what // stable identity but always sees the latest closure — exactly what
+4 -4
View File
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from '@tanstack/react-query';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
export function useNetworks() { export function useNetworks() {
return useQuery({ return useQuery({
queryKey: ["networks"], queryKey: ['networks'],
queryFn: () => apiClient.listNetworks(), queryFn: () => apiClient.listNetworks(),
meta: { toastOnError: true }, meta: { toastOnError: true },
}); });
+40 -33
View File
@@ -1,20 +1,20 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { useQuery } from "@tanstack/react-query"; import { useQuery } from '@tanstack/react-query';
import type { QueryFieldFilterConstraint } from "firebase/firestore"; import type { QueryFieldFilterConstraint } from 'firebase/firestore';
import { import {
subscribeToParticle, subscribeToParticle,
subscribeToParticleChildren, subscribeToParticleChildren,
subscribeToLatestChild, subscribeToLatestChild,
getParticle, getParticle,
getParticleChildren, getParticleChildren,
} from "@/lib/firestore-particles"; } from '@/lib/firestore-particles';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { import {
type ParticlePath, type ParticlePath,
toFirestoreDocPath, toFirestoreDocPath,
toFirestoreChildrenPath, toFirestoreChildrenPath,
} from "@/lib/particle-path"; } from '@/lib/particle-path';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
interface UseLiveParticleResult { interface UseLiveParticleResult {
particle: Particle | null; particle: Particle | null;
@@ -28,10 +28,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
useEffect(() => { useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path); const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle( const unsubscribe = subscribeToParticle(
docPath, docPath,
@@ -45,7 +41,13 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
}, },
); );
return unsubscribe; return () => {
unsubscribe();
// Reset on teardown so a new path doesn't flash the previous particle.
setIsLoading(true);
setError(null);
setParticle(null);
};
}, [path]); }, [path]);
return { particle, isLoading, error }; return { particle, isLoading, error };
@@ -59,7 +61,7 @@ interface UseLiveParticleChildrenResult {
interface UseLiveParticleChildrenParams { interface UseLiveParticleChildrenParams {
orderByField?: string; orderByField?: string;
orderDirection?: "asc" | "desc"; orderDirection?: 'asc' | 'desc';
visibilityScopes?: string[]; visibilityScopes?: string[];
onAdded?: (child: Particle) => void; onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
@@ -71,8 +73,8 @@ interface UseLiveParticleChildrenParams {
export function useLiveParticleChildren( export function useLiveParticleChildren(
path: ParticlePath | undefined, path: ParticlePath | undefined,
{ {
orderByField = "created_at", orderByField = 'created_at',
orderDirection = "desc", orderDirection = 'desc',
visibilityScopes, visibilityScopes,
onAdded, onAdded,
onRemoved, onRemoved,
@@ -85,15 +87,7 @@ export function useLiveParticleChildren(
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
useEffect(() => { useEffect(() => {
if (!path) { if (!path) return;
setChildren([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
setChildren([]);
const collectionPath = toFirestoreChildrenPath(path); const collectionPath = toFirestoreChildrenPath(path);
@@ -103,7 +97,7 @@ export function useLiveParticleChildren(
setIsLoading(false); setIsLoading(false);
}, },
onError: (err) => { onError: (err) => {
logError(err, { scope: "firestore.particle-children", path }); logError(err, { scope: 'firestore.particle-children', path });
setError(err); setError(err);
setIsLoading(false); setIsLoading(false);
}, },
@@ -116,13 +110,24 @@ export function useLiveParticleChildren(
limit, limit,
}); });
return unsubscribe; return () => {
unsubscribe();
// Reset on teardown so a new path doesn't flash the previous children.
setChildren([]);
setError(null);
setIsLoading(true);
};
// The hook intentionally keys only on path/whereFilter/limit — desktop // The hook intentionally keys only on path/whereFilter/limit — desktop
// does the same. Visibility scope changes are absorbed by the active // does the same. Visibility scope changes are absorbed by the active
// listener; reordering causes a re-subscription. // listener; reordering causes a re-subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, whereFilter, limit]); }, [path, whereFilter, limit]);
// No path: nothing to load, so report an empty non-loading state.
if (!path) {
return { children: [], isLoading: false, error: null };
}
return { children, isLoading, error }; return { children, isLoading, error };
} }
@@ -138,9 +143,6 @@ export function useLiveLatestChild(
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild( const unsubscribe = subscribeToLatestChild(
toFirestoreChildrenPath(path), toFirestoreChildrenPath(path),
(data) => { (data) => {
@@ -148,12 +150,17 @@ export function useLiveLatestChild(
setIsLoading(false); setIsLoading(false);
}, },
(err) => { (err) => {
logError(err, { scope: "firestore.latest-child", path }); logError(err, { scope: 'firestore.latest-child', path });
setIsLoading(false); setIsLoading(false);
}, },
); );
return unsubscribe; return () => {
unsubscribe();
// Reset on teardown so a new path doesn't flash the previous child.
setIsLoading(true);
setLatestChild(null);
};
}, [path]); }, [path]);
return { latestChild, isLoading }; return { latestChild, isLoading };
@@ -161,7 +168,7 @@ export function useLiveLatestChild(
export function useParticle(path?: ParticlePath) { export function useParticle(path?: ParticlePath) {
return useQuery({ return useQuery({
queryKey: ["particle", path], queryKey: ['particle', path],
queryFn: async () => { queryFn: async () => {
if (!path) return null; if (!path) return null;
const docPath = toFirestoreDocPath(path); const docPath = toFirestoreDocPath(path);
@@ -174,7 +181,7 @@ export function useParticle(path?: ParticlePath) {
export function useParticleChildren(path?: ParticlePath) { export function useParticleChildren(path?: ParticlePath) {
return useQuery({ return useQuery({
queryKey: ["particle-children", path], queryKey: ['particle-children', path],
queryFn: async () => { queryFn: async () => {
if (!path) return []; if (!path) return [];
const collectionPath = toFirestoreChildrenPath(path); const collectionPath = toFirestoreChildrenPath(path);
+24 -22
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from 'react';
import { where, type QueryFieldFilterConstraint } from "firebase/firestore"; import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren } from '@/hooks/use-particle';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle, StreamProperties } from "@/api/types"; import type { Particle, StreamProperties } from '@/api/types';
export type StreamParticle = Particle & { export type StreamParticle = Particle & {
type: "stream"; type: 'stream';
properties: StreamProperties; properties: StreamProperties;
}; };
@@ -15,8 +15,8 @@ const CLOSED_PAGE_INCREMENT = 50;
// Stable where-constraint references so the Firestore subscription only // Stable where-constraint references so the Firestore subscription only
// re-attaches when the tab actually changes, not on every render. // re-attaches when the tab actually changes, not on every render.
const OPEN_STATUS_FILTER = where("status", "==", "open"); const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where("status", "==", "closed"); const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
function useVisibilityScopes(userId?: string, networkId?: string) { function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => { return useMemo(() => {
@@ -33,7 +33,7 @@ interface UseStreamParticlesOptions {
* by active work full realtime coverage is needed for autoplay/huddles). * by active work full realtime coverage is needed for autoplay/huddles).
* Closed streams are paginated via `loadMore`. * Closed streams are paginated via `loadMore`.
*/ */
status: "open" | "closed"; status: 'open' | 'closed';
} }
interface UseStreamParticlesResult { interface UseStreamParticlesResult {
@@ -56,38 +56,40 @@ export function useStreamParticles(
const visibilityScopes = useVisibilityScopes(user?.id, networkId); const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE); const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
const [prevStatus, setPrevStatus] = useState(status);
// Every time the user switches back to the closed tab, start with a fresh // Switching back to the closed tab starts a fresh window, avoiding an
// window. Avoids an ever-growing subscription across a long session. // ever-growing subscription across a long session.
useEffect(() => { if (status !== prevStatus) {
if (status === "closed") { setPrevStatus(status);
if (status === 'closed') {
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE); setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
} }
}, [status]); }
const whereFilter: QueryFieldFilterConstraint = const whereFilter: QueryFieldFilterConstraint =
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const limit = status === "closed" ? closedLimit : undefined; const limit = status === 'closed' ? closedLimit : undefined;
const { children, isLoading, error } = useLiveParticleChildren(path, { const { children, isLoading, error } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at", orderByField: 'last_child_created_at',
orderDirection: "desc", orderDirection: 'desc',
visibilityScopes, visibilityScopes,
whereFilter, whereFilter,
limit, limit,
}); });
const streams = useMemo( const streams = useMemo(
() => children.filter((c): c is StreamParticle => c.type === "stream"), () => children.filter((c): c is StreamParticle => c.type === 'stream'),
[children], [children],
); );
// Heuristic: if we got back as many items as we asked for, assume there // Heuristic: if we got back as many items as we asked for, assume there
// might be more. Clicking load-more when there are no more is a no-op. // might be more. Clicking load-more when there are no more is a no-op.
const canLoadMore = status === "closed" && streams.length >= closedLimit; const canLoadMore = status === 'closed' && streams.length >= closedLimit;
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (status !== "closed") return; if (status !== 'closed') return;
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT); setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
}, [status]); }, [status]);
+57 -45
View File
@@ -1,15 +1,15 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import type { Particle } from "@/api/types"; import type { Particle } from '@/api/types';
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren } from '@/hooks/use-particle';
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; import { updateStreamPlaybackMarker } from '@/lib/firestore-particles';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
import { useEvent } from "@/hooks/use-event"; import { useEvent } from '@/hooks/use-event';
// --- Playback reducer (ID-based) --- // --- Playback reducer (ID-based) ---
type PlaybackStatus = "idle" | "playing" | "ended"; type PlaybackStatus = 'idle' | 'playing' | 'ended';
interface PlaybackState { interface PlaybackState {
currentParticleId: string | null; currentParticleId: string | null;
@@ -18,19 +18,19 @@ interface PlaybackState {
} }
type PlaybackAction = type PlaybackAction =
| { type: "INIT"; particleId: string } | { type: 'INIT'; particleId: string }
| { type: "SET_PARTICLE"; particleId: string } | { type: 'SET_PARTICLE'; particleId: string }
| { type: "END" } | { type: 'END' }
| { type: "PARTICLE_ADDED"; particleId: string } | { type: 'PARTICLE_ADDED'; particleId: string }
| { | {
type: "PARTICLE_REMOVED"; type: 'PARTICLE_REMOVED';
removedParticleId: string; removedParticleId: string;
fallbackParticleId: string | null; fallbackParticleId: string | null;
}; };
const initialState: PlaybackState = { const initialState: PlaybackState = {
currentParticleId: null, currentParticleId: null,
status: "idle", status: 'idle',
initialized: false, initialized: false,
}; };
@@ -39,39 +39,39 @@ function playbackReducer(
action: PlaybackAction, action: PlaybackAction,
): PlaybackState { ): PlaybackState {
switch (action.type) { switch (action.type) {
case "INIT": case 'INIT':
return { return {
currentParticleId: action.particleId, currentParticleId: action.particleId,
status: "playing", status: 'playing',
initialized: true, initialized: true,
}; };
case "SET_PARTICLE": case 'SET_PARTICLE':
return { return {
...state, ...state,
currentParticleId: action.particleId, currentParticleId: action.particleId,
status: "playing", status: 'playing',
}; };
case "END": case 'END':
return { ...state, status: "ended" }; return { ...state, status: 'ended' };
case "PARTICLE_ADDED": case 'PARTICLE_ADDED':
if (state.status === "ended") { if (state.status === 'ended') {
return { return {
...state, ...state,
currentParticleId: action.particleId, currentParticleId: action.particleId,
status: "playing", status: 'playing',
}; };
} }
return state; return state;
case "PARTICLE_REMOVED": case 'PARTICLE_REMOVED':
if (action.removedParticleId !== state.currentParticleId) return state; if (action.removedParticleId !== state.currentParticleId) return state;
if (action.fallbackParticleId) { if (action.fallbackParticleId) {
return { return {
...state, ...state,
currentParticleId: action.fallbackParticleId, currentParticleId: action.fallbackParticleId,
status: "playing", status: 'playing',
}; };
} }
return { ...state, currentParticleId: null, status: "idle" }; return { ...state, currentParticleId: null, status: 'idle' };
} }
} }
@@ -90,33 +90,40 @@ interface UseStreamPlaybackResult {
} }
export function useStreamPlayback( export function useStreamPlayback(
streamParticle: Particle & { type: "stream" }, streamParticle: Particle & { type: 'stream' },
path: ParticlePath, path: ParticlePath,
): UseStreamPlaybackResult { ): UseStreamPlaybackResult {
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
const [state, dispatch] = useReducer(playbackReducer, initialState); const [state, dispatch] = useReducer(playbackReducer, initialState);
// Track which stream we initialized for, so navigating to a sibling resets cleanly. // Track which stream we initialized for, so navigating to a sibling resets cleanly.
const initializedForRef = useRef<string | null>(null); const initializedForRef = useRef<string | null>(null);
// Latest currentIndex for onParticleRemoved, so it reads the current value
// without recreating the callback (which would re-subscribe the listener).
const currentIndexRef = useRef(0);
const onParticleAdded = useCallback((particle: Particle) => { const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id }); dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
}, []); }, []);
const onParticleRemoved = useEvent( const onParticleRemoved = useCallback(
(removed: Particle, updatedChildren: Particle[]) => { (removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1); const fallbackIndex = Math.min(
currentIndexRef.current,
updatedChildren.length - 1,
);
const fallback = updatedChildren[Math.max(0, fallbackIndex)]; const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({ dispatch({
type: "PARTICLE_REMOVED", type: 'PARTICLE_REMOVED',
removedParticleId: removed.id, removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null, fallbackParticleId: fallback?.id ?? null,
}); });
}, },
[],
); );
const { children } = useLiveParticleChildren(path, { const { children } = useLiveParticleChildren(path, {
orderByField: "created_at", orderByField: 'created_at',
orderDirection: "asc", orderDirection: 'asc',
onAdded: onParticleAdded, onAdded: onParticleAdded,
onRemoved: onParticleRemoved, onRemoved: onParticleRemoved,
}); });
@@ -129,10 +136,15 @@ export function useStreamPlayback(
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null; const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
// Keep the latest-index ref in sync for onParticleRemoved (above).
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
const initFallback = useEvent(() => { const initFallback = useEvent(() => {
if (state.initialized || children.length === 0) return; if (state.initialized || children.length === 0) return;
initializedForRef.current = streamParticle.id; initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[0].id }); dispatch({ type: 'INIT', particleId: children[0].id });
}); });
// --- Init logic: runs on every children change until initialized --- // --- Init logic: runs on every children change until initialized ---
@@ -149,11 +161,11 @@ export function useStreamPlayback(
if (children.length === 0) return; if (children.length === 0) return;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; const playbackPosition = streamParticle.playback_markers?.[userId ?? ''];
if (!playbackPosition) { if (!playbackPosition) {
initializedForRef.current = streamParticle.id; initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: children[0].id }); dispatch({ type: 'INIT', particleId: children[0].id });
return; return;
} }
@@ -163,12 +175,12 @@ export function useStreamPlayback(
if (found) { if (found) {
initializedForRef.current = streamParticle.id; initializedForRef.current = streamParticle.id;
dispatch({ type: "INIT", particleId: found.id }); dispatch({ type: 'INIT', particleId: found.id });
return; return;
} else { } else {
initializedForRef.current = streamParticle.id; initializedForRef.current = streamParticle.id;
dispatch({ dispatch({
type: "INIT", type: 'INIT',
particleId: children[children.length - 1].id, particleId: children[children.length - 1].id,
}); });
} }
@@ -201,7 +213,7 @@ export function useStreamPlayback(
lastPersistedMarkerRef.current = currentTime; lastPersistedMarkerRef.current = currentTime;
const streamDocPath = toFirestoreDocPath(path); const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch( updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
(err) => logError(err, { scope: "playback.marker", path }), (err) => logError(err, { scope: 'playback.marker', path }),
); );
// streamParticle.playback_markers is read at effect time; not in deps to // streamParticle.playback_markers is read at effect time; not in deps to
// avoid double-writes when the snapshot we just persisted echoes back. // avoid double-writes when the snapshot we just persisted echoes back.
@@ -213,18 +225,18 @@ export function useStreamPlayback(
if (currentIndex === -1) return; if (currentIndex === -1) return;
if (currentIndex < children.length - 1) { if (currentIndex < children.length - 1) {
dispatch({ dispatch({
type: "SET_PARTICLE", type: 'SET_PARTICLE',
particleId: children[currentIndex + 1].id, particleId: children[currentIndex + 1].id,
}); });
} else { } else {
dispatch({ type: "END" }); dispatch({ type: 'END' });
} }
}, [children, currentIndex]); }, [children, currentIndex]);
const prev = useCallback(() => { const prev = useCallback(() => {
if (currentIndex <= 0) return; if (currentIndex <= 0) return;
dispatch({ dispatch({
type: "SET_PARTICLE", type: 'SET_PARTICLE',
particleId: children[currentIndex - 1].id, particleId: children[currentIndex - 1].id,
}); });
}, [children, currentIndex]); }, [children, currentIndex]);
@@ -232,7 +244,7 @@ export function useStreamPlayback(
const goTo = useCallback( const goTo = useCallback(
(index: number) => { (index: number) => {
if (index >= 0 && index < children.length) { if (index >= 0 && index < children.length) {
dispatch({ type: "SET_PARTICLE", particleId: children[index].id }); dispatch({ type: 'SET_PARTICLE', particleId: children[index].id });
} }
}, },
[children], [children],
@@ -241,7 +253,7 @@ export function useStreamPlayback(
// If the particle isn't in `children` yet (e.g. just-created), the live // If the particle isn't in `children` yet (e.g. just-created), the live
// query will resolve it shortly and the derived index/particle will catch up. // query will resolve it shortly and the derived index/particle will catch up.
const goToParticle = useCallback((particleId: string) => { const goToParticle = useCallback((particleId: string) => {
dispatch({ type: "SET_PARTICLE", particleId }); dispatch({ type: 'SET_PARTICLE', particleId });
}, []); }, []);
return { return {
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useId } from "react"; import { useEffect, useId } from 'react';
import { usePlaybackPauseStore } from "@/stores/playback-pause-store"; import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
/** /**
* Suspend stream playback while `active` is true. The hook owns its own * Suspend stream playback while `active` is true. The hook owns its own
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from 'react';
import type { Transcript } from "@/api/types"; import type { Transcript } from '@/api/types';
type Sentence = Transcript["paragraphs"][number]["sentences"][number]; type Sentence = Transcript['paragraphs'][number]['sentences'][number];
interface TranscriptPlaybackState { interface TranscriptPlaybackState {
/** The sentence currently being spoken, or null if between sentences */ /** The sentence currently being spoken, or null if between sentences */
+18 -20
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from 'zod';
import { appEnv } from "@/config/env"; import { appEnv } from '@/config/env';
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
@@ -7,7 +7,7 @@ export class ApiError extends Error {
message: string, message: string,
) { ) {
super(message); super(message);
this.name = "ApiError"; this.name = 'ApiError';
} }
} }
@@ -18,42 +18,42 @@ export class ApiError extends Error {
*/ */
export class QuotaExceededError extends Error { export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) { constructor(public readonly networkId: string) {
super("Daily message limit reached"); super('Daily message limit reached');
this.name = "QuotaExceededError"; this.name = 'QuotaExceededError';
} }
} }
function normalizeMessage(message: string): string { function normalizeMessage(message: string): string {
return message.replace(/^Error:\s*/, "").trim(); return message.replace(/^Error:\s*/, '').trim();
} }
export function toUserMessage(err: unknown): string { export function toUserMessage(err: unknown): string {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.status === 401) return "Please sign in again."; if (err.status === 401) return 'Please sign in again.';
if (err.status === 403) return "You don't have permission to do that."; if (err.status === 403) return "You don't have permission to do that.";
if (err.status === 404) return "Not found."; if (err.status === 404) return 'Not found.';
if (err.status === 408 || err.status === 429) { if (err.status === 408 || err.status === 429) {
return "Please try again in a moment."; return 'Please try again in a moment.';
} }
if (err.status >= 500) { if (err.status >= 500) {
return "Something went wrong on our end. Please try again."; return 'Something went wrong on our end. Please try again.';
} }
return normalizeMessage(err.message) || "Request failed."; return normalizeMessage(err.message) || 'Request failed.';
} }
if (err instanceof z.ZodError) { if (err instanceof z.ZodError) {
return "Received unexpected data from the server."; return 'Received unexpected data from the server.';
} }
if (err instanceof TypeError && /fetch|network/i.test(err.message)) { if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
return "Network error. Check your connection."; return 'Network error. Check your connection.';
} }
if (err instanceof Error) { if (err instanceof Error) {
return normalizeMessage(err.message) || "Something went wrong."; return normalizeMessage(err.message) || 'Something went wrong.';
} }
return "Something went wrong."; return 'Something went wrong.';
} }
type ErrorContext = Record<string, unknown>; type ErrorContext = Record<string, unknown>;
@@ -75,16 +75,14 @@ export function installErrorSinks(sinks: {
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */ /** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
export function logError(err: unknown, context?: ErrorContext): void { export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") { if (appEnv === 'dev') {
// eslint-disable-next-line no-console console.error('[error]', err, context ?? {});
console.error("[error]", err, context ?? {});
} }
breadcrumbSink?.(err, context); breadcrumbSink?.(err, context);
} }
/** Unexpected failures the user may not see. Always captured. */ /** Unexpected failures the user may not see. Always captured. */
export function reportError(err: unknown, context?: ErrorContext): void { export function reportError(err: unknown, context?: ErrorContext): void {
// eslint-disable-next-line no-console console.error('[error]', err, context ?? {});
console.error("[error]", err, context ?? {});
captureSink?.(err, context); captureSink?.(err, context);
} }
+38 -38
View File
@@ -21,15 +21,15 @@ import {
type SnapshotOptions, type SnapshotOptions,
type Unsubscribe, type Unsubscribe,
type QueryFieldFilterConstraint, type QueryFieldFilterConstraint,
} from "firebase/firestore"; } from 'firebase/firestore';
import { firestoreDb } from "@/firebase"; import { firestoreDb } from '@/firebase';
import { isContainerType, ParticleSchema } from "@/api/types"; import { isContainerType, ParticleSchema } from '@/api/types';
import type { import type {
Particle, Particle,
ParticleType, ParticleType,
ParticlePropertiesMap, ParticlePropertiesMap,
Reactions, Reactions,
} from "@/api/types"; } from '@/api/types';
// --- Converter --- // --- Converter ---
@@ -37,7 +37,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData { toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle; const { id: _id, created_at, updated_at, ...rest } = particle;
const deletedAt = const deletedAt =
"deleted_at" in particle ? particle.deleted_at : undefined; 'deleted_at' in particle ? particle.deleted_at : undefined;
return { return {
...rest, ...rest,
created_at: Timestamp.fromDate(created_at), created_at: Timestamp.fromDate(created_at),
@@ -50,12 +50,12 @@ const particleConverter: FirestoreDataConverter<Particle> = {
options?: SnapshotOptions, options?: SnapshotOptions,
): Particle { ): Particle {
const raw = snap.data(options); const raw = snap.data(options);
if (typeof raw.type !== "string") { if (typeof raw.type !== 'string') {
throw new Error(`Invalid particle type: ${raw.type}`); throw new Error(`Invalid particle type: ${raw.type}`);
} }
const type = raw.type as ParticleType; const type = raw.type as ParticleType;
switch (type) { switch (type) {
case "stream": case 'stream':
return ParticleSchema.parse({ return ParticleSchema.parse({
id: snap.id, id: snap.id,
type: raw.type, type: raw.type,
@@ -81,7 +81,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
raw.huddle_active_participants ?? undefined, raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined, status: raw.status ?? undefined,
}); });
case "folder": case 'folder':
return ParticleSchema.parse({ return ParticleSchema.parse({
id: snap.id, id: snap.id,
type: raw.type, type: raw.type,
@@ -93,15 +93,15 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: undefined, : undefined,
visible_to: raw.visible_to, visible_to: raw.visible_to,
}); });
case "media": case 'media':
case "file": case 'file':
case "text": case 'text':
case "quest": case 'quest':
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.
const properties = const properties =
type === "text" && raw.properties?.edited_at type === 'text' && raw.properties?.edited_at
? { ? {
...raw.properties, ...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(), edited_at: (raw.properties.edited_at as Timestamp).toDate(),
@@ -165,17 +165,17 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
export interface GetParticleChildrenOptions { export interface GetParticleChildrenOptions {
orderByField: string; orderByField: string;
orderDirection: "asc" | "desc"; orderDirection: 'asc' | 'desc';
} }
export async function getParticleChildren( export async function getParticleChildren(
collectionPath: string, collectionPath: string,
{ {
orderByField = "created_at", orderByField = 'created_at',
orderDirection = "asc", orderDirection = 'asc',
}: GetParticleChildrenOptions = { }: GetParticleChildrenOptions = {
orderByField: "created_at", orderByField: 'created_at',
orderDirection: "asc", orderDirection: 'asc',
}, },
): Promise<Particle[]> { ): Promise<Particle[]> {
const q = query( const q = query(
@@ -191,7 +191,7 @@ export interface SubscribeToParticleChildrenOptions {
onError: (error: Error) => void; onError: (error: Error) => void;
visibilityScopes?: string[]; visibilityScopes?: string[];
orderByField?: string; orderByField?: string;
orderDirection?: "asc" | "desc"; orderDirection?: 'asc' | 'desc';
onAdded?: (child: Particle) => void; onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint; whereFilter?: QueryFieldFilterConstraint;
@@ -205,8 +205,8 @@ export function subscribeToParticleChildren(
onData, onData,
onError, onError,
visibilityScopes = [], visibilityScopes = [],
orderByField = "created_at", orderByField = 'created_at',
orderDirection = "desc", orderDirection = 'desc',
onAdded, onAdded,
onRemoved, onRemoved,
whereFilter, whereFilter,
@@ -218,7 +218,7 @@ export function subscribeToParticleChildren(
orderBy(orderByField, orderDirection), orderBy(orderByField, orderDirection),
); );
if (visibilityScopes.length > 0) { if (visibilityScopes.length > 0) {
q = query(q, where("visible_to", "array-contains-any", visibilityScopes)); q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
} }
if (whereFilter) { if (whereFilter) {
q = query(q, whereFilter); q = query(q, whereFilter);
@@ -234,8 +234,8 @@ export function subscribeToParticleChildren(
if (onAdded || onRemoved) { if (onAdded || onRemoved) {
for (const change of snap.docChanges()) { for (const change of snap.docChanges()) {
if (change.type === "added" && onAdded) onAdded(change.doc.data()); if (change.type === 'added' && onAdded) onAdded(change.doc.data());
if (change.type === "removed" && onRemoved) if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren); onRemoved(change.doc.data(), updatedChildren);
} }
} }
@@ -251,7 +251,7 @@ export function subscribeToLatestChild(
): Unsubscribe { ): Unsubscribe {
const q = query( const q = query(
typedCollection(collectionPath), typedCollection(collectionPath),
orderBy("created_at", "desc"), orderBy('created_at', 'desc'),
limit(1), limit(1),
); );
return onSnapshot( return onSnapshot(
@@ -279,7 +279,7 @@ export async function createParticle<T extends ParticleType>(
} }
const particle: Particle = ParticleSchema.parse({ const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type id: '', // ignored by toFirestore, but needed to satisfy the type
type, type,
properties, properties,
created_at: new Date(), created_at: new Date(),
@@ -292,22 +292,22 @@ export async function createParticle<T extends ParticleType>(
export async function createStreamParticle( export async function createStreamParticle(
collectionPath: string, collectionPath: string,
properties: ParticlePropertiesMap["stream"], properties: ParticlePropertiesMap['stream'],
createdByHumanId: string, createdByHumanId: string,
visibleTo?: string[], visibleTo?: string[],
): Promise<string> { ): Promise<string> {
if (!visibleTo || visibleTo.length === 0) { if (!visibleTo || visibleTo.length === 0) {
throw new Error("visibleTo is required for streams and cannot be empty"); throw new Error('visibleTo is required for streams and cannot be empty');
} }
const particle: Particle = ParticleSchema.parse({ const particle: Particle = ParticleSchema.parse({
id: "", id: '',
type: "stream", type: 'stream',
properties, properties,
created_at: new Date(), created_at: new Date(),
created_by_human_id: createdByHumanId, created_by_human_id: createdByHumanId,
visible_to: visibleTo, visible_to: visibleTo,
status: "open", status: 'open',
}); });
const ref = await addDoc(typedCollection(collectionPath), particle); const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id; return ref.id;
@@ -340,8 +340,8 @@ export async function editTextParticleContent(
): Promise<void> { ): Promise<void> {
const particleRef = typedDoc(docPath); const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { await updateDoc(particleRef, {
"properties.content": content, 'properties.content': content,
"properties.edited_at": serverTimestamp(), 'properties.edited_at': serverTimestamp(),
updated_at: serverTimestamp(), updated_at: serverTimestamp(),
}); });
} }
@@ -383,7 +383,7 @@ export async function updateParticle(
export async function updateStreamStatus( export async function updateStreamStatus(
docPath: string, docPath: string,
status: "open" | "closed", status: 'open' | 'closed',
): Promise<void> { ): Promise<void> {
const particleRef = typedDoc(docPath); const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
@@ -426,7 +426,7 @@ export async function updateStreamPlaybackMarker(
const RESERVED_REACTION_CHARS = /[~*/[\]]/g; const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
export function sanitizeReactionText(text: string): string { export function sanitizeReactionText(text: string): string {
return text.replace(RESERVED_REACTION_CHARS, ""); return text.replace(RESERVED_REACTION_CHARS, '');
} }
export async function toggleParticleReaction( export async function toggleParticleReaction(
@@ -441,9 +441,9 @@ export async function toggleParticleReaction(
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false; const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
await updateDoc( await updateDoc(
particleRef, particleRef,
new FieldPath("reactions", key), new FieldPath('reactions', key),
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
"updated_at", 'updated_at',
serverTimestamp(), serverTimestamp(),
); );
} }
+4 -4
View File
@@ -1,8 +1,8 @@
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
import { getInitials } from "@/lib/utils"; import { getInitials } from '@/lib/utils';
export const REMOVED_MEMBER_LABEL = "Removed member"; export const REMOVED_MEMBER_LABEL = 'Removed member';
export const REMOVED_MEMBER_INITIALS = ""; export const REMOVED_MEMBER_INITIALS = '';
export interface HumanDisplay { export interface HumanDisplay {
/** True when the human was found in the provided list. */ /** True when the human was found in the provided list. */
+10 -10
View File
@@ -1,6 +1,6 @@
import { createNavigationContainerRef } from "@react-navigation/native"; import { createNavigationContainerRef } from '@react-navigation/native';
import type { Notification } from "expo-notifications"; import type { Notification } from 'expo-notifications';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
// Shared ref so non-component code (notification handlers, deep links) can // Shared ref so non-component code (notification handlers, deep links) can
// drive navigation without prop-drilling. Typed via the global // drive navigation without prop-drilling. Typed via the global
@@ -10,7 +10,7 @@ export const navigationRef = createNavigationContainerRef();
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages) // Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
// puts in `Notifications.notification.request.content.data`. // puts in `Notifications.notification.request.content.data`.
type ParticleCreatedData = { type ParticleCreatedData = {
kind: "particle_created"; kind: 'particle_created';
network_id: string; network_id: string;
stream_id: string; stream_id: string;
particle_id: string; particle_id: string;
@@ -20,11 +20,11 @@ type ParticleCreatedData = {
function isParticleCreatedData(data: unknown): data is ParticleCreatedData { function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
return ( return (
typeof data === "object" && typeof data === 'object' &&
data !== null && data !== null &&
(data as { kind?: unknown }).kind === "particle_created" && (data as { kind?: unknown }).kind === 'particle_created' &&
typeof (data as { network_id?: unknown }).network_id === "string" && typeof (data as { network_id?: unknown }).network_id === 'string' &&
typeof (data as { stream_id?: unknown }).stream_id === "string" typeof (data as { stream_id?: unknown }).stream_id === 'string'
); );
} }
@@ -48,7 +48,7 @@ export function routeNotificationTap(notification: Notification): void {
} }
navigateToStream(data); navigateToStream(data);
} catch (err) { } catch (err) {
logError(err, { scope: "push.route" }); logError(err, { scope: 'push.route' });
} }
} }
@@ -66,7 +66,7 @@ export function flushPendingNavigation(): void {
} }
function navigateToStream(data: ParticleCreatedData): void { function navigateToStream(data: ParticleCreatedData): void {
navigationRef.navigate("StreamView", { navigationRef.navigate('StreamView', {
networkId: data.network_id, networkId: data.network_id,
streamId: data.stream_id, streamId: data.stream_id,
}); });
+4 -4
View File
@@ -20,7 +20,7 @@ export function particlePath(
networkId: string, networkId: string,
segments: string[] = [], segments: string[] = [],
): ParticlePath { ): ParticlePath {
return `/${[networkId, ...segments].join("/")}` as ParticlePath; return `/${[networkId, ...segments].join('/')}` as ParticlePath;
} }
/** /**
@@ -30,7 +30,7 @@ export function parseParticlePath(path: ParticlePath): {
networkId: string; networkId: string;
segments: string[]; segments: string[];
} { } {
const parts = path.split("/").filter(Boolean); const parts = path.split('/').filter(Boolean);
return { networkId: parts[0], segments: parts.slice(1) }; return { networkId: parts[0], segments: parts.slice(1) };
} }
@@ -49,9 +49,9 @@ export function toFirestoreDocPath(path: ParticlePath): string {
const parts: string[] = [base, segments[0]]; const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) { for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]); parts.push('children', segments[i]);
} }
return parts.join("/"); return parts.join('/');
} }
/** /**
+18 -18
View File
@@ -1,13 +1,13 @@
import Constants from "expo-constants"; import Constants from 'expo-constants';
import * as Device from "expo-device"; import * as Device from 'expo-device';
import * as Notifications from "expo-notifications"; import * as Notifications from 'expo-notifications';
import * as SecureStore from "expo-secure-store"; import * as SecureStore from 'expo-secure-store';
import { Platform } from "react-native"; import { Platform } from 'react-native';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { logError } from "@/lib/errors"; import { logError } from '@/lib/errors';
import { routeNotificationTap } from "@/lib/notification-routing"; import { routeNotificationTap } from '@/lib/notification-routing';
const STORED_TOKEN_KEY = "expo_push_token"; const STORED_TOKEN_KEY = 'expo_push_token';
let configured = false; let configured = false;
let tokenListenerSubscription: Notifications.Subscription | null = null; let tokenListenerSubscription: Notifications.Subscription | null = null;
@@ -76,18 +76,18 @@ async function acquirePushToken(): Promise<string | null> {
const existing = await Notifications.getPermissionsAsync(); const existing = await Notifications.getPermissionsAsync();
let status = existing.status; let status = existing.status;
if (status !== "granted") { if (status !== 'granted') {
const requested = await Notifications.requestPermissionsAsync(); const requested = await Notifications.requestPermissionsAsync();
status = requested.status; status = requested.status;
} }
if (status !== "granted") return null; if (status !== 'granted') return null;
const projectId = const projectId =
Constants.expoConfig?.extra?.eas?.projectId ?? Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId; Constants.easConfig?.projectId;
if (!projectId) { if (!projectId) {
logError(new Error("EAS projectId missing — cannot fetch push token"), { logError(new Error('EAS projectId missing — cannot fetch push token'), {
scope: "push.acquire", scope: 'push.acquire',
}); });
return null; return null;
} }
@@ -108,7 +108,7 @@ async function setStoredToken(token: string): Promise<void> {
try { try {
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token); await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
} catch (err) { } catch (err) {
logError(err, { scope: "push.store" }); logError(err, { scope: 'push.store' });
} }
} }
@@ -133,8 +133,8 @@ export async function syncPushToken(token?: string | null): Promise<void> {
const stored = await getStoredToken(); const stored = await getStoredToken();
if (stored === next) return; if (stored === next) return;
const platform = Platform.OS === "ios" ? "ios" : "android"; const platform = Platform.OS === 'ios' ? 'ios' : 'android';
const appVersion = Constants.expoConfig?.version ?? ""; const appVersion = Constants.expoConfig?.version ?? '';
await apiClient.registerPushToken({ await apiClient.registerPushToken({
token: next, token: next,
@@ -143,7 +143,7 @@ export async function syncPushToken(token?: string | null): Promise<void> {
}); });
await setStoredToken(next); await setStoredToken(next);
} catch (err) { } catch (err) {
logError(err, { scope: "push.sync" }); logError(err, { scope: 'push.sync' });
} }
} }
@@ -158,7 +158,7 @@ export async function unregisterPushToken(): Promise<void> {
try { try {
await apiClient.unregisterPushToken(stored); await apiClient.unregisterPushToken(stored);
} catch (err) { } catch (err) {
logError(err, { scope: "push.unregister" }); logError(err, { scope: 'push.unregister' });
} }
} }
} finally { } finally {
+25 -25
View File
@@ -7,13 +7,13 @@
* React Native ships a WebSocket polyfill, so this code runs unchanged. * React Native ships a WebSocket polyfill, so this code runs unchanged.
*/ */
import { logError, reportError } from "@/lib/errors"; import { logError, reportError } from '@/lib/errors';
export type ConnectionState = export type ConnectionState =
| "disconnected" | 'disconnected'
| "connecting" | 'connecting'
| "connected" | 'connected'
| "reconnecting"; | 'reconnecting';
export interface ChannelMessage { export interface ChannelMessage {
humanId: string; humanId: string;
@@ -21,7 +21,7 @@ export interface ChannelMessage {
} }
interface ServerMessage { interface ServerMessage {
type: "subscribed" | "join" | "leave" | "message" | "error"; type: 'subscribed' | 'join' | 'leave' | 'message' | 'error';
channel?: string; channel?: string;
humanId?: string; humanId?: string;
presence?: string[]; presence?: string[];
@@ -29,7 +29,7 @@ interface ServerMessage {
message?: string; message?: string;
} }
type ChannelEventType = "subscribed" | "join" | "leave" | "message"; type ChannelEventType = 'subscribed' | 'join' | 'leave' | 'message';
type ChannelEventCallback = (msg: ServerMessage) => void; type ChannelEventCallback = (msg: ServerMessage) => void;
interface PusherClientConfig { interface PusherClientConfig {
@@ -44,7 +44,7 @@ const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
export class PusherClient { export class PusherClient {
private config: PusherClientConfig; private config: PusherClientConfig;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private state: ConnectionState = "disconnected"; private state: ConnectionState = 'disconnected';
private stateListeners = new Set<(state: ConnectionState) => void>(); private stateListeners = new Set<(state: ConnectionState) => void>();
private listeners = new Map< private listeners = new Map<
@@ -73,20 +73,20 @@ export class PusherClient {
const token = this.config.getToken(); const token = this.config.getToken();
if (!token) { if (!token) {
console.warn("[pusher] no token available, cannot connect"); console.warn('[pusher] no token available, cannot connect');
return; return;
} }
this.shouldReconnect = true; this.shouldReconnect = true;
this.setState( this.setState(
this.state === "reconnecting" ? "reconnecting" : "connecting", this.state === 'reconnecting' ? 'reconnecting' : 'connecting',
); );
const url = `${this.config.url}?token=${encodeURIComponent(token)}`; const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
this.setState("connected"); this.setState('connected');
this.reconnectDelay = INITIAL_RECONNECT_DELAY; this.reconnectDelay = INITIAL_RECONNECT_DELAY;
this.startPing(); this.startPing();
this.resubscribeAll(); this.resubscribeAll();
@@ -101,7 +101,7 @@ export class PusherClient {
this.ws.onerror = (event) => { this.ws.onerror = (event) => {
// onclose fires after onerror — reconnection is handled there. // onclose fires after onerror — reconnection is handled there.
logError(event, { scope: "pusher.ws" }); logError(event, { scope: 'pusher.ws' });
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
@@ -114,21 +114,21 @@ export class PusherClient {
this.clearReconnectTimer(); this.clearReconnectTimer();
this.cleanup(); this.cleanup();
this.activeSubscriptions.clear(); this.activeSubscriptions.clear();
this.setState("disconnected"); this.setState('disconnected');
} }
subscribe(channelId: string): void { subscribe(channelId: string): void {
this.activeSubscriptions.add(channelId); this.activeSubscriptions.add(channelId);
this.send({ type: "subscribe", channel: channelId }); this.send({ type: 'subscribe', channel: channelId });
} }
unsubscribe(channelId: string): void { unsubscribe(channelId: string): void {
this.activeSubscriptions.delete(channelId); this.activeSubscriptions.delete(channelId);
this.send({ type: "unsubscribe", channel: channelId }); this.send({ type: 'unsubscribe', channel: channelId });
} }
sendMessage(channelId: string, payload: unknown): void { sendMessage(channelId: string, payload: unknown): void {
this.send({ type: "message", channel: channelId, payload }); this.send({ type: 'message', channel: channelId, payload });
} }
on( on(
@@ -181,19 +181,19 @@ export class PusherClient {
} }
private handleMessage(data: string): void { private handleMessage(data: string): void {
if (data === "pong") return; if (data === 'pong') return;
let msg: ServerMessage; let msg: ServerMessage;
try { try {
msg = JSON.parse(data); msg = JSON.parse(data);
} catch (err) { } catch (err) {
logError(err, { scope: "pusher.parse", data }); logError(err, { scope: 'pusher.parse', data });
return; return;
} }
if (msg.type === "error") { if (msg.type === 'error') {
logError(new Error(msg.message ?? "pusher server error"), { logError(new Error(msg.message ?? 'pusher server error'), {
scope: "pusher.server", scope: 'pusher.server',
}); });
return; return;
} }
@@ -210,19 +210,19 @@ export class PusherClient {
try { try {
cb(msg); cb(msg);
} catch (err) { } catch (err) {
reportError(err, { scope: "pusher.listener", channel: msg.channel }); reportError(err, { scope: 'pusher.listener', channel: msg.channel });
} }
} }
} }
private resubscribeAll(): void { private resubscribeAll(): void {
for (const channelId of this.activeSubscriptions) { for (const channelId of this.activeSubscriptions) {
this.send({ type: "subscribe", channel: channelId }); this.send({ type: 'subscribe', channel: channelId });
} }
} }
private scheduleReconnect(): void { private scheduleReconnect(): void {
this.setState("reconnecting"); this.setState('reconnecting');
const jitter = Math.random() * 0.5 + 0.75; const jitter = Math.random() * 0.5 + 0.75;
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY); const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
@@ -264,7 +264,7 @@ export class PusherClient {
this.stopPing(); this.stopPing();
this.pingTimer = setInterval(() => { this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send("ping"); this.ws.send('ping');
} }
}, PING_INTERVAL); }, PING_INTERVAL);
} }
+18 -20
View File
@@ -2,39 +2,37 @@ import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useRef, useMemo,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from 'react';
import { PusherClient, type ConnectionState } from "./pusher-client"; import { PusherClient, type ConnectionState } from './pusher-client';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { appConfig } from "@/config/env"; import { appConfig } from '@/config/env';
const PusherContext = createContext<PusherClient | null>(null); const PusherContext = createContext<PusherClient | null>(null);
const PusherStateContext = createContext<ConnectionState>("disconnected"); const PusherStateContext = createContext<ConnectionState>('disconnected');
export function PusherProvider({ children }: { children: ReactNode }) { export function PusherProvider({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
const clientRef = useRef<PusherClient | null>(null);
const [connectionState, setConnectionState] = const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected"); useState<ConnectionState>('disconnected');
useEffect(() => { const client = useMemo(() => {
if (!token) { if (!token) {
if (clientRef.current) { return null;
clientRef.current.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
}
return;
} }
const client = new PusherClient({ return new PusherClient({
url: appConfig.pusherUrl, url: appConfig.pusherUrl,
getToken: () => useAuthStore.getState().token, getToken: () => useAuthStore.getState().token,
}); });
}, [token]);
clientRef.current = client; useEffect(() => {
if (!client) {
return;
}
const unsubscribeState = client.onStateChange((state) => { const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state); setConnectionState(state);
@@ -45,12 +43,12 @@ export function PusherProvider({ children }: { children: ReactNode }) {
return () => { return () => {
unsubscribeState(); unsubscribeState();
client.disconnect(); client.disconnect();
clientRef.current = null; setConnectionState('disconnected');
}; };
}, [token]); }, [client]);
return ( return (
<PusherContext.Provider value={clientRef.current}> <PusherContext.Provider value={client}>
<PusherStateContext.Provider value={connectionState}> <PusherStateContext.Provider value={connectionState}>
{children} {children}
</PusherStateContext.Provider> </PusherStateContext.Provider>
+7 -11
View File
@@ -1,13 +1,9 @@
import { import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
MutationCache, import { toast } from 'sonner-native';
QueryCache, import { ApiError, logError, reportError, toUserMessage } from '@/lib/errors';
QueryClient, import { useAuthStore } from '@/stores/auth-store';
} from "@tanstack/react-query";
import { toast } from "sonner-native";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
import { useAuthStore } from "@/stores/auth-store";
declare module "@tanstack/react-query" { declare module '@tanstack/react-query' {
interface Register { interface Register {
queryMeta: { toastOnError?: boolean }; queryMeta: { toastOnError?: boolean };
mutationMeta: { suppressToast?: boolean }; mutationMeta: { suppressToast?: boolean };
@@ -48,7 +44,7 @@ export function createQueryClient(): QueryClient {
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: (err, query) => { onError: (err, query) => {
handleUnauthorized(err); handleUnauthorized(err);
logError(err, { scope: "query", queryKey: query.queryKey }); logError(err, { scope: 'query', queryKey: query.queryKey });
if (query.meta?.toastOnError) { if (query.meta?.toastOnError) {
toast.error(toUserMessage(err)); toast.error(toUserMessage(err));
} }
@@ -58,7 +54,7 @@ export function createQueryClient(): QueryClient {
onError: (err, _variables, _context, mutation) => { onError: (err, _variables, _context, mutation) => {
handleUnauthorized(err); handleUnauthorized(err);
reportError(err, { reportError(err, {
scope: "mutation", scope: 'mutation',
mutationKey: mutation.options.mutationKey, mutationKey: mutation.options.mutationKey,
}); });
if (mutation.meta?.suppressToast) return; if (mutation.meta?.suppressToast) return;
+60 -8
View File
@@ -1,15 +1,67 @@
const ADJECTIVES = [ const ADJECTIVES = [
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle", 'amber',
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal", 'bold',
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty", 'calm',
"bright", "clear", "deep", "fresh", "grand", "swift", 'crisp',
'dark',
'eager',
'faint',
'gentle',
'hasty',
'icy',
'jade',
'keen',
'lush',
'misty',
'nimble',
'opal',
'pale',
'quiet',
'rapid',
'sharp',
'taut',
'vivid',
'warm',
'zesty',
'bright',
'clear',
'deep',
'fresh',
'grand',
'swift',
]; ];
const NOUNS = [ const NOUNS = [
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor", 'arrow',
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal", 'bloom',
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith", 'cedar',
"brook", "cliff", "delta", "frost", "glow", "reef", 'drift',
'ember',
'flint',
'grove',
'harbor',
'iris',
'jewel',
'knoll',
'lake',
'moss',
'nova',
'orbit',
'petal',
'quartz',
'ridge',
'spark',
'trail',
'vale',
'wave',
'yarn',
'zenith',
'brook',
'cliff',
'delta',
'frost',
'glow',
'reef',
]; ];
export function generateRandomName(): string { export function generateRandomName(): string {
+3 -3
View File
@@ -1,4 +1,4 @@
import { setAudioModeAsync, setIsAudioActiveAsync } from "expo-audio"; import { setAudioModeAsync, setIsAudioActiveAsync } from 'expo-audio';
// Around camera/mic recording we switch the iOS audio session to playAndRecord // Around camera/mic recording we switch the iOS audio session to playAndRecord
// with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple // with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple
@@ -10,7 +10,7 @@ export async function acquireRecordingAudioSession() {
await setAudioModeAsync({ await setAudioModeAsync({
allowsRecording: true, allowsRecording: true,
playsInSilentMode: true, playsInSilentMode: true,
interruptionMode: "doNotMix", interruptionMode: 'doNotMix',
}); });
} }
@@ -18,7 +18,7 @@ export async function releaseRecordingAudioSession() {
await setAudioModeAsync({ await setAudioModeAsync({
allowsRecording: false, allowsRecording: false,
playsInSilentMode: true, playsInSilentMode: true,
interruptionMode: "mixWithOthers", interruptionMode: 'mixWithOthers',
}); });
await setIsAudioActiveAsync(false); await setIsAudioActiveAsync(false);
} }
+7 -7
View File
@@ -1,23 +1,23 @@
import { removeDuplicates } from "@/lib/utils"; import { removeDuplicates } from '@/lib/utils';
const HUMAN_PREFIX = "human:"; const HUMAN_PREFIX = 'human:';
const NETWORK_PREFIX = "network:"; const NETWORK_PREFIX = 'network:';
export type StreamVisibility = export type StreamVisibility =
| { mode: "network" } | { mode: 'network' }
| { mode: "custom"; humanIds: string[] }; | { mode: 'custom'; humanIds: string[] };
export function parseVisibleTo( export function parseVisibleTo(
visibleTo: string[], visibleTo: string[],
networkId: string, networkId: string,
): StreamVisibility { ): StreamVisibility {
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) { if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
return { mode: "network" }; return { mode: 'network' };
} }
const humanIds = visibleTo const humanIds = visibleTo
.filter((v) => v.startsWith(HUMAN_PREFIX)) .filter((v) => v.startsWith(HUMAN_PREFIX))
.map((v) => v.slice(HUMAN_PREFIX.length)); .map((v) => v.slice(HUMAN_PREFIX.length));
return { mode: "custom", humanIds }; return { mode: 'custom', humanIds };
} }
export function buildNetworkVisibility(networkId: string): string[] { export function buildNetworkVisibility(networkId: string): string[] {
+3 -2
View File
@@ -6,10 +6,11 @@ const MONTH = 2592000;
const YEAR = 31536000; const YEAR = 31536000;
export function formatDistanceToNow(date: Date | string): string { export function formatDistanceToNow(date: Date | string): string {
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime(); const ms =
typeof date === 'string' ? new Date(date).getTime() : date.getTime();
const seconds = Math.floor((Date.now() - ms) / 1000); const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 5) return "just now"; if (seconds < 5) return 'just now';
if (seconds < MINUTE) return `${seconds}s ago`; if (seconds < MINUTE) return `${seconds}s ago`;
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`; if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`; if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
+20 -27
View File
@@ -2,17 +2,17 @@ import {
FileSystemUploadType, FileSystemUploadType,
getInfoAsync, getInfoAsync,
uploadAsync, uploadAsync,
} from "expo-file-system/legacy"; } from 'expo-file-system/legacy';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import { import {
createParticle, createParticle,
createStreamParticle, createStreamParticle,
} from "@/lib/firestore-particles"; } from '@/lib/firestore-particles';
import { import {
particlePath, particlePath,
toFirestoreChildrenPath, toFirestoreChildrenPath,
type ParticlePath, type ParticlePath,
} from "@/lib/particle-path"; } from '@/lib/particle-path';
interface UploadMediaParticleParams { interface UploadMediaParticleParams {
networkId: string; networkId: string;
@@ -21,7 +21,7 @@ interface UploadMediaParticleParams {
fileUri: string; fileUri: string;
mimeType: string; mimeType: string;
durationMs: number; durationMs: number;
source: "camera" | "screen"; source: 'camera' | 'screen';
createdByHumanId: string; createdByHumanId: string;
} }
@@ -44,11 +44,11 @@ export async function uploadMediaParticle({
}: UploadMediaParticleParams): Promise<string> { }: UploadMediaParticleParams): Promise<string> {
const info = await getInfoAsync(fileUri); const info = await getInfoAsync(fileUri);
if (!info.exists || info.size === undefined) { if (!info.exists || info.size === undefined) {
throw new Error("Recording file disappeared before upload."); throw new Error('Recording file disappeared before upload.');
} }
const sizeBytes = info.size; const sizeBytes = info.size;
const namePrefix = mimeType.startsWith("audio/") ? "voice" : "video"; const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video';
const ext = extensionFromMime(mimeType); const ext = extensionFromMime(mimeType);
const name = `${namePrefix}-${Date.now()}${ext}`; const name = `${namePrefix}-${Date.now()}${ext}`;
@@ -61,15 +61,13 @@ export async function uploadMediaParticle({
}); });
const uploadResult = await uploadAsync(upload_url, fileUri, { const uploadResult = await uploadAsync(upload_url, fileUri, {
httpMethod: "PUT", httpMethod: 'PUT',
uploadType: FileSystemUploadType.BINARY_CONTENT, uploadType: FileSystemUploadType.BINARY_CONTENT,
headers: upload_headers, headers: upload_headers,
}); });
if (uploadResult.status < 200 || uploadResult.status >= 300) { if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error( throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`);
`Upload to depot failed (HTTP ${uploadResult.status}).`,
);
} }
await apiClient.confirmUpload(object_id); await apiClient.confirmUpload(object_id);
@@ -77,7 +75,7 @@ export async function uploadMediaParticle({
const collectionPath = toFirestoreChildrenPath(targetPath); const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle( return createParticle(
collectionPath, collectionPath,
"media", 'media',
{ {
object_id, object_id,
mime_type: mimeType, mime_type: mimeType,
@@ -102,20 +100,15 @@ export async function createTextParticle({
createdByHumanId, createdByHumanId,
}: CreateTextParticleParams): Promise<string> { }: CreateTextParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath); const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle( return createParticle(collectionPath, 'text', { content }, createdByHumanId);
collectionPath,
"text",
{ content },
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';
if (mime === "audio/mp4") return ".m4a"; if (mime === 'audio/mp4') return '.m4a';
if (mime === "audio/webm") return ".webm"; if (mime === 'audio/webm') return '.webm';
return ""; return '';
} }
// Helper kept here so callers can construct a fresh stream's child-path before // Helper kept here so callers can construct a fresh stream's child-path before
@@ -137,13 +130,13 @@ interface CreateStreamWithFirstParticleParams {
createdByHumanId: string; createdByHumanId: string;
/** First particle to write into the new stream. Required — empty streams are not useful. */ /** First particle to write into the new stream. Required — empty streams are not useful. */
firstParticle: firstParticle:
| { type: "text"; content: string } | { type: 'text'; content: string }
| { | {
type: "media"; type: 'media';
fileUri: string; fileUri: string;
mimeType: string; mimeType: string;
durationMs: number; durationMs: number;
source: "camera" | "screen"; source: 'camera' | 'screen';
}; };
} }
@@ -178,7 +171,7 @@ export async function createStreamWithFirstParticle({
const streamPath = particlePath(networkId, [streamId]); const streamPath = particlePath(networkId, [streamId]);
// 2. The first child goes inside the new stream. // 2. The first child goes inside the new stream.
if (firstParticle.type === "text") { if (firstParticle.type === 'text') {
await createTextParticle({ await createTextParticle({
networkId, networkId,
targetPath: streamPath, targetPath: streamPath,
+3 -3
View File
@@ -1,12 +1,12 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function getInitials(email: string): string { export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? ""; const prefix = email.split('@')[0] ?? '';
return prefix.slice(0, 2).toUpperCase(); return prefix.slice(0, 2).toUpperCase();
} }
+17 -17
View File
@@ -1,22 +1,22 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { ActivityIndicator, View } from "react-native"; import { ActivityIndicator, View } from 'react-native';
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from '@/stores/auth-store';
import { SignInScreen } from "@/features/auth/SignInScreen"; import { SignInScreen } from '@/features/auth/SignInScreen';
import { NetworkListScreen } from "@/features/networks/NetworkListScreen"; import { NetworkListScreen } from '@/features/networks/NetworkListScreen';
import { StreamListScreen } from "@/features/streams/StreamListScreen"; 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 { 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';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() { export function RootNavigator() {
const status = useAuthStore((s) => s.status); const status = useAuthStore((s) => s.status);
if (status === "idle" || status === "restoring") { if (status === 'idle' || status === 'restoring') {
return ( return (
<View className="flex-1 items-center justify-center bg-background"> <View className="flex-1 items-center justify-center bg-background">
<ActivityIndicator /> <ActivityIndicator />
@@ -24,7 +24,7 @@ export function RootNavigator() {
); );
} }
if (status === "unauthenticated") { if (status === 'unauthenticated') {
return ( return (
<Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="SignIn" component={SignInScreen} /> <Stack.Screen name="SignIn" component={SignInScreen} />
@@ -42,17 +42,17 @@ export function RootNavigator() {
<Stack.Screen <Stack.Screen
name="StreamView" name="StreamView"
component={StreamViewScreen} component={StreamViewScreen}
options={{ animation: "fade", gestureEnabled: false }} options={{ animation: 'fade', gestureEnabled: false }}
/> />
<Stack.Screen <Stack.Screen
name="Huddle" name="Huddle"
component={HuddleScreen} component={HuddleScreen}
options={{ animation: "slide_from_bottom", gestureEnabled: false }} options={{ animation: 'slide_from_bottom', gestureEnabled: false }}
/> />
<Stack.Screen <Stack.Screen
name="NewStream" name="NewStream"
component={NewStreamScreen} component={NewStreamScreen}
options={{ animation: "slide_from_bottom" }} options={{ animation: 'slide_from_bottom' }}
/> />
<Stack.Screen name="Settings" component={SettingsScreen} /> <Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Account" component={AccountScreen} /> <Stack.Screen name="Account" component={AccountScreen} />
+5 -1
View File
@@ -1,4 +1,4 @@
import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
// Pure stack model from PRD §5. Drawer affordance lives inside the // Pure stack model from PRD §5. Drawer affordance lives inside the
// NetworkList screen itself, not the navigator — see Drawer.tsx. // NetworkList screen itself, not the navigator — see Drawer.tsx.
@@ -24,6 +24,10 @@ export type RootStackScreenProps<T extends keyof RootStackParamList> =
declare global { declare global {
namespace ReactNavigation { namespace ReactNavigation {
// React Navigation wires up typed navigation by merging into this global
// interface. It must stay an `interface` (type aliases can't be augmented)
// and is intentionally empty — it only re-exports our param list.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface RootParamList extends RootStackParamList {} interface RootParamList extends RootStackParamList {}
} }
} }
+23 -24
View File
@@ -1,21 +1,21 @@
import * as SecureStore from "expo-secure-store"; import * as SecureStore from 'expo-secure-store';
import { create } from "zustand"; import { create } from 'zustand';
import { import {
signInWithCustomToken, signInWithCustomToken,
signOut as firebaseSignOut, signOut as firebaseSignOut,
} from "firebase/auth"; } from 'firebase/auth';
import { apiClient } from "@/api/client"; import { apiClient } from '@/api/client';
import type { Human } from "@/api/types"; import type { Human } from '@/api/types';
import { firebaseAuth } from "@/firebase"; import { firebaseAuth } from '@/firebase';
import { logError, ApiError } from "@/lib/errors"; import { logError, ApiError } from '@/lib/errors';
import { import {
startPushTokenSync, startPushTokenSync,
stopPushTokenSync, stopPushTokenSync,
syncPushToken, syncPushToken,
unregisterPushToken, unregisterPushToken,
} from "@/lib/push-notifications"; } from '@/lib/push-notifications';
const AUTH_TOKEN_KEY = "auth_token"; const AUTH_TOKEN_KEY = 'auth_token';
async function readPersistedToken(): Promise<string | null> { async function readPersistedToken(): Promise<string | null> {
try { try {
@@ -45,11 +45,11 @@ async function signInToFirebase(): Promise<void> {
} catch (err) { } catch (err) {
// Firestore subscriptions will fail until the next successful sign-in; the // Firestore subscriptions will fail until the next successful sign-in; the
// rest of the app keeps working against Orion. Sentry catches the failure. // rest of the app keeps working against Orion. Sentry catches the failure.
logError(err, { scope: "auth.firebase" }); logError(err, { scope: 'auth.firebase' });
} }
} }
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated"; type AuthStatus = 'idle' | 'restoring' | 'unauthenticated' | 'authenticated';
interface AuthState { interface AuthState {
status: AuthStatus; status: AuthStatus;
@@ -73,7 +73,7 @@ interface AuthState {
} }
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
status: "idle", status: 'idle',
user: null, user: null,
token: null, token: null,
isRequestingCode: false, isRequestingCode: false,
@@ -82,11 +82,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
error: null, error: null,
restoreSession: async () => { restoreSession: async () => {
set({ status: "restoring" }); set({ status: 'restoring' });
const token = await readPersistedToken(); const token = await readPersistedToken();
if (!token) { if (!token) {
set({ status: "unauthenticated" }); set({ status: 'unauthenticated' });
return; return;
} }
@@ -96,12 +96,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try { try {
const user = await apiClient.me(); const user = await apiClient.me();
await signInToFirebase(); await signInToFirebase();
set({ status: "authenticated", user }); set({ status: 'authenticated', user });
startPushTokenSync(); startPushTokenSync();
void syncPushToken(); void syncPushToken();
} catch (err) { } catch (err) {
// Expected on expired/invalid tokens — fall back to the login screen. // Expected on expired/invalid tokens — fall back to the login screen.
logError(err, { scope: "auth.restore" }); logError(err, { scope: 'auth.restore' });
await get().invalidateSession(); await get().invalidateSession();
} }
}, },
@@ -111,8 +111,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try { try {
await apiClient.requestCode({ email }); await apiClient.requestCode({ email });
} catch (e) { } catch (e) {
const message = const message = e instanceof ApiError ? e.message : 'Failed to send code';
e instanceof ApiError ? e.message : "Failed to send code";
set({ error: message }); set({ error: message });
throw e; throw e;
} finally { } finally {
@@ -128,11 +127,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
apiClient.setToken(token); apiClient.setToken(token);
set({ token }); set({ token });
await signInToFirebase(); await signInToFirebase();
set({ status: "authenticated", user: human }); set({ status: 'authenticated', user: human });
startPushTokenSync(); startPushTokenSync();
void syncPushToken(); void syncPushToken();
} catch (e) { } catch (e) {
const message = e instanceof ApiError ? e.message : "Failed to sign in"; const message = e instanceof ApiError ? e.message : 'Failed to sign in';
set({ error: message }); set({ error: message });
throw e; throw e;
} finally { } finally {
@@ -150,15 +149,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await apiClient.signOut(); await apiClient.signOut();
} catch (err) { } catch (err) {
// Best-effort — sign out locally regardless. // Best-effort — sign out locally regardless.
logError(err, { scope: "auth.signOut" }); logError(err, { scope: 'auth.signOut' });
} finally { } finally {
await firebaseSignOut(firebaseAuth).catch((err) => await firebaseSignOut(firebaseAuth).catch((err) =>
logError(err, { scope: "auth.firebaseSignOut" }), logError(err, { scope: 'auth.firebaseSignOut' }),
); );
apiClient.setToken(null); apiClient.setToken(null);
await clearPersistedToken(); await clearPersistedToken();
set({ set({
status: "unauthenticated", status: 'unauthenticated',
user: null, user: null,
token: null, token: null,
isSigningOut: false, isSigningOut: false,
@@ -171,7 +170,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
stopPushTokenSync(); stopPushTokenSync();
apiClient.setToken(null); apiClient.setToken(null);
await clearPersistedToken(); await clearPersistedToken();
set({ status: "unauthenticated", user: null, token: null }); set({ status: 'unauthenticated', user: null, token: null });
await unregisterPushToken(); // Best-effort await unregisterPushToken(); // Best-effort
}, },
+1 -1
View File
@@ -1,4 +1,4 @@
import { create } from "zustand"; import { create } from 'zustand';
/** /**
* Single source of truth for "is stream playback paused." Each component that * Single source of truth for "is stream playback paused." Each component that
+1293 -18
View File
File diff suppressed because it is too large Load Diff