diff --git a/js/mobile/.prettierignore b/js/mobile/.prettierignore new file mode 100644 index 0000000..f273493 --- /dev/null +++ b/js/mobile/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +.expo +ios +android +yarn.lock diff --git a/js/mobile/.prettierrc b/js/mobile/.prettierrc new file mode 100644 index 0000000..2ff2460 --- /dev/null +++ b/js/mobile/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "jsxSingleQuote": false +} diff --git a/js/mobile/eslint.config.js b/js/mobile/eslint.config.js new file mode 100644 index 0000000..f1e5060 --- /dev/null +++ b/js/mobile/eslint.config.js @@ -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/*"], + }, +]); diff --git a/js/mobile/package.json b/js/mobile/package.json index 93354d1..ebe23a4 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -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/'", "android": "expo run:android", "compile": "tsc --noEmit", - "lint": "expo lint" + "lint": "expo lint", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" }, "packageManager": "yarn@1.22.22", "dependencies": { @@ -53,7 +54,11 @@ }, "devDependencies": { "@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", + "prettier": "^3.8.3", "tailwindcss": "^3.4.17", "typescript": "~5.9.0" } diff --git a/js/mobile/src/App.tsx b/js/mobile/src/App.tsx index 1ea860b..bd7f209 100644 --- a/js/mobile/src/App.tsx +++ b/js/mobile/src/App.tsx @@ -1,22 +1,22 @@ -import { useEffect } from "react"; -import { StatusBar } from "expo-status-bar"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import { NavigationContainer } from "@react-navigation/native"; +import { useEffect } from 'react'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { NavigationContainer } from '@react-navigation/native'; import { initialWindowMetrics, SafeAreaProvider, -} from "react-native-safe-area-context"; -import { Toaster } from "sonner-native"; -import { createQueryClient } from "@/lib/query-client"; +} from 'react-native-safe-area-context'; +import { Toaster } from 'sonner-native'; +import { createQueryClient } from '@/lib/query-client'; import { flushPendingNavigation, navigationRef, -} from "@/lib/notification-routing"; -import { configureNotifications } from "@/lib/push-notifications"; -import { PusherProvider } from "@/lib/pusher-provider"; -import { RootNavigator } from "@/navigation/RootNavigator"; -import { useAuthStore } from "@/stores/auth-store"; +} from '@/lib/notification-routing'; +import { configureNotifications } from '@/lib/push-notifications'; +import { PusherProvider } from '@/lib/pusher-provider'; +import { RootNavigator } from '@/navigation/RootNavigator'; +import { useAuthStore } from '@/stores/auth-store'; const queryClient = createQueryClient(); diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 5ab288d..27da3e9 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -1,6 +1,6 @@ -import { appConfig } from "@/config/env"; -import { ApiError } from "@/lib/errors"; -import type { z } from "zod"; +import { appConfig } from '@/config/env'; +import { ApiError } from '@/lib/errors'; +import type { z } from 'zod'; import { BillingStatusSchema, CheckoutSessionResponseSchema, @@ -15,7 +15,7 @@ import { PortalSessionResponseSchema, PrepareUploadResponseSchema, SignInResponseSchema, -} from "./types"; +} from './types'; import type { AcceptInvitationRequest, AddMembersRequest, @@ -25,7 +25,7 @@ import type { RequestCodeRequest, RevokeInvitationRequest, SignInRequest, -} from "./types"; +} from './types'; /** * HTTP transport for Orion. Holds the bearer token as private state — the auth @@ -51,11 +51,11 @@ class ApiClient { const headers: Record = {}; if (body) { - headers["Content-Type"] = "application/json"; + headers['Content-Type'] = 'application/json'; } if (this.token) { - headers["Authorization"] = `Bearer ${this.token}`; + headers['Authorization'] = `Bearer ${this.token}`; } const response = await fetch(`${this.baseUrl}${path}`, { @@ -65,11 +65,11 @@ class ApiClient { }); if (response.status === 401) { - throw new ApiError(401, "Unauthorized"); + throw new ApiError(401, 'Unauthorized'); } 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); } @@ -98,35 +98,32 @@ class ApiClient { // --- Auth --- async requestCode(data: RequestCodeRequest): Promise { - await this.requestVoid("POST", "/auth/request-code", data); + await this.requestVoid('POST', '/auth/request-code', data); } async signIn(data: SignInRequest) { - return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data); + return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data); } async me() { - return this.request(HumanSchema, "GET", "/auth/me"); + return this.request(HumanSchema, 'GET', '/auth/me'); } async signOut(): Promise { - await this.requestVoid("POST", "/auth/sign-out"); + await this.requestVoid('POST', '/auth/sign-out'); } async getFirebaseToken() { return this.request( FirebaseTokenResponseSchema, - "POST", - "/auth/firebase-token", + 'POST', + '/auth/firebase-token', ); } // TODO: security: require passing in the particle id once api deprecates this async getParticleDownloadUrl(objectId: string): Promise { - const response = await this.fetch( - "GET", - `/particles/${objectId}/download`, - ); + const response = await this.fetch('GET', `/particles/${objectId}/download`); const data = await response.json(); return data.url; } @@ -136,21 +133,21 @@ class ApiClient { async updateSettings(data: { email_notifications_enabled?: boolean; }): Promise { - await this.requestVoid("PATCH", "/humans/me/settings", data); + await this.requestVoid('PATCH', '/humans/me/settings', data); } // --- Push notification tokens --- async registerPushToken(data: { token: string; - platform: "ios" | "android"; + platform: 'ios' | 'android'; app_version: string; }): Promise { - await this.requestVoid("POST", "/humans/me/push-tokens", data); + await this.requestVoid('POST', '/humans/me/push-tokens', data); } async unregisterPushToken(token: string): Promise { - await this.requestVoid("DELETE", "/humans/me/push-tokens", { token }); + await this.requestVoid('DELETE', '/humans/me/push-tokens', { token }); } // --- Depot --- @@ -158,8 +155,8 @@ class ApiClient { async prepareUpload(data: PrepareUploadRequest) { return this.request( PrepareUploadResponseSchema, - "POST", - "/depot/upload", + 'POST', + '/depot/upload', data, ); } @@ -167,7 +164,7 @@ class ApiClient { async confirmUpload(objectId: string) { return this.request( DepotObjectSchema, - "POST", + 'POST', `/depot/objects/${objectId}/confirm`, ); } @@ -175,24 +172,24 @@ class ApiClient { // --- Networks --- async listNetworks() { - return this.request(ListNetworksResponseSchema, "GET", "/networks"); + return this.request(ListNetworksResponseSchema, 'GET', '/networks'); } async createNetwork(data: CreateNetworkRequest) { - return this.request(NetworkSchema, "POST", "/networks", data); + return this.request(NetworkSchema, 'POST', '/networks', data); } 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 { - await this.requestVoid("POST", `/networks/${networkId}/members`, data); + await this.requestVoid('POST', `/networks/${networkId}/members`, data); } async removeMember(networkId: string, humanId: string): Promise { await this.requestVoid( - "DELETE", + 'DELETE', `/networks/${networkId}/members/${humanId}`, ); } @@ -202,17 +199,17 @@ class ApiClient { async listNetworkInvitations(networkId: string) { return this.request( ListInvitationsResponseSchema, - "GET", + 'GET', `/networks/${networkId}/invitations`, ); } async listMyInvitations() { - return this.request(ListInvitationsResponseSchema, "GET", "/invitations"); + return this.request(ListInvitationsResponseSchema, 'GET', '/invitations'); } async acceptInvitation(data: AcceptInvitationRequest): Promise { - await this.requestVoid("POST", "/invitations/accept", data); + await this.requestVoid('POST', '/invitations/accept', data); } async revokeInvitation( @@ -220,7 +217,7 @@ class ApiClient { data: RevokeInvitationRequest, ): Promise { await this.requestVoid( - "DELETE", + 'DELETE', `/networks/${networkId}/invitations`, data, ); @@ -231,8 +228,8 @@ class ApiClient { async getLivekitToken(networkId: string, streamId: string) { return this.request( GetLivekitTokenResponseSchema, - "POST", - "/livekit/token", + 'POST', + '/livekit/token', { network_id: networkId, stream_id: streamId }, ); } @@ -242,7 +239,7 @@ class ApiClient { async getNetworkBilling(networkId: string) { return this.request( BillingStatusSchema, - "GET", + 'GET', `/networks/${networkId}/billing`, ); } @@ -250,7 +247,7 @@ class ApiClient { async createCheckoutSession(networkId: string, cadence: BillingCadence) { return this.request( CheckoutSessionResponseSchema, - "POST", + 'POST', `/networks/${networkId}/billing/checkout-session`, { cadence }, ); @@ -259,7 +256,7 @@ class ApiClient { async createPortalSession(networkId: string) { return this.request( PortalSessionResponseSchema, - "POST", + 'POST', `/networks/${networkId}/billing/portal-session`, ); } @@ -267,7 +264,7 @@ class ApiClient { async getNetworkUsage(networkId: string) { return this.request( NetworkUsageSchema, - "GET", + 'GET', `/networks/${networkId}/usage`, ); } diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index 1ab02d1..7410cbb 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; export const HumanSchema = z.object({ id: z.string(), @@ -25,12 +25,12 @@ export type ListNetworksResponse = z.infer; // --- Network request/response types --- -const CreateNetworkRequestSchema = z.object({ +export const CreateNetworkRequestSchema = z.object({ name: z.string(), }); export type CreateNetworkRequest = z.infer; -const AddMembersRequestSchema = z.object({ +export const AddMembersRequestSchema = z.object({ email_addresses: z.array(z.string().email()), }); export type AddMembersRequest = z.infer; @@ -52,7 +52,7 @@ export type RevokeInvitationRequest = { email: string }; // --- Depot types --- -const PrepareUploadRequestSchema = z.object({ +export const PrepareUploadRequestSchema = z.object({ network_id: z.string(), name: z.string(), content_type: z.string(), @@ -122,7 +122,7 @@ export const MediaPropertiesSchema = z.object({ duration_ms: z.number(), size_bytes: z.number(), 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 // 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. @@ -162,7 +162,9 @@ export type PaperProperties = z.infer; // --- 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; // --- Tombstone (soft-delete) --- @@ -175,7 +177,15 @@ const TombstoneFields = { 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 { stream: StreamProperties; @@ -196,9 +206,9 @@ const ParticleBaseSchema = z.object({ updated_at: z.coerce.date().optional(), }); -export const ParticleSchema = z.discriminatedUnion("type", [ +export const ParticleSchema = z.discriminatedUnion('type', [ ParticleBaseSchema.extend({ - type: z.literal("stream"), + type: z.literal('stream'), properties: StreamPropertiesSchema, // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // 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(), // Array of humanIds currently in the huddle (updated via LiveKit webhooks) huddle_active_participants: z.array(z.string()).optional(), - status: z.enum(["open", "closed"]).optional(), + status: z.enum(['open', 'closed']).optional(), }), 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. ["network:123"] - visible to everyone in the network visible_to: z.array(z.string()), }), - ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...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 }), + ParticleBaseSchema.extend({ + type: z.literal('media'), + properties: MediaPropertiesSchema, + reactions: ReactionsSchema, + ...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; -export type ParticleType = Particle["type"]; +export type ParticleType = Particle['type']; /** Container types can have children subcollections */ -export const CONTAINER_TYPES: ReadonlySet = new Set(["stream", "folder"]); +export const CONTAINER_TYPES: ReadonlySet = new Set([ + 'stream', + 'folder', +]); export function isContainerType(type: ParticleType): boolean { 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). */ 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 --- @@ -247,16 +283,18 @@ export const GetLivekitTokenResponseSchema = z.object({ token: z.string(), server_url: z.string(), }); -export type GetLivekitTokenResponse = z.infer; +export type GetLivekitTokenResponse = z.infer< + typeof GetLivekitTokenResponseSchema +>; // --- Auth types --- -const RequestCodeRequestSchema = z.object({ +export const RequestCodeRequestSchema = z.object({ email: z.string().email(), }); export type RequestCodeRequest = z.infer; -const SignInRequestSchema = z.object({ +export const SignInRequestSchema = z.object({ email: z.string().email(), code: z.string(), }); @@ -275,21 +313,21 @@ export type FirebaseTokenResponse = z.infer; // --- Billing types --- -export const BillingCadenceSchema = z.enum(["monthly", "annual"]); +export const BillingCadenceSchema = z.enum(['monthly', 'annual']); export type BillingCadence = z.infer; -export const NetworkPlanSchema = z.enum(["free", "pro"]); +export const NetworkPlanSchema = z.enum(['free', 'pro']); export type NetworkPlan = z.infer; // Mirrors Stripe subscription.status plus "active" as the default free-tier value. export const BillingPlanStatusSchema = z.enum([ - "active", - "trialing", - "past_due", - "canceled", - "incomplete", - "incomplete_expired", - "unpaid", + 'active', + 'trialing', + 'past_due', + 'canceled', + 'incomplete', + 'incomplete_expired', + 'unpaid', ]); export type BillingPlanStatus = z.infer; @@ -308,7 +346,9 @@ export type BillingStatus = z.infer; export const CheckoutSessionResponseSchema = z.object({ url: z.string().url(), }); -export type CheckoutSessionResponse = z.infer; +export type CheckoutSessionResponse = z.infer< + typeof CheckoutSessionResponseSchema +>; export const PortalSessionResponseSchema = z.object({ url: z.string().url(), diff --git a/js/mobile/src/components/Avatar.tsx b/js/mobile/src/components/Avatar.tsx index bced118..8655682 100644 --- a/js/mobile/src/components/Avatar.tsx +++ b/js/mobile/src/components/Avatar.tsx @@ -1,9 +1,9 @@ -import { Text, View } from "react-native"; -import type { Human } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { cn } from "@/lib/utils"; +import { Text, View } from 'react-native'; +import type { Human } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { cn } from '@/lib/utils'; -type Size = "xs" | "sm" | "md"; +type Size = 'xs' | 'sm' | 'md'; interface AvatarProps { humanId: string | null | undefined; @@ -17,9 +17,9 @@ interface AvatarProps { } const sizeMap: Record = { - xs: { box: "h-6 w-6", text: "text-[9px]", ring: 1.5 }, - sm: { box: "h-9 w-9", text: "text-xs", ring: 2 }, - md: { box: "h-10 w-10", text: "text-sm", ring: 2 }, + xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 }, + sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 }, + md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 }, }; /** @@ -30,7 +30,7 @@ const sizeMap: Record = { export function Avatar({ humanId, humans, - size = "sm", + size = 'sm', online = false, stackBg, className, @@ -41,7 +41,7 @@ export function Avatar({ return ( - + {initials} diff --git a/js/mobile/src/components/BottomSheet.tsx b/js/mobile/src/components/BottomSheet.tsx index b0ae060..b676711 100644 --- a/js/mobile/src/components/BottomSheet.tsx +++ b/js/mobile/src/components/BottomSheet.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Dimensions, KeyboardAvoidingView, @@ -6,13 +6,13 @@ import { Platform, Pressable, View, -} from "react-native"; +} from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, -} from "react-native-safe-area-context"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +} from 'react-native-safe-area-context'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, @@ -22,9 +22,9 @@ import Animated, { useSharedValue, withSpring, 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; interface BottomSheetProps { @@ -58,7 +58,7 @@ export function BottomSheet({ onClose, onClosed, avoidKeyboard = false, - maxHeight = "85%", + maxHeight = '85%', children, }: BottomSheetProps) { // 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 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 // current callback even if the parent re-rendered with a new closure. const onClosedRef = useRef(onClosed); @@ -80,7 +83,6 @@ export function BottomSheet({ useEffect(() => { if (open) { - setMounted(true); requestAnimationFrame(() => { translateY.value = withSpring(0, { damping: 24, @@ -104,14 +106,18 @@ export function BottomSheet({ .activeOffsetY(10) .failOffsetX([-25, 25]) .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); }) .onEnd((e) => { - "worklet"; + 'worklet'; if (e.translationY > 120 || e.velocityY > 800) { runOnJS(onClose)(); } else { + // eslint-disable-next-line react-hooks/immutability translateY.value = withSpring(0, { damping: 24, stiffness: 260, @@ -138,7 +144,7 @@ export function BottomSheet({ const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View; const wrapperProps = avoidKeyboard - ? { behavior: Platform.OS === "ios" ? ("padding" as const) : undefined } + ? { behavior: Platform.OS === 'ios' ? ('padding' as const) : undefined } : {}; return ( @@ -151,9 +157,9 @@ export function BottomSheet({ @@ -162,23 +168,23 @@ export function BottomSheet({ - + diff --git a/js/mobile/src/components/ComposingIndicator.tsx b/js/mobile/src/components/ComposingIndicator.tsx index fcc62ea..61e93d0 100644 --- a/js/mobile/src/components/ComposingIndicator.tsx +++ b/js/mobile/src/components/ComposingIndicator.tsx @@ -1,15 +1,15 @@ -import { useEffect } from "react"; -import { Text, View } from "react-native"; +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, -} from "react-native-reanimated"; -import type { Human } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import type { ComposingUser } from "@/features/stream-view/stream-presence-context"; +} from 'react-native-reanimated'; +import type { Human } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import type { ComposingUser } from '@/features/stream-view/stream-presence-context'; interface ComposingIndicatorProps { users: ComposingUser[]; @@ -29,13 +29,16 @@ export function ComposingIndicator({ if (users.length === 0) return null; return ( - + {users.map((u) => { const { displayName } = resolveHumanDisplay(u.humanId, networkHumans); const label = - u.mode === "recording" + u.mode === 'recording' ? `${displayName} is recording` - : u.mode === "screen" + : u.mode === 'screen' ? `${displayName} is sharing` : `${displayName} is typing`; @@ -87,9 +90,6 @@ function Dot({ delay }: { delay: number }) { })); return ( - + ); } diff --git a/js/mobile/src/components/FlowyLogo.tsx b/js/mobile/src/components/FlowyLogo.tsx index 148ce89..b88dcea 100644 --- a/js/mobile/src/components/FlowyLogo.tsx +++ b/js/mobile/src/components/FlowyLogo.tsx @@ -1,4 +1,4 @@ -import Svg, { Path } from "react-native-svg"; +import Svg, { Path } from 'react-native-svg'; type Props = { height?: number; @@ -7,7 +7,7 @@ type Props = { 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; return ( diff --git a/js/mobile/src/components/ListSeparator.tsx b/js/mobile/src/components/ListSeparator.tsx index e65d57e..58593aa 100644 --- a/js/mobile/src/components/ListSeparator.tsx +++ b/js/mobile/src/components/ListSeparator.tsx @@ -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 diff --git a/js/mobile/src/components/RelativeTimestamp.tsx b/js/mobile/src/components/RelativeTimestamp.tsx index 53d60f6..514f1a9 100644 --- a/js/mobile/src/components/RelativeTimestamp.tsx +++ b/js/mobile/src/components/RelativeTimestamp.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from "react"; -import { Text, type TextProps } from "react-native"; -import { formatDistanceToNow } from "@/lib/time-utils"; +import { useEffect, useState } from 'react'; +import { Text, type TextProps } from 'react-native'; +import { formatDistanceToNow } from '@/lib/time-utils'; const MINUTE_MS = 60_000; -interface RelativeTimestampProps extends Omit { +interface RelativeTimestampProps extends Omit { date: Date; } diff --git a/js/mobile/src/config/env.ts b/js/mobile/src/config/env.ts index b54fe17..ad93ecc 100644 --- a/js/mobile/src/config/env.ts +++ b/js/mobile/src/config/env.ts @@ -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 // (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts @@ -26,38 +26,38 @@ type AppConfig = { sentryDsn: string; }; -const configs: Record<"dev" | "prod", AppConfig> = { +const configs: Record<'dev' | 'prod', AppConfig> = { dev: { - orionUrl: "https://orion.dev.flowy.live", - pusherUrl: "wss://pusher.dev.flowy.live/ws", + orionUrl: 'https://orion.dev.flowy.live', + pusherUrl: 'wss://pusher.dev.flowy.live/ws', firebase: { - apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk", - appId: "1:1006580076785:web:e2a0736d60a78e02b15950", - authDomain: "flowy-dev-440017.firebaseapp.com", - messagingSenderId: "1006580076785", - projectId: "flowy-dev-440017", - storageBucket: "flowy-dev-440017.firebasestorage.app", + apiKey: 'AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk', + appId: '1:1006580076785:web:e2a0736d60a78e02b15950', + authDomain: 'flowy-dev-440017.firebaseapp.com', + messagingSenderId: '1006580076785', + projectId: 'flowy-dev-440017', + storageBucket: 'flowy-dev-440017.firebasestorage.app', }, sentryDsn: - "https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528", + 'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528', }, prod: { - orionUrl: "https://orion.flowy.live", - pusherUrl: "wss://pusher.flowy.live/ws", + orionUrl: 'https://orion.flowy.live', + pusherUrl: 'wss://pusher.flowy.live/ws', firebase: { - apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg", - appId: "1:68063426854:web:5054f16f50898f5706e9e7", - authDomain: "flowy-prod-440017.firebaseapp.com", - messagingSenderId: "68063426854", - projectId: "flowy-prod-440017", - storageBucket: "flowy-prod-440017.firebasestorage.app", + apiKey: 'AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg', + appId: '1:68063426854:web:5054f16f50898f5706e9e7', + authDomain: 'flowy-prod-440017.firebaseapp.com', + messagingSenderId: '68063426854', + projectId: 'flowy-prod-440017', + storageBucket: 'flowy-prod-440017.firebasestorage.app', }, 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) ?.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]; diff --git a/js/mobile/src/features/auth/SignInScreen.tsx b/js/mobile/src/features/auth/SignInScreen.tsx index ab85b2d..0409869 100644 --- a/js/mobile/src/features/auth/SignInScreen.tsx +++ b/js/mobile/src/features/auth/SignInScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState } from 'react'; import { KeyboardAvoidingView, Platform, @@ -6,32 +6,32 @@ import { Text, TextInput, View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { useAuthStore } from "@/stores/auth-store"; +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useAuthStore } from '@/stores/auth-store'; -type Step = "email" | "code"; +type Step = 'email' | 'code'; export function SignInScreen() { - const [step, setStep] = useState("email"); - const [email, setEmail] = useState(""); + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); return ( - {step === "email" ? ( + {step === 'email' ? ( { setEmail(submittedEmail); - setStep("code"); + setStep('code'); }} /> ) : ( - setStep("email")} /> + setStep('email')} /> )} @@ -40,7 +40,7 @@ export function SignInScreen() { } function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) { - const [email, setEmail] = useState(""); + const [email, setEmail] = useState(''); const isRequestingCode = useAuthStore((s) => s.isRequestingCode); const error = useAuthStore((s) => s.error); const requestCode = useAuthStore((s) => s.requestCode); @@ -88,23 +88,21 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) { /> - {error ? ( - {error} - ) : null} + {error ? {error} : null} - {isRequestingCode ? "Sending..." : "Continue"} + {isRequestingCode ? 'Sending...' : 'Continue'} @@ -112,7 +110,7 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) { } function CodeStep({ email, onBack }: { email: string; onBack: () => void }) { - const [code, setCode] = useState(""); + const [code, setCode] = useState(''); const isSigningIn = useAuthStore((s) => s.isSigningIn); const error = useAuthStore((s) => s.error); const signIn = useAuthStore((s) => s.signIn); @@ -135,7 +133,7 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) { Check your email - We sent a code to{" "} + We sent a code to{' '} {email}. @@ -159,24 +157,22 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) { /> - {error ? ( - {error} - ) : null} + {error ? {error} : null} - {isSigningIn ? "Signing in..." : "Sign in"} + {isSigningIn ? 'Signing in...' : 'Sign in'} {}); } void releaseRecordingAudioSession().catch((err) => - logError(err, { scope: "compose.audio.exit" }), + logError(err, { scope: 'compose.audio.exit' }), ); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -56,23 +56,16 @@ export function AudioRecordingOverlay({ const elapsedMs = state.durationMillis ?? 0; - useEffect(() => { - 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") => { + const finish = async (kind: 'commit' | 'cancel') => { if (finalizedRef.current) return; finalizedRef.current = true; const durationMs = state.durationMillis ?? 0; try { await recorder.stop(); } catch (err) { - logError(err, { scope: "compose.audio.stop" }); + logError(err, { scope: 'compose.audio.stop' }); } - if (kind === "cancel") { + if (kind === 'cancel') { onCancel(); return; } @@ -84,6 +77,14 @@ export function AudioRecordingOverlay({ 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); return ( @@ -97,22 +98,22 @@ export function AudioRecordingOverlay({ - {state.isRecording ? "Recording" : "Starting…"} + {state.isRecording ? 'Recording' : 'Starting…'} - {elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s + {elapsedSec.toString().padStart(2, '0')}s · max {MAX_DURATION_S}s void finish("cancel")} + onPress={() => void finish('cancel')} accessibilityLabel="Cancel recording" className="rounded-full bg-white/15 px-6 py-3" > Cancel void finish("commit")} + onPress={() => void finish('commit')} accessibilityLabel="Stop recording" className="rounded-full bg-white px-7 py-3" > diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index 001f6b8..c706257 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -1,43 +1,37 @@ -import { useCallback, useEffect, useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native"; -import * as Haptics from "expo-haptics"; +import { useCallback, useEffect, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native'; +import * 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 { - 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 { - useStreamComposingBroadcast, + useStreamComposingBroadcastOptional, type ComposingMode, -} from "@/features/stream-view/stream-presence-context"; -import { TextComposeModal } from "./TextComposeModal"; -import { VideoRecordingOverlay } from "./VideoRecordingOverlay"; -import { AudioRecordingOverlay } from "./AudioRecordingOverlay"; -import { ReviewSheet } from "./ReviewSheet"; +} from '@/features/stream-view/stream-presence-context'; +import { TextComposeModal } from './TextComposeModal'; +import { VideoRecordingOverlay } from './VideoRecordingOverlay'; +import { AudioRecordingOverlay } from './AudioRecordingOverlay'; +import { ReviewSheet } from './ReviewSheet'; -type RecordingMode = "video" | "audio"; +type RecordingMode = 'video' | 'audio'; type ComposeUiState = - | { kind: "idle" } - | { kind: "recording"; mode: RecordingMode } + | { kind: 'idle' } + | { kind: 'recording'; mode: RecordingMode } | { - kind: "review"; + kind: 'review'; mode: RecordingMode; uri: string; durationMs: number; } | { - kind: "uploading"; + kind: 'uploading'; mode: RecordingMode; uri: string; durationMs: number; @@ -47,7 +41,7 @@ interface SubmitMediaParams { fileUri: string; mimeType: string; durationMs: number; - source: "camera" | "screen"; + source: 'camera' | 'screen'; } interface ComposeDockProps { @@ -75,8 +69,8 @@ export function ComposeDock({ }: ComposeDockProps) { const userId = useAuthStore((s) => s.user?.id); - const [mode, setMode] = useState("video"); - const [ui, setUi] = useState({ kind: "idle" }); + const [mode, setMode] = useState('video'); + const [ui, setUi] = useState({ kind: 'idle' }); const [textOpen, setTextOpen] = useState(false); const [camPerm, requestCamPerm] = useCameraPermissions(); @@ -85,7 +79,7 @@ export function ComposeDock({ // Tell StreamView to fully unmount its expo-video player while we record. // That player otherwise holds the iOS AVAudioSession and crashes the camera. const setComposing = usePlaybackPauseStore((s) => s.setComposing); - const isComposing = ui.kind !== "idle" || textOpen; + const isComposing = ui.kind !== 'idle' || textOpen; useEffect(() => { setComposing(isComposing); return () => setComposing(false); @@ -98,13 +92,13 @@ export function ComposeDock({ if (forVideo) { const cam = camPerm?.granted ? camPerm : await requestCamPerm(); if (!cam.granted) { - toast.error("Camera permission is required to record video."); + toast.error('Camera permission is required to record video.'); return false; } } const mic = micPerm?.granted ? micPerm : await requestMicPerm(); if (!mic.granted) { - toast.error("Microphone permission is required to record."); + toast.error('Microphone permission is required to record.'); return false; } return true; @@ -113,46 +107,45 @@ export function ComposeDock({ ); const startRecording = useEvent(async () => { - if (ui.kind !== "idle") return; - const ok = await ensurePermissions(mode === "video"); + if (ui.kind !== 'idle') return; + const ok = await ensurePermissions(mode === 'video'); if (!ok) return; void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - setUi({ kind: "recording", mode }); + setUi({ kind: 'recording', mode }); }); const handleRecordingComplete = useCallback( ({ uri, durationMs }: { uri: string; durationMs: number }) => { void Haptics.selectionAsync(); setUi((prev) => { - const m = "mode" in prev ? prev.mode : mode; - return { kind: "review", mode: m, uri, durationMs }; + const m = 'mode' in prev ? prev.mode : mode; + return { kind: 'review', mode: m, uri, durationMs }; }); }, [mode], ); const handleRecordingCancel = useCallback(() => { - setUi({ kind: "idle" }); + setUi({ kind: 'idle' }); }, []); const sendReview = useEvent(async () => { - if (ui.kind !== "review" || !userId) return; + if (ui.kind !== 'review' || !userId) return; const captured = ui; setUi({ - kind: "uploading", + kind: 'uploading', mode: captured.mode, uri: captured.uri, durationMs: captured.durationMs, }); try { - const mimeType = - captured.mode === "audio" ? "audio/mp4" : "video/mp4"; + const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4'; if (submitMedia) { await submitMedia({ fileUri: captured.uri, mimeType, durationMs: captured.durationMs, - source: "camera", + source: 'camera', }); } else { const particleId = await uploadMediaParticle({ @@ -161,13 +154,13 @@ export function ComposeDock({ fileUri: captured.uri, mimeType, durationMs: captured.durationMs, - source: "camera", + source: 'camera', createdByHumanId: userId, }); onParticleCreated?.(particleId); } void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - setUi({ kind: "idle" }); + setUi({ kind: 'idle' }); } catch (err) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); setUi(captured); @@ -175,11 +168,11 @@ export function ComposeDock({ } }); - const retake = useCallback(() => setUi({ kind: "idle" }), []); - const cancelReview = useCallback(() => setUi({ kind: "idle" }), []); + const retake = useCallback(() => setUi({ kind: 'idle' }), []); + const cancelReview = useCallback(() => setUi({ kind: 'idle' }), []); const submitText = useEvent(async (content: string) => { - if (!userId) throw new Error("Not signed in."); + if (!userId) throw new Error('Not signed in.'); if (submitTextOverride) { await submitTextOverride(content); } else { @@ -195,9 +188,7 @@ export function ComposeDock({ }); const dockHidden = - ui.kind === "review" || - ui.kind === "uploading" || - ui.kind === "recording"; + ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording'; return ( <> @@ -209,18 +200,18 @@ export function ComposeDock({ > - setMode((m) => (m === "video" ? "audio" : "video")) + setMode((m) => (m === 'video' ? 'audio' : 'video')) } - disabled={ui.kind !== "idle"} + disabled={ui.kind !== 'idle'} accessibilityLabel={`Switch to ${ - mode === "video" ? "audio" : "video" + mode === 'video' ? 'audio' : 'video' } mode`} className={cn( - "h-11 w-11 items-center justify-center rounded-full bg-white/15", - ui.kind !== "idle" && "opacity-40", + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', )} > - {mode === "video" ? ( + {mode === 'video' ? ( ) : ( @@ -230,24 +221,22 @@ export function ComposeDock({ - - Tap to record - + Tap to record setTextOpen(true)} - disabled={ui.kind !== "idle"} + disabled={ui.kind !== 'idle'} accessibilityLabel="Compose text" className={cn( - "h-11 w-11 items-center justify-center rounded-full bg-white/15", - ui.kind !== "idle" && "opacity-40", + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', )} > @@ -256,8 +245,8 @@ export function ComposeDock({ ) : null} - {ui.kind === "recording" ? ( - ui.mode === "video" ? ( + {ui.kind === 'recording' ? ( + ui.mode === 'video' ? ( | null; - try { - broadcast = useStreamComposingBroadcast(); - } catch { - broadcast = null; - } + // null when the dock is rendered outside a stream (no presence provider). + const broadcast = useStreamComposingBroadcastOptional(); const mode: ComposingMode | null = - ui.kind === "recording" ? "recording" : textOpen ? "typing" : null; + ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null; useEffect(() => { if (silent || !broadcast) return; diff --git a/js/mobile/src/features/compose/ReviewSheet.tsx b/js/mobile/src/features/compose/ReviewSheet.tsx index 1f8824c..c71ae31 100644 --- a/js/mobile/src/features/compose/ReviewSheet.tsx +++ b/js/mobile/src/features/compose/ReviewSheet.tsx @@ -1,21 +1,21 @@ -import { useEffect } from "react"; -import { ActivityIndicator, Modal, Pressable, Text, View } from "react-native"; +import { useEffect } from 'react'; +import { ActivityIndicator, Modal, Pressable, Text, View } from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, -} from "react-native-safe-area-context"; -import { useVideoPlayer, VideoView } from "expo-video"; -import { Mic } from "lucide-react-native"; -import { toast } from "sonner-native"; -import { cn } from "@/lib/utils"; -import { toUserMessage } from "@/lib/errors"; +} from 'react-native-safe-area-context'; +import { useVideoPlayer, VideoView } from 'expo-video'; +import { Mic } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import { cn } from '@/lib/utils'; +import { toUserMessage } from '@/lib/errors'; interface ReviewSheetProps { open: boolean; /** Local file URI from the recorder. */ uri: string | null; - mode: "video" | "audio" | null; + mode: 'video' | 'audio' | null; durationMs: number; /** * True once the parent has flipped to the uploading state. The sheet stays @@ -43,10 +43,10 @@ export function ReviewSheet({ onRetake, onCancel, }: ReviewSheetProps) { - const player = useVideoPlayer(uri ?? "", (p) => { + const player = useVideoPlayer(uri ?? '', (p) => { p.loop = true; p.muted = false; - p.audioMixingMode = "mixWithOthers"; + p.audioMixingMode = 'mixWithOthers'; }); useEffect(() => { @@ -77,102 +77,102 @@ export function ReviewSheet({ onRequestClose={sending ? undefined : onCancel} > - - {uri ? ( - mode === "audio" ? ( - - - + + {uri ? ( + mode === 'audio' ? ( + + + + + + Voice message · {seconds}s + + + Tap send to share, or retake. + + + + - - Voice message · {seconds}s - - - Tap send to share, or retake. - - - - - - ) : ( - - ) - ) : null} + ) : ( + + ) + ) : null} - - - - + + - Cancel - - - - + + Cancel + + + + - - - - Retake - + + + + Retake + - - - {sending ? "Sending..." : "Send"} - - - - + + + {sending ? 'Sending...' : 'Send'} + + + + - {sending ? ( - - - Sending... - - ) : null} - + {sending ? ( + + + Sending... + + ) : null} + ); diff --git a/js/mobile/src/features/compose/TextComposeModal.tsx b/js/mobile/src/features/compose/TextComposeModal.tsx index 307a783..6bb3736 100644 --- a/js/mobile/src/features/compose/TextComposeModal.tsx +++ b/js/mobile/src/features/compose/TextComposeModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from 'react'; import { KeyboardAvoidingView, Modal, @@ -7,26 +7,23 @@ import { Text, TextInput, View, -} from "react-native"; +} from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, -} from "react-native-safe-area-context"; -import { toast } from "sonner-native"; -import { cn } from "@/lib/utils"; -import { toUserMessage } from "@/lib/errors"; +} from 'react-native-safe-area-context'; +import { toast } from 'sonner-native'; +import { cn } from '@/lib/utils'; +import { toUserMessage } from '@/lib/errors'; const IMMERSIVE_CHAR_LIMIT = 120; function getImmersiveStyle(length: number) { - if (length === 0) - return { className: "text-3xl font-semibold leading-snug" }; - if (length < 30) - return { className: "text-5xl font-semibold leading-tight" }; - if (length < 70) - return { className: "text-3xl font-semibold leading-snug" }; - return { className: "text-2xl font-normal leading-snug" }; + if (length === 0) return { className: 'text-3xl font-semibold leading-snug' }; + if (length < 30) return { className: 'text-5xl font-semibold leading-tight' }; + if (length < 70) return { className: 'text-3xl font-semibold leading-snug' }; + return { className: 'text-2xl font-normal leading-snug' }; } interface TextComposeModalProps { @@ -50,20 +47,26 @@ export function TextComposeModal({ onClose, onSubmit, }: TextComposeModalProps) { - const [content, setContent] = useState(""); + const [content, setContent] = useState(''); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); // Reset whenever the modal opens fresh. - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { - setContent(""); + setContent(''); 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]); const trimmed = content.trim(); @@ -92,60 +95,60 @@ export function TextComposeModal({ onRequestClose={onClose} > - - - - - Cancel - - - + + + - {submitting ? "Sending..." : "Send"} - - - + Cancel + + + + {submitting ? 'Sending...' : 'Send'} + + + - - - - - + + + + + ); diff --git a/js/mobile/src/features/compose/VideoRecordingOverlay.tsx b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx index eb2680c..97adca9 100644 --- a/js/mobile/src/features/compose/VideoRecordingOverlay.tsx +++ b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx @@ -1,12 +1,12 @@ -import { useEffect, useRef, useState } from "react"; -import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; -import { CameraView, type CameraType } from "expo-camera"; -import { SwitchCamera } from "lucide-react-native"; -import { logError } from "@/lib/errors"; +import { useEffect, useRef, useState } from 'react'; +import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { CameraView, type CameraType } from 'expo-camera'; +import { SwitchCamera } from 'lucide-react-native'; +import { logError } from '@/lib/errors'; import { acquireRecordingAudioSession, releaseRecordingAudioSession, -} from "@/lib/recording-audio-session"; +} from '@/lib/recording-audio-session'; const MAX_DURATION_S = 60; const VIDEO_BITRATE_BPS = 1_200_000; @@ -24,7 +24,7 @@ export function VideoRecordingOverlay({ const [cameraReady, setCameraReady] = useState(false); const [recording, setRecording] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); - const [facing, setFacing] = useState("front"); + const [facing, setFacing] = useState('front'); const startedAtRef = useRef(null); const cancelledRef = useRef(false); @@ -32,7 +32,7 @@ export function VideoRecordingOverlay({ return () => { cancelledRef.current = true; void releaseRecordingAudioSession().catch((err) => - logError(err, { scope: "compose.video.exit" }), + logError(err, { scope: 'compose.video.exit' }), ); }; }, []); @@ -44,7 +44,7 @@ export function VideoRecordingOverlay({ try { await acquireRecordingAudioSession(); } catch (err) { - logError(err, { scope: "compose.video.audioSession" }); + logError(err, { scope: 'compose.video.audioSession' }); onCancel(); return; } @@ -56,16 +56,16 @@ export function VideoRecordingOverlay({ try { result = await cam.recordAsync({ maxDuration: MAX_DURATION_S, - ...(Platform.OS === "ios" ? { codec: "hvc1" as const } : {}), + ...(Platform.OS === 'ios' ? { codec: 'hvc1' as const } : {}), }); } catch (err) { if (cancelledRef.current) return; - logError(err, { scope: "compose.video.recordAsync" }); + logError(err, { scope: 'compose.video.recordAsync' }); onCancel(); return; } finally { void releaseRecordingAudioSession().catch((err) => - logError(err, { scope: "compose.video.release" }), + logError(err, { scope: 'compose.video.release' }), ); } if (cancelledRef.current) return; @@ -118,9 +118,7 @@ export function VideoRecordingOverlay({ {!recording && cameraReady ? ( - setFacing((f) => (f === "front" ? "back" : "front")) - } + onPress={() => setFacing((f) => (f === 'front' ? 'back' : 'front'))} accessibilityLabel="Flip camera" className="h-11 w-11 items-center justify-center rounded-full bg-white/15" > @@ -137,7 +135,7 @@ export function VideoRecordingOverlay({ - REC · {elapsedSec.toString().padStart(2, "0")}s + REC · {elapsedSec.toString().padStart(2, '0')}s @@ -166,8 +164,8 @@ export function VideoRecordingOverlay({ accessibilityLabel="Start recording" className={ 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/40" + ? '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' } > diff --git a/js/mobile/src/features/huddle/HuddleScreen.tsx b/js/mobile/src/features/huddle/HuddleScreen.tsx index c307f2f..9890290 100644 --- a/js/mobile/src/features/huddle/HuddleScreen.tsx +++ b/js/mobile/src/features/huddle/HuddleScreen.tsx @@ -1,14 +1,8 @@ -import { useCallback, useEffect } from "react"; -import { - Alert, - Dimensions, - Pressable, - 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 { useCallback, useEffect } from 'react'; +import { Alert, Dimensions, Pressable, 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 { AudioSession, LiveKitRoom, @@ -17,14 +11,14 @@ import { useLocalParticipant, useRoomContext, useTracks, -} from "@livekit/react-native"; -import type { TrackReferenceOrPlaceholder } from "@livekit/components-core"; -import { Track } from "livekit-client"; -import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native"; -import { useNetwork } from "@/hooks/use-networks"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { cn } from "@/lib/utils"; -import type { RootStackScreenProps } from "@/navigation/types"; +} from '@livekit/react-native'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { Track } from 'livekit-client'; +import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide-react-native'; +import { useNetwork } from '@/hooks/use-networks'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { cn } from '@/lib/utils'; +import type { RootStackScreenProps } from '@/navigation/types'; /** * 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({ route, navigation, -}: RootStackScreenProps<"Huddle">) { +}: RootStackScreenProps<'Huddle'>) { const { token, serverUrl, streamName, networkId } = route.params; // iOS in particular requires us to bracket the room session with @@ -66,10 +60,10 @@ export function HuddleScreen({ connect={true} audio={true} video={false} - options={{ adaptiveStream: { pixelDensity: "screen" } }} + options={{ adaptiveStream: { pixelDensity: 'screen' } }} onDisconnected={leave} onError={(err) => { - Alert.alert("Huddle error", err.message ?? "Failed to connect."); + Alert.alert('Huddle error', err.message ?? 'Failed to connect.'); leave(); }} > @@ -119,15 +113,18 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) { }, [room, onLeave]); return ( - + - + {streamName} {tracks.length === 1 - ? "1 participant" + ? '1 participant' : `${tracks.length} participants`} @@ -139,7 +136,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) { {hasVideo ? ( - + ) : ( @@ -261,7 +254,9 @@ function Tile({ } 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}`; } @@ -270,7 +265,7 @@ interface ControlButtonProps { label: string; onPress: () => void; active?: boolean; - tone?: "default" | "danger"; + tone?: 'default' | 'danger'; } function ControlButton({ @@ -278,7 +273,7 @@ function ControlButton({ label, onPress, active = false, - tone = "default", + tone = 'default', }: ControlButtonProps) { // Used purely for the visual state — destructive tone always wins so // "Leave" is unmistakable regardless of toggle state. @@ -287,16 +282,15 @@ function ControlButton({ onPress={onPress} accessibilityLabel={label} className={cn( - "h-14 w-14 items-center justify-center rounded-full", - tone === "danger" - ? "bg-red-600 active:bg-red-700" + 'h-14 w-14 items-center justify-center rounded-full', + tone === 'danger' + ? 'bg-red-600 active:bg-red-700' : active - ? "bg-white/20 active:bg-white/30" - : "bg-white/10 active:bg-white/20", + ? 'bg-white/20 active:bg-white/30' + : 'bg-white/10 active:bg-white/20', )} > {icon} ); } - diff --git a/js/mobile/src/features/huddle/use-open-huddle.ts b/js/mobile/src/features/huddle/use-open-huddle.ts index ebd8c32..865d377 100644 --- a/js/mobile/src/features/huddle/use-open-huddle.ts +++ b/js/mobile/src/features/huddle/use-open-huddle.ts @@ -1,10 +1,10 @@ -import { useCallback, useState } from "react"; -import { useNavigation } from "@react-navigation/native"; -import { toast } from "sonner-native"; -import { apiClient } from "@/api/client"; -import { toUserMessage } from "@/lib/errors"; -import type { RootStackParamList } from "@/navigation/types"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useCallback, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { toast } from 'sonner-native'; +import { apiClient } from '@/api/client'; +import { toUserMessage } from '@/lib/errors'; +import type { RootStackParamList } from '@/navigation/types'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; /** * Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh @@ -26,7 +26,7 @@ export function useOpenHuddle() { networkId, streamId, ); - navigation.navigate("Huddle", { + navigation.navigate('Huddle', { networkId, streamId, streamName, diff --git a/js/mobile/src/features/networks/Drawer.tsx b/js/mobile/src/features/networks/Drawer.tsx index dee8a6f..4c03114 100644 --- a/js/mobile/src/features/networks/Drawer.tsx +++ b/js/mobile/src/features/networks/Drawer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from 'react'; import { Animated, Dimensions, @@ -7,15 +7,15 @@ import { Pressable, Text, View, -} from "react-native"; +} from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, -} from "react-native-safe-area-context"; -import { useAuthStore } from "@/stores/auth-store"; +} from 'react-native-safe-area-context'; +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 ANIM_MS = 220; @@ -26,13 +26,11 @@ interface DrawerProps { onNavigateSettings: () => void; } -export function Drawer({ - open, - onClose, - onNavigateAccount, -}: DrawerProps) { - const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current; - const backdropOpacity = useRef(new Animated.Value(0)).current; +export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { + // Lazy-init so each Animated.Value is created once; the setters are never + // called — the values are mutated internally by the native driver. + const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH)); + const [backdropOpacity] = useState(() => new Animated.Value(0)); useEffect(() => { Animated.parallel([ @@ -55,7 +53,7 @@ export function Drawer({ const signOut = useAuthStore((s) => s.signOut); 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 ( - - - - + + + + - - - - - - {initials} - + + + + + + {initials} + + + + + {user?.email_prefix ?? ''} + + + {user?.email ?? ''} + + - - - {user?.email_prefix ?? ""} - - - {user?.email ?? ""} - + + + { + onClose(); + onNavigateAccount(); + }} + /> - - - { - onClose(); - onNavigateAccount(); - }} - /> - - - - { - void signOut(); - }} - tone="destructive" - /> - - - - + + { + void signOut(); + }} + tone="destructive" + /> + + + + ); @@ -140,12 +138,12 @@ function DrawerRow({ label, onPress, disabled, - tone = "default", + tone = 'default', }: { label: string; onPress: () => void; disabled?: boolean; - tone?: "default" | "destructive"; + tone?: 'default' | 'destructive'; }) { return ( {label} diff --git a/js/mobile/src/features/networks/NetworkListScreen.tsx b/js/mobile/src/features/networks/NetworkListScreen.tsx index 4efb26a..8473932 100644 --- a/js/mobile/src/features/networks/NetworkListScreen.tsx +++ b/js/mobile/src/features/networks/NetworkListScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -6,24 +6,24 @@ import { RefreshControl, Text, View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import type { Network } from "@/api/types"; -import { useNetworks } from "@/hooks/use-networks"; -import { useAuthStore } from "@/stores/auth-store"; -import { toUserMessage } from "@/lib/errors"; -import type { RootStackScreenProps } from "@/navigation/types"; -import { FlowyLogo } from "@/components/FlowyLogo"; -import { ListSeparator } from "@/components/ListSeparator"; -import { Drawer } from "./Drawer"; +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import type { Network } from '@/api/types'; +import { useNetworks } from '@/hooks/use-networks'; +import { useAuthStore } from '@/stores/auth-store'; +import { toUserMessage } from '@/lib/errors'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { FlowyLogo } from '@/components/FlowyLogo'; +import { ListSeparator } from '@/components/ListSeparator'; +import { Drawer } from './Drawer'; export function NetworkListScreen({ navigation, -}: RootStackScreenProps<"NetworkList">) { +}: RootStackScreenProps<'NetworkList'>) { const [drawerOpen, setDrawerOpen] = useState(false); const { data, isLoading, refetch, error } = useNetworks(); 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 // isRefetching can leave the native spinner visually stuck after the @@ -39,7 +39,7 @@ export function NetworkListScreen({ }, [refetch]); return ( - + setDrawerOpen(true)} @@ -81,7 +81,7 @@ export function NetworkListScreen({ - navigation.navigate("StreamList", { networkId: item.id }) + navigation.navigate('StreamList', { networkId: item.id }) } /> )} @@ -91,8 +91,8 @@ export function NetworkListScreen({ setDrawerOpen(false)} - onNavigateAccount={() => navigation.navigate("Account")} - onNavigateSettings={() => navigation.navigate("Settings")} + onNavigateAccount={() => navigation.navigate('Account')} + onNavigateSettings={() => navigation.navigate('Settings')} /> ); @@ -115,8 +115,8 @@ function NetworkCard({ {network.name} - {network.humans.length}{" "} - {network.humans.length === 1 ? "member" : "members"} + {network.humans.length}{' '} + {network.humans.length === 1 ? 'member' : 'members'} @@ -128,7 +128,7 @@ function EmptyState() { return ( - You aren't in any networks yet. + You aren’t in any networks yet. Ask a friend for an invite, or create one on desktop. diff --git a/js/mobile/src/features/settings/AccountScreen.tsx b/js/mobile/src/features/settings/AccountScreen.tsx index ee1a133..e57f93a 100644 --- a/js/mobile/src/features/settings/AccountScreen.tsx +++ b/js/mobile/src/features/settings/AccountScreen.tsx @@ -1,13 +1,13 @@ -import { Pressable, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { useAuthStore } from "@/stores/auth-store"; -import type { RootStackScreenProps } from "@/navigation/types"; +import { Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useAuthStore } from '@/stores/auth-store'; +import type { RootStackScreenProps } from '@/navigation/types'; -export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) { +export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { const user = useAuthStore((s) => s.user); return ( - + navigation.goBack()} className="px-2 py-1"> @@ -19,7 +19,7 @@ export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) { - + ); diff --git a/js/mobile/src/features/settings/SettingsScreen.tsx b/js/mobile/src/features/settings/SettingsScreen.tsx index 1a4b4e7..6b71a6e 100644 --- a/js/mobile/src/features/settings/SettingsScreen.tsx +++ b/js/mobile/src/features/settings/SettingsScreen.tsx @@ -1,12 +1,12 @@ -import { Pressable, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import type { RootStackScreenProps } from "@/navigation/types"; +import { Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import type { RootStackScreenProps } from '@/navigation/types'; export function SettingsScreen({ navigation, -}: RootStackScreenProps<"Settings">) { +}: RootStackScreenProps<'Settings'>) { return ( - + navigation.goBack()} className="px-2 py-1"> diff --git a/js/mobile/src/features/stream-view/DeletedParticleView.tsx b/js/mobile/src/features/stream-view/DeletedParticleView.tsx index f90c750..2a94575 100644 --- a/js/mobile/src/features/stream-view/DeletedParticleView.tsx +++ b/js/mobile/src/features/stream-view/DeletedParticleView.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; -import { Text, View } from "react-native"; -import { Trash2 } from "lucide-react-native"; -import type { Particle } from "@/api/types"; -import { useNetwork } from "@/hooks/use-networks"; -import { resolveHumanDisplay } from "@/lib/humans"; +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; +import { Trash2 } from 'lucide-react-native'; +import type { Particle } from '@/api/types'; +import { useNetwork } from '@/hooks/use-networks'; +import { resolveHumanDisplay } from '@/lib/humans'; // 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 @@ -25,7 +25,9 @@ export function DeletedParticleView({ }: DeletedParticleViewProps) { const network = useNetwork(networkId); 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 ? resolveHumanDisplay(deleterId, network?.humans) : null; diff --git a/js/mobile/src/features/stream-view/EditParticleSheet.tsx b/js/mobile/src/features/stream-view/EditParticleSheet.tsx index 2f480df..4eb1931 100644 --- a/js/mobile/src/features/stream-view/EditParticleSheet.tsx +++ b/js/mobile/src/features/stream-view/EditParticleSheet.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from "react"; -import { Pressable, Text, TextInput, View } from "react-native"; -import { toast } from "sonner-native"; -import { cn } from "@/lib/utils"; -import { toUserMessage } from "@/lib/errors"; -import { editTextParticleContent } from "@/lib/firestore-particles"; -import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; -import { BottomSheet } from "@/components/BottomSheet"; +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { cn } from '@/lib/utils'; +import { toUserMessage } from '@/lib/errors'; +import { editTextParticleContent } from '@/lib/firestore-particles'; +import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; +import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; +import { BottomSheet } from '@/components/BottomSheet'; interface EditParticleSheetProps { open: boolean; @@ -25,17 +25,20 @@ export function EditParticleSheet({ particleId, currentContent, }: EditParticleSheetProps) { - useSuspendPlayback(open, "edit-particle"); + useSuspendPlayback(open, 'edit-particle'); const [content, setContent] = useState(currentContent); 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) { setContent(currentContent); setSaving(false); } - }, [open, currentContent]); + } const trimmed = content.trim(); const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent; @@ -65,11 +68,11 @@ export function EditParticleSheet({ - {saving ? "Saving..." : "Save"} + {saving ? 'Saving...' : 'Save'} diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx index 3a25ffc..51e6959 100644 --- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx +++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx @@ -1,20 +1,20 @@ -import { useEffect } from "react"; -import { Text, View } from "react-native"; +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; import { FileIcon, HelpCircle, ScrollText, BookOpen, type LucideIcon, -} from "lucide-react-native"; -import type { Particle } from "@/api/types"; -import { useNetwork } from "@/hooks/use-networks"; -import { resolveHumanDisplay } from "@/lib/humans"; +} from 'lucide-react-native'; +import type { Particle } from '@/api/types'; +import { useNetwork } from '@/hooks/use-networks'; +import { resolveHumanDisplay } from '@/lib/humans'; const TYPE_META: Record = { - quest: { icon: ScrollText, label: "Quest" }, - paper: { icon: BookOpen, label: "Paper" }, - file: { icon: FileIcon, label: "File" }, + quest: { icon: ScrollText, label: 'Quest' }, + paper: { icon: BookOpen, label: 'Paper' }, + file: { icon: FileIcon, label: 'File' }, }; const PLACEHOLDER_DURATION_MS = 5000; @@ -44,13 +44,13 @@ export function FallbackParticleView({ const Icon = meta.icon; const title = (() => { switch (particle.type) { - case "quest": + case 'quest': return particle.properties.title; - case "paper": + case 'paper': return particle.properties.title; - case "file": + case 'file': return particle.properties.filename; - case "folder": + case 'folder': return particle.properties.name; default: return null; diff --git a/js/mobile/src/features/stream-view/MediaParticleView.tsx b/js/mobile/src/features/stream-view/MediaParticleView.tsx index e10955a..0b043c2 100644 --- a/js/mobile/src/features/stream-view/MediaParticleView.tsx +++ b/js/mobile/src/features/stream-view/MediaParticleView.tsx @@ -1,17 +1,17 @@ -import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Text, View } from "react-native"; -import { Mic, Video as VideoIcon } from "lucide-react-native"; -import { useEventListener } from "expo"; -import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video"; -import type { Particle } from "@/api/types"; -import { apiClient } from "@/api/client"; -import { logError } from "@/lib/errors"; -import { useEvent } from "@/hooks/use-event"; -import { useTranscriptPlayback } from "@/hooks/use-transcript-playback"; -import { TranscriptOverlay } from "./TranscriptOverlay"; -import { useStreamSafeArea } from "./stream-safe-area"; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Text, View } from 'react-native'; +import { Mic, Video as VideoIcon } from 'lucide-react-native'; +import { useEventListener } from 'expo'; +import { useVideoPlayer, VideoView, type VideoPlayerStatus } from 'expo-video'; +import type { Particle } from '@/api/types'; +import { apiClient } from '@/api/client'; +import { logError } from '@/lib/errors'; +import { useEvent } from '@/hooks/use-event'; +import { useTranscriptPlayback } from '@/hooks/use-transcript-playback'; +import { TranscriptOverlay } from './TranscriptOverlay'; +import { useStreamSafeArea } from './stream-safe-area'; -type MediaParticle = Extract; +type MediaParticle = Extract; interface MediaParticleViewProps { particle: MediaParticle; @@ -19,7 +19,7 @@ interface MediaParticleViewProps { onEnded: () => void; onProgress: (ratio: number) => void; /** "cover" fills the screen (may crop); "contain" fits the whole frame. */ - contentFit?: "cover" | "contain"; + contentFit?: 'cover' | 'contain'; } const TICK_MS = 150; @@ -42,13 +42,13 @@ export function MediaParticleView({ paused, onEnded, onProgress, - contentFit = "cover", + contentFit = 'cover', }: MediaParticleViewProps) { const activeObjectId = particle.properties.transcoded_object_id ?? particle.properties.object_id; const activeMime = particle.properties.transcoded_mime_type ?? particle.properties.mime_type; - const isAudio = activeMime.startsWith("audio/"); + const isAudio = activeMime.startsWith('audio/'); const isPlayable = isPlayableMime(activeMime); // Reset progress as the active particle changes — independent of playback @@ -89,7 +89,7 @@ function PlayableMediaView({ paused: boolean; onEnded: () => void; onProgress: (ratio: number) => void; - contentFit: "cover" | "contain"; + contentFit: 'cover' | 'contain'; }) { const [sourceUri, setSourceUri] = useState(null); const [resolveError, setResolveError] = useState(null); @@ -101,24 +101,26 @@ function PlayableMediaView({ // resolves a new active object id. useEffect(() => { let cancelled = false; - setSourceUri(null); - setResolveError(null); - setCurrentTime(0); apiClient .getParticleDownloadUrl(activeObjectId) .then((url) => { if (!cancelled) setSourceUri(url); }) .catch((err) => { - logError(err, { scope: "media.download-url" }); + logError(err, { scope: 'media.download-url' }); if (!cancelled) setResolveError(err as Error); }); return () => { 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]); - const player = useVideoPlayer(sourceUri ?? "", (p) => { + const player = useVideoPlayer(sourceUri ?? '', (p) => { p.loop = false; p.muted = false; p.timeUpdateEventInterval = 0.15; @@ -126,7 +128,7 @@ function PlayableMediaView({ // the player blocks expo-camera from acquiring the session for video // recording (audio works because expo-audio deactivates other sessions // natively before claiming the session). - p.audioMixingMode = "mixWithOthers"; + p.audioMixingMode = 'mixWithOthers'; }); // 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 // duration ratios because video duration may be 0 for the first frame or two. - useEventListener(player, "statusChange", ({ status }) => { - if (status === ("idle" satisfies VideoPlayerStatus)) { + useEventListener(player, 'statusChange', ({ status }) => { + if (status === ('idle' satisfies VideoPlayerStatus)) { // ignored — happens during source swap } }); @@ -151,7 +153,7 @@ function PlayableMediaView({ // Drive caption highlighting from the player's own timeUpdate cadence // (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which // naturally freezes the active word/sentence — no extra plumbing needed. - useEventListener(player, "timeUpdate", ({ currentTime: t }) => { + useEventListener(player, 'timeUpdate', ({ currentTime: t }) => { setCurrentTime(t); }); @@ -190,7 +192,7 @@ function PlayableMediaView({ return ( - Couldn't load this {isAudio ? "voice message" : "video"}. + Couldn’t load this {isAudio ? 'voice message' : 'video'}. Tap forward to continue. @@ -284,12 +286,10 @@ function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) { )} - {isAudio ? "Voice message" : "Video message"} + {isAudio ? 'Voice message' : 'Video message'} - - View on desktop - + View on desktop 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. // WebM/VP9 (the legacy desktop format) is not decodable. return ( - mime === "video/mp4" || - mime === "video/quicktime" || - mime === "audio/mp4" || - mime === "audio/aac" || - mime === "audio/x-m4a" || - mime === "audio/mpeg" + mime === 'video/mp4' || + mime === 'video/quicktime' || + mime === 'audio/mp4' || + mime === 'audio/aac' || + mime === 'audio/x-m4a' || + mime === 'audio/mpeg' ); } diff --git a/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx index de09509..a74f481 100644 --- a/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx +++ b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx @@ -1,5 +1,5 @@ -import { useEffect } from "react"; -import { Text, View } from "react-native"; +import { useEffect } from 'react'; +import { Text, View } from 'react-native'; import Animated, { Easing, @@ -7,7 +7,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming, -} from "react-native-reanimated"; +} from 'react-native-reanimated'; interface PlaybackPageIndicatorProps { total: number; @@ -78,7 +78,7 @@ export function PlaybackPageIndicator({ {paginated && current >= 0 && ( {current + 1} / {total} @@ -95,7 +95,11 @@ function GhostStub({ visible }: { visible: boolean }) { return ( ); } @@ -117,7 +121,10 @@ function Segment({ isActive, isPast, progress, paused }: SegmentProps) { useEffect(() => { if (isPast) { 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; } if (!isActive) { diff --git a/js/mobile/src/features/stream-view/ReactionSheet.tsx b/js/mobile/src/features/stream-view/ReactionSheet.tsx index 4cdd6b2..f02c915 100644 --- a/js/mobile/src/features/stream-view/ReactionSheet.tsx +++ b/js/mobile/src/features/stream-view/ReactionSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from 'react'; import { Dimensions, KeyboardAvoidingView, @@ -8,18 +8,15 @@ import { Text, TextInput, View, -} from "react-native"; +} from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, -} from "react-native-safe-area-context"; -import { Send, X } from "lucide-react-native"; -import * as Haptics from "expo-haptics"; -import { - Gesture, - GestureDetector, -} from "react-native-gesture-handler"; +} from 'react-native-safe-area-context'; +import { Send, X } from 'lucide-react-native'; +import * as Haptics from 'expo-haptics'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, @@ -29,16 +26,15 @@ import Animated, { useSharedValue, withSpring, withTiming, -} from "react-native-reanimated"; -import { REACTION_EMOJIS, type Reactions } from "@/api/types"; -import type { Human } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { sanitizeReactionText } from "@/lib/firestore-particles"; -import { cn } from "@/lib/utils"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +} from 'react-native-reanimated'; +import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { sanitizeReactionText } from '@/lib/firestore-particles'; +import { cn } from '@/lib/utils'; +import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; const TEXT_REACTION_MAX = 40; -const SCREEN_HEIGHT = Dimensions.get("window").height; +const SCREEN_HEIGHT = Dimensions.get('window').height; const ANIMATION_MS = 240; const EMOJI_SET = new Set(REACTION_EMOJIS); @@ -75,7 +71,7 @@ export function ReactionSheet({ }: ReactionSheetProps) { // Suspend playback whenever the sheet is mounted-and-open. The Modal // 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 // has its starting position rendered. Using local `mounted` state lets us @@ -85,8 +81,7 @@ export function ReactionSheet({ useEffect(() => { if (open) { - setMounted(true); - // Schedule animation after the modal mounts + // Schedule the slide-in after the modal mounts (handled at render time). requestAnimationFrame(() => { translateY.value = withSpring(0, { damping: 24, @@ -115,14 +110,18 @@ export function ReactionSheet({ .activeOffsetY(10) .failOffsetX([-25, 25]) .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); }) .onEnd((e) => { - "worklet"; + 'worklet'; if (e.translationY > 120 || e.velocityY > 800) { runOnJS(dismiss)(); } else { + // eslint-disable-next-line react-hooks/immutability translateY.value = withSpring(0, { damping: 24, stiffness: 260, @@ -152,25 +151,31 @@ export function ReactionSheet({ const activeTextKeys = useMemo( () => Object.keys(reactions ?? {}).filter( - (k) => - !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0, + (k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0, ), [reactions], ); // --- Text reaction input --- - const [text, setText] = useState(""); + const [text, setText] = useState(''); - useEffect(() => { - if (open) setText(""); - }, [open]); + // Mount on open (staying mounted through the exit animation) and clear the + // input. Render-time adjustment avoids a setState-in-effect cascade. + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setMounted(true); + setText(''); + } + } const submitText = () => { const trimmed = text.trim(); if (!trimmed) return; void Haptics.selectionAsync(); onToggle(trimmed.slice(0, TEXT_REACTION_MAX)); - setText(""); + setText(''); onClose(); }; @@ -190,180 +195,184 @@ export function ReactionSheet({ onRequestClose={dismiss} > - - - - + + + + - - - - - - {/* Drag handle — affords downward dismissal at a glance. */} - + + + + + + {/* Drag handle — affords downward dismissal at a glance. */} + - - - React - - - - + + + React + + + + + - - {/* Existing reactions row — tap a pill to toggle yours. */} - {activeEmojis.length > 0 || activeTextKeys.length > 0 ? ( - - {activeEmojis.map((emoji) => { - const reactors = reactions![emoji]; - const isMine = reactors.includes(currentHumanId); + {/* Existing reactions row — tap a pill to toggle yours. */} + {activeEmojis.length > 0 || activeTextKeys.length > 0 ? ( + + {activeEmojis.map((emoji) => { + const reactors = reactions![emoji]; + const isMine = reactors.includes(currentHumanId); + return ( + 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', + )} + > + {emoji} + + {reactors.length} + + + ); + })} + + {activeTextKeys.map((key) => { + const reactors = reactions![key]; + const isMine = reactors.includes(currentHumanId); + const firstReactor = resolveHumanDisplay( + reactors[0], + humans, + ); + return ( + 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', + )} + > + + + {firstReactor.initials} + + + + {key} + + {reactors.length > 1 ? ( + + {reactors.length} + + ) : null} + + ); + })} + + ) : null} + + {/* Quick-pick emoji palette — six big tappable buttons. */} + + {REACTION_EMOJIS.slice(0, 6).map((emoji) => { + const isMine = + reactions?.[emoji]?.includes(currentHumanId) ?? false; return ( handleEmoji(emoji)} + accessibilityLabel={`React with ${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", + 'h-14 w-14 items-center justify-center rounded-full', + isMine ? 'bg-white/25' : 'bg-white/10', )} > - {emoji} - - {reactors.length} - - - ); - })} - - {activeTextKeys.map((key) => { - const reactors = reactions![key]; - const isMine = reactors.includes(currentHumanId); - const firstReactor = resolveHumanDisplay( - reactors[0], - humans, - ); - return ( - 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", - )} - > - - - {firstReactor.initials} - - - - {key} - - {reactors.length > 1 ? ( - - {reactors.length} - - ) : null} + {emoji} ); })} - ) : null} - {/* Quick-pick emoji palette — six big tappable buttons. */} - - {REACTION_EMOJIS.slice(0, 6).map((emoji) => { - const isMine = - reactions?.[emoji]?.includes(currentHumanId) ?? false; - return ( - handleEmoji(emoji)} - accessibilityLabel={`React with ${emoji}`} - className={cn( - "h-14 w-14 items-center justify-center rounded-full", - isMine ? "bg-white/25" : "bg-white/10", - )} - > - {emoji} - - ); - })} - - - {/* Text reaction input — 40-char cap matches desktop. */} - - - - setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX)) - } - placeholder="Send a quick reply..." - placeholderTextColor="rgba(255,255,255,0.4)" - maxLength={TEXT_REACTION_MAX} - autoCapitalize="none" - autoCorrect={false} - onSubmitEditing={submitText} - returnKeyType="send" - className="text-white text-base" - /> + {/* Text reaction input — 40-char cap matches desktop. */} + + + + setText( + sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX), + ) + } + placeholder="Send a quick reply..." + placeholderTextColor="rgba(255,255,255,0.4)" + maxLength={TEXT_REACTION_MAX} + autoCapitalize="none" + autoCorrect={false} + onSubmitEditing={submitText} + returnKeyType="send" + className="text-white text-base" + /> + + + + - - - - - - - - - + + + + + ); diff --git a/js/mobile/src/features/stream-view/ReactionStack.tsx b/js/mobile/src/features/stream-view/ReactionStack.tsx index 3b64714..c88afb0 100644 --- a/js/mobile/src/features/stream-view/ReactionStack.tsx +++ b/js/mobile/src/features/stream-view/ReactionStack.tsx @@ -1,11 +1,10 @@ -import { useMemo } from "react"; -import { Pressable, Text, View } from "react-native"; -import { Plus } from "lucide-react-native"; -import * as Haptics from "expo-haptics"; -import { REACTION_EMOJIS, type Reactions } from "@/api/types"; -import type { Human } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { cn } from "@/lib/utils"; +import { useMemo } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Plus } from 'lucide-react-native'; +import * as Haptics from 'expo-haptics'; +import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { cn } from '@/lib/utils'; const EMOJI_SET = new Set(REACTION_EMOJIS); @@ -58,12 +57,12 @@ export function ReactionStack({ key={emoji} onPress={() => handleToggle(emoji)} className={cn( - "flex-row items-center gap-1 rounded-full px-2 py-1", - isMine ? "bg-white/25" : "bg-black/45", + 'flex-row items-center gap-1 rounded-full px-2 py-1', + isMine ? 'bg-white/25' : 'bg-black/45', )} style={ isMine - ? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" } + ? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' } : undefined } > @@ -84,13 +83,13 @@ export function ReactionStack({ key={text} onPress={() => handleToggle(text)} className={cn( - "flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5", - isMine ? "bg-white/25" : "bg-black/45", + 'flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5', + isMine ? 'bg-white/25' : 'bg-black/45', )} style={[ { maxWidth: 200 }, isMine - ? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" } + ? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' } : null, ]} > @@ -99,10 +98,7 @@ export function ReactionStack({ {firstReactor.initials} - + {text} {reactors.length > 1 ? ( diff --git a/js/mobile/src/features/stream-view/RenameStreamSheet.tsx b/js/mobile/src/features/stream-view/RenameStreamSheet.tsx index 6a94afb..66c1c46 100644 --- a/js/mobile/src/features/stream-view/RenameStreamSheet.tsx +++ b/js/mobile/src/features/stream-view/RenameStreamSheet.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from "react"; -import { Pressable, Text, TextInput, View } from "react-native"; -import { toast } from "sonner-native"; -import { cn } from "@/lib/utils"; -import { toUserMessage } from "@/lib/errors"; -import { updateParticleProperties } from "@/lib/firestore-particles"; -import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; -import { BottomSheet } from "@/components/BottomSheet"; +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { cn } from '@/lib/utils'; +import { toUserMessage } from '@/lib/errors'; +import { updateParticleProperties } from '@/lib/firestore-particles'; +import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; +import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; +import { BottomSheet } from '@/components/BottomSheet'; interface RenameStreamSheetProps { open: boolean; @@ -23,17 +23,20 @@ export function RenameStreamSheet({ streamId, currentName, }: RenameStreamSheetProps) { - useSuspendPlayback(open, "rename-stream"); + useSuspendPlayback(open, 'rename-stream'); const [name, setName] = useState(currentName); 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) { setName(currentName); setSaving(false); } - }, [open, currentName]); + } const trimmed = name.trim(); const canSave = !saving && trimmed.length > 0 && trimmed !== currentName; @@ -43,7 +46,7 @@ export function RenameStreamSheet({ setSaving(true); try { const docPath = toFirestoreDocPath(particlePath(networkId, [streamId])); - await updateParticleProperties<"stream">(docPath, { name: trimmed }); + await updateParticleProperties<'stream'>(docPath, { name: trimmed }); onClose(); } catch (err) { toast.error(toUserMessage(err)); @@ -58,18 +61,14 @@ export function RenameStreamSheet({ Cancel Rename - + - {saving ? "Saving..." : "Save"} + {saving ? 'Saving...' : 'Save'} diff --git a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx index 948f4de..1a257cf 100644 --- a/js/mobile/src/features/stream-view/StreamActionsSheet.tsx +++ b/js/mobile/src/features/stream-view/StreamActionsSheet.tsx @@ -1,27 +1,27 @@ -import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; import { CircleCheckBig, CircleDot, Pencil, Trash2, Users, -} from "lucide-react-native"; -import { cn } from "@/lib/utils"; -import { BottomSheet } from "@/components/BottomSheet"; +} from 'lucide-react-native'; +import { cn } from '@/lib/utils'; +import { BottomSheet } from '@/components/BottomSheet'; export type StreamActionId = - | "toggle-status" - | "rename" - | "members" - | "edit-particle" - | "delete-particle"; + | 'toggle-status' + | 'rename' + | 'members' + | 'edit-particle' + | 'delete-particle'; interface StreamActionsSheetProps { open: boolean; onClose: () => void; onSelect: (action: StreamActionId) => void; - streamStatus: "open" | "closed"; + streamStatus: 'open' | 'closed'; isCreator: boolean; /** True when the *current* particle is a text particle this user authored. */ canEditParticle: boolean; @@ -63,34 +63,32 @@ export function StreamActionsSheet({ ) : ( ) } - label={ - streamStatus === "open" ? "Close stream" : "Reopen stream" - } - onPress={() => choose("toggle-status")} + label={streamStatus === 'open' ? 'Close stream' : 'Reopen stream'} + onPress={() => choose('toggle-status')} /> } label="Members" - onPress={() => choose("members")} + onPress={() => choose('members')} /> {isCreator ? ( } label="Rename stream" - onPress={() => choose("rename")} + onPress={() => choose('rename')} /> ) : null} {canEditParticle ? ( } label="Edit particle" - onPress={() => choose("edit-particle")} + onPress={() => choose('edit-particle')} /> ) : null} {canDeleteParticle ? ( @@ -98,7 +96,7 @@ export function StreamActionsSheet({ icon={} label="Delete particle" tone="destructive" - onPress={() => choose("delete-particle")} + onPress={() => choose('delete-particle')} /> ) : null} @@ -119,12 +117,12 @@ function ActionRow({ icon, label, onPress, - tone = "default", + tone = 'default', }: { icon: React.ReactNode; label: string; onPress: () => void; - tone?: "default" | "destructive"; + tone?: 'default' | 'destructive'; }) { return ( {icon} {label} diff --git a/js/mobile/src/features/stream-view/StreamMembersSheet.tsx b/js/mobile/src/features/stream-view/StreamMembersSheet.tsx index bcbeeae..28f96f7 100644 --- a/js/mobile/src/features/stream-view/StreamMembersSheet.tsx +++ b/js/mobile/src/features/stream-view/StreamMembersSheet.tsx @@ -1,28 +1,28 @@ -import { useMemo } from "react"; -import { Pressable, ScrollView, Text, View } from "react-native"; -import { Globe, Lock, X } from "lucide-react-native"; -import { toast } from "sonner-native"; -import type { Particle } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { useNetwork } from "@/hooks/use-networks"; -import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; -import { updateParticleVisibleTo } from "@/lib/firestore-particles"; +import { useMemo } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { Globe, Lock, X } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import type { Particle } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { useNetwork } from '@/hooks/use-networks'; +import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; +import { updateParticleVisibleTo } from '@/lib/firestore-particles'; import { buildCustomVisibility, buildNetworkVisibility, parseVisibleTo, -} from "@/lib/stream-visibility"; -import { toUserMessage } from "@/lib/errors"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; -import { BottomSheet } from "@/components/BottomSheet"; -import { Avatar } from "@/components/Avatar"; -import { useStreamPresence } from "./stream-presence-context"; +} from '@/lib/stream-visibility'; +import { toUserMessage } from '@/lib/errors'; +import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; +import { BottomSheet } from '@/components/BottomSheet'; +import { Avatar } from '@/components/Avatar'; +import { useStreamPresence } from './stream-presence-context'; interface StreamMembersSheetProps { open: boolean; onClose: () => void; networkId: string; - streamParticle: Particle & { type: "stream" }; + streamParticle: Particle & { type: 'stream' }; isCreator: boolean; } @@ -38,7 +38,7 @@ export function StreamMembersSheet({ streamParticle, isCreator, }: StreamMembersSheetProps) { - useSuspendPlayback(open, "stream-members"); + useSuspendPlayback(open, 'stream-members'); const { onlineHumanIds } = useStreamPresence(); const network = useNetwork(networkId); @@ -52,7 +52,7 @@ export function StreamMembersSheet({ ); const memberIds = - visibility.mode === "network" + visibility.mode === 'network' ? humans.map((h) => h.id) : visibility.humanIds; const memberSet = new Set(memberIds); @@ -70,7 +70,7 @@ export function StreamMembersSheet({ const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId])); const removeMember = (id: string) => { - if (visibility.mode !== "custom") return; + if (visibility.mode !== 'custom') return; if (id === creatorId) return; const next = visibility.humanIds.filter((x) => x !== id); if (next.length === 0) return; @@ -78,7 +78,7 @@ export function StreamMembersSheet({ }; const addMember = (id: string) => { - if (visibility.mode !== "custom") return; + if (visibility.mode !== 'custom') return; void apply(buildCustomVisibility([...visibility.humanIds, id])); }; @@ -99,13 +99,13 @@ export function StreamMembersSheet({ {isCreator ? ( } label="Network-wide" onPress={setNetworkWide} /> } label="Specific people" onPress={setCustomOnlyCreator} @@ -113,19 +113,19 @@ export function StreamMembersSheet({ ) : ( - {visibility.mode === "network" ? ( + {visibility.mode === 'network' ? ( <> - Everyone in {network?.name ?? "network"} + Everyone in {network?.name ?? 'network'} ) : ( <> - {memberIds.length} specific{" "} - {memberIds.length === 1 ? "person" : "people"} + {memberIds.length} specific{' '} + {memberIds.length === 1 ? 'person' : 'people'} )} @@ -136,19 +136,16 @@ export function StreamMembersSheet({ - {visibility.mode === "network" ? "Has access" : "People"} ·{" "} + {visibility.mode === 'network' ? 'Has access' : 'People'} ·{' '} {memberIds.length} {memberIds.map((id) => { const display = resolveHumanDisplay(id, humans); const isCreatorRow = id === creatorId; const canRemove = - isCreator && visibility.mode === "custom" && !isCreatorRow; + isCreator && visibility.mode === 'custom' && !isCreatorRow; return ( - + {display.displayName} {display.exists ? ( - + {display.email} ) : null} @@ -194,7 +188,7 @@ export function StreamMembersSheet({ {isCreator && - visibility.mode === "custom" && + visibility.mode === 'custom' && availableToAdd.length > 0 ? ( @@ -221,10 +215,7 @@ export function StreamMembersSheet({ > {display.displayName} - + {display.email} @@ -254,16 +245,14 @@ function ModePill({ {icon} {label} diff --git a/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx index d756cdd..5a3539a 100644 --- a/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx +++ b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx @@ -1,9 +1,9 @@ -import { Text, View } from "react-native"; -import type { Network, Particle } from "@/api/types"; -import { resolveHumanDisplay } from "@/lib/humans"; -import { RelativeTimestamp } from "@/components/RelativeTimestamp"; -import { Avatar } from "@/components/Avatar"; -import { useStreamPresence } from "./stream-presence-context"; +import { Text, View } from 'react-native'; +import type { Network, Particle } from '@/api/types'; +import { resolveHumanDisplay } from '@/lib/humans'; +import { RelativeTimestamp } from '@/components/RelativeTimestamp'; +import { Avatar } from '@/components/Avatar'; +import { useStreamPresence } from './stream-presence-context'; interface StreamMetadataHeaderProps { particle: Particle | null; @@ -26,7 +26,7 @@ export function StreamMetadataHeader({ ); 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 ? onlineHumanIds.has(particle.created_by_human_id) : false; @@ -40,10 +40,7 @@ export function StreamMetadataHeader({ online={isOnline} /> - + {display.displayName} @@ -53,7 +50,7 @@ export function StreamMetadataHeader({ /> {editedAt ? ( - · edited{" "} + · edited{' '} ) : null} diff --git a/js/mobile/src/features/stream-view/StreamTopActions.tsx b/js/mobile/src/features/stream-view/StreamTopActions.tsx index 8d55602..fba78a8 100644 --- a/js/mobile/src/features/stream-view/StreamTopActions.tsx +++ b/js/mobile/src/features/stream-view/StreamTopActions.tsx @@ -1,23 +1,23 @@ -import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; import { EllipsisVertical, Globe, Headphones, Maximize2, Minimize2, -} from "lucide-react-native"; -import type { Human, Particle } from "@/api/types"; -import { parseVisibleTo } from "@/lib/stream-visibility"; -import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/Avatar"; -import { useOpenHuddle } from "@/features/huddle/use-open-huddle"; -import { useStreamPresence } from "./stream-presence-context"; +} from 'lucide-react-native'; +import type { Human, Particle } from '@/api/types'; +import { parseVisibleTo } from '@/lib/stream-visibility'; +import { cn } from '@/lib/utils'; +import { Avatar } from '@/components/Avatar'; +import { useOpenHuddle } from '@/features/huddle/use-open-huddle'; +import { useStreamPresence } from './stream-presence-context'; interface StreamTopActionsProps { networkId: string; - streamParticle: Particle & { type: "stream" }; + streamParticle: Particle & { type: 'stream' }; humans: Human[]; - videoFit: "cover" | "contain"; + videoFit: 'cover' | 'contain'; onToggleVideoFit: () => void; onOpenMembers: () => void; onOpenActions: () => void; @@ -48,7 +48,7 @@ export function StreamTopActions({ const huddleActive = huddleCount > 0; const visibility = parseVisibleTo(streamParticle.visible_to, networkId); const memberIds = - visibility.mode === "network" + visibility.mode === 'network' ? humans.map((h) => h.id) : visibility.humanIds; const shown = memberIds.slice(0, MAX_AVATARS); @@ -61,15 +61,12 @@ export function StreamTopActions({ accessibilityLabel="Stream members" 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 ? ( ) : ( {shown.map((id, idx) => ( - + {/* The stack ring matches the chrome's translucent bg so it reads as a separator without painting hard black halos. */} {huddleLoading ? ( @@ -124,14 +121,16 @@ export function StreamTopActions({ - {videoFit === "cover" ? ( + {videoFit === 'cover' ? ( ) : ( diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index cc65acf..e1e438e 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -1,14 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; -import { Alert, Dimensions, Pressable, Text, View } from "react-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 { useCallback, useState } from 'react'; +import { Alert, Dimensions, Pressable, Text, View } from 'react-native'; +import { useIsFocused } from '@react-navigation/native'; import { - Gesture, - GestureDetector, -} from "react-native-gesture-handler"; + 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 { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, @@ -17,54 +17,54 @@ import Animated, { useSharedValue, withSpring, withTiming, -} from "react-native-reanimated"; -import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import { isParticleDeleted, type Particle } from "@/api/types"; +} from 'react-native-reanimated'; +import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; +import { isParticleDeleted, type Particle } from '@/api/types'; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath, -} from "@/lib/particle-path"; +} from '@/lib/particle-path'; import { softDeleteParticle, toggleParticleReaction, updateStreamStatus, -} from "@/lib/firestore-particles"; -import { toast } from "sonner-native"; -import { toUserMessage } from "@/lib/errors"; -import { useNetwork } from "@/hooks/use-networks"; -import { useStreamPlayback } from "@/hooks/use-stream-playback"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +} from '@/lib/firestore-particles'; +import { toast } from 'sonner-native'; +import { toUserMessage } from '@/lib/errors'; +import { useNetwork } from '@/hooks/use-networks'; +import { useStreamPlayback } from '@/hooks/use-stream-playback'; +import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { selectIsComposing, selectIsPaused, usePlaybackPauseStore, -} from "@/stores/playback-pause-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { ComposeDock } from "@/features/compose/ComposeDock"; -import { ComposingIndicator } from "@/components/ComposingIndicator"; -import { PlaybackPageIndicator } from "./PlaybackPageIndicator"; -import { ReactionSheet } from "./ReactionSheet"; -import { StreamMetadataHeader } from "./StreamMetadataHeader"; -import { StreamSafeAreaProvider } from "./stream-safe-area"; +} from '@/stores/playback-pause-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { ComposeDock } from '@/features/compose/ComposeDock'; +import { ComposingIndicator } from '@/components/ComposingIndicator'; +import { PlaybackPageIndicator } from './PlaybackPageIndicator'; +import { ReactionSheet } from './ReactionSheet'; +import { StreamMetadataHeader } from './StreamMetadataHeader'; +import { StreamSafeAreaProvider } from './stream-safe-area'; import { StreamPresenceProvider, useStreamComposing, -} from "./stream-presence-context"; -import { TextParticleView } from "./TextParticleView"; -import { MediaParticleView } from "./MediaParticleView"; -import { DeletedParticleView } from "./DeletedParticleView"; -import { FallbackParticleView } from "./FallbackParticleView"; -import { useExitCountdown } from "./use-exit-countdown"; -import { StreamTopActions } from "./StreamTopActions"; -import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet"; -import { StreamMembersSheet } from "./StreamMembersSheet"; -import { RenameStreamSheet } from "./RenameStreamSheet"; -import { EditParticleSheet } from "./EditParticleSheet"; -import { ReactionStack } from "./ReactionStack"; +} from './stream-presence-context'; +import { TextParticleView } from './TextParticleView'; +import { MediaParticleView } from './MediaParticleView'; +import { DeletedParticleView } from './DeletedParticleView'; +import { FallbackParticleView } from './FallbackParticleView'; +import { useExitCountdown } from './use-exit-countdown'; +import { StreamTopActions } from './StreamTopActions'; +import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet'; +import { StreamMembersSheet } from './StreamMembersSheet'; +import { RenameStreamSheet } from './RenameStreamSheet'; +import { EditParticleSheet } from './EditParticleSheet'; +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 // asymmetric "Snapchat thumb-zone" so right-handed taps default to forward. const PREV_ZONE_RATIO = 0.28; @@ -80,7 +80,7 @@ const REACTIONS_VELOCITY = 600; const COMPOSE_DOCK_HEIGHT = 50; interface StreamViewProps { - streamParticle: Particle & { type: "stream" }; + streamParticle: Particle & { type: 'stream' }; path: ParticlePath; onExit: () => void; } @@ -118,19 +118,26 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const paused = usePlaybackPauseStore(selectIsPaused); const composing = usePlaybackPauseStore(selectIsComposing); 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 // setter inside a runOnJS callback dispatched from the worklet thread. const [holdActive, setHoldActive] = useState(false); - useSuspendPlayback(holdActive, "touch-hold"); + useSuspendPlayback(holdActive, 'touch-hold'); // Suspend playback whenever another screen (Huddle, NewStream, modals // routed as screens) is on top. Native stack keeps StreamView mounted, so // without this the stream would keep advancing — and the exit countdown // would fire — behind the huddle. const isFocused = useIsFocused(); - useSuspendPlayback(!isFocused, "screen-unfocused"); + useSuspendPlayback(!isFocused, 'screen-unfocused'); // Reaction sheet — opens via swipe-up on the canvas. const [reactionsOpen, setReactionsOpen] = useState(false); @@ -142,32 +149,32 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const [membersOpen, setMembersOpen] = useState(false); const [renameOpen, setRenameOpen] = 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 canDeleteCurrentParticle = !!currentParticle && !!userId && currentParticle.created_by_human_id === userId && - currentParticle.type !== "stream" && - currentParticle.type !== "folder" && + currentParticle.type !== 'stream' && + currentParticle.type !== 'folder' && !isParticleDeleted(currentParticle); const canEditCurrentParticle = !!currentParticle && !!userId && currentParticle.created_by_human_id === userId && - currentParticle.type === "text" && + currentParticle.type === 'text' && !isParticleDeleted(currentParticle); const editableTextParticle = - canEditCurrentParticle && currentParticle && currentParticle.type === "text" + canEditCurrentParticle && currentParticle && currentParticle.type === 'text' ? currentParticle : null; const showFitToggle = !!currentParticle && !isParticleDeleted(currentParticle) && - currentParticle.type === "media" && - !currentParticle.properties.mime_type.startsWith("audio/"); + currentParticle.type === 'media' && + !currentParticle.properties.mime_type.startsWith('audio/'); const handleStreamAction = useCallback( async (action: StreamActionId) => { @@ -175,38 +182,38 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { particlePath(networkId, [streamParticle.id]), ); switch (action) { - case "toggle-status": { + case 'toggle-status': { try { await updateStreamStatus( streamDocPath, - streamParticle.status === "open" ? "closed" : "open", + streamParticle.status === 'open' ? 'closed' : 'open', ); } catch (err) { toast.error(toUserMessage(err)); } return; } - case "rename": + case 'rename': setRenameOpen(true); return; - case "members": + case 'members': setMembersOpen(true); return; - case "edit-particle": + case 'edit-particle': if (!canEditCurrentParticle) return; setEditOpen(true); return; - case "delete-particle": { + case 'delete-particle': { if (!currentParticle || !userId) return; if (!canDeleteCurrentParticle) return; Alert.alert( - "Delete this particle?", - "This cannot be undone. Other viewers will see a \"deleted\" message in its place.", + 'Delete this particle?', + 'This cannot be undone. Other viewers will see a "deleted" message in its place.', [ - { text: "Cancel", style: "cancel" }, + { text: 'Cancel', style: 'cancel' }, { - text: "Delete", - style: "destructive", + text: 'Delete', + style: 'destructive', onPress: async () => { try { const docPath = toFirestoreDocPath( @@ -240,7 +247,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const reactionsOnCurrent = currentParticle && !isParticleDeleted(currentParticle) - ? currentParticle.type === "media" || currentParticle.type === "text" + ? currentParticle.type === 'media' || currentParticle.type === 'text' ? currentParticle.reactions : undefined : undefined; @@ -252,12 +259,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const docPath = toFirestoreDocPath( particlePath(networkId, [streamParticle.id, currentParticle.id]), ); - void toggleParticleReaction( - docPath, - key, - userId, - reactionsOnCurrent, - ); + void toggleParticleReaction(docPath, key, userId, reactionsOnCurrent); }, [userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent], ); @@ -280,11 +282,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { [children.length, currentIndex, goToParticle], ); - // Reset progress whenever the active particle changes. - useEffect(() => { - setProgress(0); - }, [currentParticle?.id]); - const handleTap = useCallback( (xRatio: number) => { if (xRatio < PREV_ZONE_RATIO) { @@ -303,7 +300,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { // --- Swipe-down dismiss --- const translateY = useSharedValue(0); - const screenWidth = Dimensions.get("window").width; + const screenWidth = Dimensions.get('window').width; const exit = useCallback(() => { onExit(); @@ -314,15 +311,12 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { .failOffsetX([-30, 30]) .failOffsetY(-20) .onUpdate((e) => { - "worklet"; + 'worklet'; translateY.value = Math.max(0, e.translationY); }) .onEnd((e) => { - "worklet"; - if ( - e.translationY > DISMISS_DISTANCE || - e.velocityY > DISMISS_VELOCITY - ) { + 'worklet'; + if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) { translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 }); runOnJS(exit)(); } else { @@ -341,7 +335,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { .failOffsetX([-30, 30]) .failOffsetY(20) .onEnd((e) => { - "worklet"; + 'worklet'; if ( e.translationY < -REACTIONS_DISTANCE || e.velocityY < -REACTIONS_VELOCITY @@ -355,7 +349,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { .maxDuration(180) .maxDistance(15) .onEnd((e, success) => { - "worklet"; + 'worklet'; if (!success) return; const ratio = e.x / screenWidth; runOnJS(handleTap)(ratio); @@ -366,15 +360,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { .minDuration(180) .maxDistance(15) .onStart(() => { - "worklet"; + 'worklet'; runOnJS(setHoldActive)(true); }) .onTouchesUp(() => { - "worklet"; + 'worklet'; runOnJS(setHoldActive)(false); }) .onFinalize(() => { - "worklet"; + 'worklet'; runOnJS(setHoldActive)(false); }); @@ -441,7 +435,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { ); } switch (particle.type) { - case "text": + case 'text': return ( ); - case "media": + case 'media': return ( - setVideoFit((v) => (v === "cover" ? "contain" : "cover")) + setVideoFit((v) => (v === 'cover' ? 'contain' : 'cover')) } onOpenMembers={() => setMembersOpen(true)} onOpenActions={() => setActionsOpen(true)} @@ -637,15 +631,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { {currentParticle && !composing && !isParticleDeleted(currentParticle) && - (currentParticle.type === "media" || - currentParticle.type === "text") ? ( + (currentParticle.type === 'media' || + currentParticle.type === 'text') ? ( + {/* Compose dock + recording overlays. Sits above the GestureDetector 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} onClose={() => setActionsOpen(false)} onSelect={(action) => void handleStreamAction(action)} - streamStatus={streamParticle.status ?? "open"} + streamStatus={streamParticle.status ?? 'open'} isCreator={isCreator} canEditParticle={canEditCurrentParticle} canDeleteParticle={canDeleteCurrentParticle} diff --git a/js/mobile/src/features/stream-view/StreamViewScreen.tsx b/js/mobile/src/features/stream-view/StreamViewScreen.tsx index e400596..16e7ace 100644 --- a/js/mobile/src/features/stream-view/StreamViewScreen.tsx +++ b/js/mobile/src/features/stream-view/StreamViewScreen.tsx @@ -1,14 +1,14 @@ -import { ActivityIndicator, Pressable, Text, View } from "react-native"; -import { StatusBar } from "expo-status-bar"; -import type { RootStackScreenProps } from "@/navigation/types"; -import { particlePath } from "@/lib/particle-path"; -import { useLiveParticle } from "@/hooks/use-particle"; -import { StreamView } from "./StreamView"; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { particlePath } from '@/lib/particle-path'; +import { useLiveParticle } from '@/hooks/use-particle'; +import { StreamView } from './StreamView'; export function StreamViewScreen({ navigation, route, -}: RootStackScreenProps<"StreamView">) { +}: RootStackScreenProps<'StreamView'>) { const { networkId, streamId } = route.params; const streamPath = particlePath(networkId, [streamId]); 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 ( diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx index 8a528dc..6e4e026 100644 --- a/js/mobile/src/features/stream-view/TextParticleView.tsx +++ b/js/mobile/src/features/stream-view/TextParticleView.tsx @@ -1,12 +1,12 @@ -import { useEffect, useRef, type ReactNode } from "react"; -import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native"; -import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked"; -import type { Particle } from "@/api/types"; -import { cn } from "@/lib/utils"; -import { RelativeTimestamp } from "@/components/RelativeTimestamp"; -import { useStreamSafeArea } from "./stream-safe-area"; +import { useEffect, useRef, type ReactNode } from 'react'; +import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native'; +import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked'; +import type { Particle } from '@/api/types'; +import { cn } from '@/lib/utils'; +import { RelativeTimestamp } from '@/components/RelativeTimestamp'; +import { useStreamSafeArea } from './stream-safe-area'; -type TextParticle = Extract; +type TextParticle = Extract; interface TextParticleViewProps { particle: TextParticle; @@ -31,11 +31,9 @@ function computeReadDuration(text: string): number { } function getImmersiveStyle(length: number) { - if (length < 30) - return { className: "text-5xl font-semibold leading-tight" }; - if (length < 70) - return { className: "text-3xl font-semibold leading-snug" }; - return { className: "text-2xl font-normal leading-snug" }; + if (length < 30) return { className: 'text-5xl font-semibold leading-tight' }; + if (length < 70) 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 @@ -59,7 +57,7 @@ function withTaskCheckboxes(markdown: string): string { return markdown.replace( TASK_ITEM_RE, (_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 // uses CodeMirror; react-native-marked only exposes the language tag). They // render as plain monospace on the dark surface, which is acceptable for v1. -const TEXT_COLOR = "rgba(255,255,255,0.92)"; -const ACCENT = "#60a5fa"; -const SURFACE = "rgba(24,24,28,0.96)"; -const OUTLINE = "rgba(255,255,255,0.2)"; -const MONO = Platform.OS === "ios" ? "Menlo" : "monospace"; +const TEXT_COLOR = 'rgba(255,255,255,0.92)'; +const ACCENT = '#60a5fa'; +const SURFACE = 'rgba(24,24,28,0.96)'; +const OUTLINE = 'rgba(255,255,255,0.2)'; +const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace'; const MARKDOWN_THEME = { colors: { @@ -90,26 +88,88 @@ const MARKDOWN_THEME = { const MARKDOWN_STYLES: MarkedStyles = { text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 }, - strong: { fontWeight: "700" }, - em: { fontStyle: "italic" }, + strong: { fontWeight: '700' }, + em: { fontStyle: 'italic' }, strikethrough: { - textDecorationLine: "line-through", - color: "rgba(255,255,255,0.6)", + textDecorationLine: 'line-through', + color: 'rgba(255,255,255,0.6)', }, // fontStyle "normal" cancels react-native-marked's italic-by-default for // links and inline code (desktop renders neither italic). - link: { color: ACCENT, fontStyle: "normal" }, + link: { color: ACCENT, fontStyle: 'normal' }, // borderBottomWidth 0 removes the library's default heading underline rule, // which desktop's headings don't have. - h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 }, - h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 }, - h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 }, - h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 }, - h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 }, - h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 }, - codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" }, - code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 }, - blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 }, + h1: { + color: '#ffffff', + fontSize: 28, + lineHeight: 34, + fontWeight: '700', + marginTop: 8, + marginBottom: 8, + borderBottomWidth: 0, + }, + h2: { + color: '#ffffff', + fontSize: 24, + lineHeight: 30, + fontWeight: '700', + marginTop: 8, + marginBottom: 6, + borderBottomWidth: 0, + }, + h3: { + color: '#ffffff', + fontSize: 20, + lineHeight: 26, + fontWeight: '600', + marginTop: 6, + marginBottom: 4, + }, + h4: { + color: '#ffffff', + fontSize: 18, + lineHeight: 24, + fontWeight: '600', + marginTop: 6, + marginBottom: 4, + }, + h5: { + color: '#ffffff', + fontSize: 16, + lineHeight: 22, + fontWeight: '600', + marginTop: 4, + marginBottom: 2, + }, + h6: { + color: 'rgba(255,255,255,0.7)', + fontSize: 15, + lineHeight: 20, + fontWeight: '600', + marginTop: 4, + marginBottom: 2, + }, + codespan: { + color: '#fca5a5', + fontFamily: MONO, + fontStyle: 'normal', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + code: { + backgroundColor: SURFACE, + borderColor: OUTLINE, + borderWidth: 1, + borderRadius: 8, + padding: 12, + marginVertical: 6, + }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: OUTLINE, + paddingLeft: 12, + marginVertical: 6, + opacity: 0.85, + }, // hr is left to the library default, which already draws a 1px rule in the // themed border color (OUTLINE). table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 }, @@ -187,7 +247,10 @@ export function TextParticleView({ // Immersive (short, plain): centered, large type — feels like a lock-screen // note. Short messages that contain markdown fall through to the rendered // 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); return ( {content} diff --git a/js/mobile/src/features/stream-view/TranscriptOverlay.tsx b/js/mobile/src/features/stream-view/TranscriptOverlay.tsx index c20aedd..0aea921 100644 --- a/js/mobile/src/features/stream-view/TranscriptOverlay.tsx +++ b/js/mobile/src/features/stream-view/TranscriptOverlay.tsx @@ -1,9 +1,9 @@ -import { useMemo, useRef } from "react"; -import { Text, View } from "react-native"; -import type { Transcript } from "@/api/types"; +import { useMemo, useState } from 'react'; +import { Text, View } from 'react-native'; +import type { Transcript } from '@/api/types'; -type Sentence = Transcript["paragraphs"][number]["sentences"][number]; -type Word = Transcript["words"][number]; +type Sentence = Transcript['paragraphs'][number]['sentences'][number]; +type Word = Transcript['words'][number]; const CHUNK_SIZE = 9; @@ -41,35 +41,40 @@ export function TranscriptOverlay({ const activeWord = activeWordIndex !== null ? transcript.words[activeWordIndex] : null; - const lastSpokenWordRef = useRef(null); - if (activeWord) { - lastSpokenWordRef.current = activeWord; + // Remember the last spoken word so highlights hold during pauses. + const [lastSpokenWord, setLastSpokenWord] = useState(null); + if (activeWord && activeWord !== lastSpokenWord) { + setLastSpokenWord(activeWord); } - const highlightWord = activeWord ?? lastSpokenWordRef.current; + const highlightWord = activeWord ?? lastSpokenWord; - const lastChunkRef = useRef(null); - - const activeChunk = useMemo(() => { - if (activeWord) { - for (const chunk of chunks) { - if ( - chunk.some( - (w) => w.start === activeWord.start && w.end === activeWord.end, - ) - ) { - 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; + // The chunk currently being spoken (null during a pause or if not found). + const spokenChunk = useMemo(() => { + if (!activeWord) return null; + return ( + chunks.find((chunk) => + chunk.some( + (w) => w.start === activeWord.start && w.end === activeWord.end, + ), + ) ?? null + ); }, [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(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; return ( @@ -87,10 +92,10 @@ export function TranscriptOverlay({ - {i > 0 ? " " : ""} + {i > 0 ? ' ' : ''} {word.word} ); diff --git a/js/mobile/src/features/stream-view/stream-presence-context.tsx b/js/mobile/src/features/stream-view/stream-presence-context.tsx index 9b8e1df..1fc78c3 100644 --- a/js/mobile/src/features/stream-view/stream-presence-context.tsx +++ b/js/mobile/src/features/stream-view/stream-presence-context.tsx @@ -7,11 +7,11 @@ import { useRef, useState, type ReactNode, -} from "react"; -import { useChannel } from "@/hooks/use-channel"; -import { useAuthStore } from "@/stores/auth-store"; +} from 'react'; +import { useChannel } from '@/hooks/use-channel'; +import { useAuthStore } from '@/stores/auth-store'; -export type ComposingMode = "recording" | "typing" | "screen"; +export type ComposingMode = 'recording' | 'typing' | 'screen'; export interface ComposingUser { humanId: string; @@ -74,14 +74,14 @@ export function StreamPresenceProvider({ if (!payload?.type) continue; if (msg.humanId === currentUserId) continue; - if (payload.type === "composing_start" && payload.mode) { + if (payload.type === 'composing_start' && payload.mode) { map.set(msg.humanId, { humanId: msg.humanId, mode: payload.mode as ComposingMode, lastSeen: Date.now(), }); changed = true; - } else if (payload.type === "composing_stop") { + } else if (payload.type === 'composing_stop') { if (map.delete(msg.humanId)) changed = true; } } @@ -139,10 +139,10 @@ export function StreamPresenceProvider({ const startComposing = useCallback( (mode: ComposingMode) => { - sendMessage({ type: "composing_start", mode }); + sendMessage({ type: 'composing_start', mode }); if (heartbeatRef.current) clearInterval(heartbeatRef.current); heartbeatRef.current = setInterval(() => { - sendMessage({ type: "composing_start", mode }); + sendMessage({ type: 'composing_start', mode }); }, COMPOSING_HEARTBEAT_MS); }, [sendMessage], @@ -151,7 +151,7 @@ export function StreamPresenceProvider({ const stopComposing = useCallback(() => { if (heartbeatRef.current) clearInterval(heartbeatRef.current); heartbeatRef.current = undefined; - sendMessage({ type: "composing_stop" }); + sendMessage({ type: 'composing_stop' }); }, [sendMessage]); useEffect(() => { @@ -181,7 +181,7 @@ function useStreamPresenceContext() { const ctx = useContext(StreamPresenceContext); if (!ctx) { throw new Error( - "useStreamPresence must be used within a StreamPresenceProvider", + 'useStreamPresence must be used within a StreamPresenceProvider', ); } return ctx; @@ -201,3 +201,17 @@ export function useStreamComposingBroadcast() { const { startComposing, stopComposing } = useStreamPresenceContext(); 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, + }; +} diff --git a/js/mobile/src/features/stream-view/stream-safe-area.tsx b/js/mobile/src/features/stream-view/stream-safe-area.tsx index 6e27287..43e4fbe 100644 --- a/js/mobile/src/features/stream-view/stream-safe-area.tsx +++ b/js/mobile/src/features/stream-view/stream-safe-area.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, type ReactNode } from 'react'; interface StreamSafeArea { /** Pixels from the screen top reserved for the segmented bar + metadata. */ diff --git a/js/mobile/src/features/stream-view/use-exit-countdown.ts b/js/mobile/src/features/stream-view/use-exit-countdown.ts index 2d60735..375b98b 100644 --- a/js/mobile/src/features/stream-view/use-exit-countdown.ts +++ b/js/mobile/src/features/stream-view/use-exit-countdown.ts @@ -1,10 +1,10 @@ -import { useEffect, useState } from "react"; -import { useEvent } from "@/hooks/use-event"; +import { useEffect, useState } from 'react'; +import { useEvent } from '@/hooks/use-event'; export const EXIT_DELAY_MS = 5000; 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. @@ -16,18 +16,19 @@ export function useExitCountdown( onExit: () => void, ): number | null { const [remainingMs, setRemainingMs] = useState(null); + const [prevStatus, setPrevStatus] = useState(status); const handleExit = useEvent(onExit); - useEffect(() => { - if (status === "ended") { - setRemainingMs(EXIT_DELAY_MS); - } else { - setRemainingMs(null); - } - }, [status]); + // Start the countdown when playback ends; clear it on any other transition. + if (status !== prevStatus) { + setPrevStatus(status); + setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null); + } + + const isCounting = remainingMs !== null && remainingMs > 0; useEffect(() => { - if (remainingMs === null || remainingMs <= 0 || paused) return; + if (!isCounting || paused) return; const interval = setInterval(() => { setRemainingMs((prev) => { if (prev === null) return null; @@ -36,7 +37,7 @@ export function useExitCountdown( }); }, EXIT_TICK_MS); return () => clearInterval(interval); - }, [remainingMs !== null && remainingMs > 0, paused, remainingMs]); + }, [isCounting, paused]); useEffect(() => { if (remainingMs !== null && remainingMs <= 0) { diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx index 2be3492..9ca70f8 100644 --- a/js/mobile/src/features/streams/NewStreamScreen.tsx +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState } from 'react'; import { KeyboardAvoidingView, Platform, @@ -6,24 +6,24 @@ import { Text, TextInput, View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { StatusBar } from "expo-status-bar"; -import { ChevronRight, Globe, Lock, X } from "lucide-react-native"; -import { toast } from "sonner-native"; -import { ComposeDock } from "@/features/compose/ComposeDock"; -import { useNetwork } from "@/hooks/use-networks"; -import { particlePath } from "@/lib/particle-path"; -import { generateRandomName } from "@/lib/random-name"; -import { createStreamWithFirstParticle } from "@/lib/upload"; -import { toUserMessage } from "@/lib/errors"; +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { StatusBar } from 'expo-status-bar'; +import { ChevronRight, Globe, Lock, X } from 'lucide-react-native'; +import { toast } from 'sonner-native'; +import { ComposeDock } from '@/features/compose/ComposeDock'; +import { useNetwork } from '@/hooks/use-networks'; +import { particlePath } from '@/lib/particle-path'; +import { generateRandomName } from '@/lib/random-name'; +import { createStreamWithFirstParticle } from '@/lib/upload'; +import { toUserMessage } from '@/lib/errors'; import { buildNetworkVisibility, parseVisibleTo, -} from "@/lib/stream-visibility"; -import { useAuthStore } from "@/stores/auth-store"; -import type { RootStackScreenProps } from "@/navigation/types"; -import { VisibilityPickerSheet } from "./VisibilityPickerSheet"; +} from '@/lib/stream-visibility'; +import { useAuthStore } from '@/stores/auth-store'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { VisibilityPickerSheet } from './VisibilityPickerSheet'; const STREAM_NAME_MAX = 60; @@ -35,13 +35,13 @@ const STREAM_NAME_MAX = 60; export function NewStreamScreen({ route, navigation, -}: RootStackScreenProps<"NewStream">) { +}: RootStackScreenProps<'NewStream'>) { const { networkId } = route.params; const network = useNetwork(networkId); const userId = useAuthStore((s) => s.user?.id); const suggestion = useMemo(() => generateRandomName(), []); - const [name, setName] = useState(""); + const [name, setName] = useState(''); const [visibleTo, setVisibleTo] = useState(() => buildNetworkVisibility(networkId), ); @@ -50,18 +50,18 @@ export function NewStreamScreen({ const effectiveName = name.trim() || suggestion; const handleStreamCreated = (streamId: string) => { - navigation.replace("StreamView", { networkId, streamId }); + navigation.replace('StreamView', { networkId, streamId }); }; const submitText = async (content: string) => { - if (!userId) throw new Error("Not signed in."); + if (!userId) throw new Error('Not signed in.'); try { const { streamId } = await createStreamWithFirstParticle({ networkId, name: effectiveName, visibleTo, createdByHumanId: userId, - firstParticle: { type: "text", content }, + firstParticle: { type: 'text', content }, }); handleStreamCreated(streamId); } catch (err) { @@ -79,9 +79,9 @@ export function NewStreamScreen({ fileUri: string; mimeType: string; 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 { const { streamId } = await createStreamWithFirstParticle({ networkId, @@ -89,7 +89,7 @@ export function NewStreamScreen({ visibleTo, createdByHumanId: userId, firstParticle: { - type: "media", + type: 'media', fileUri, mimeType, durationMs, @@ -107,17 +107,17 @@ export function NewStreamScreen({ const visibility = parseVisibleTo(visibleTo, networkId); const visibleSummary = - visibility.mode === "network" - ? `Everyone in ${network?.name ?? "this network"}` + visibility.mode === 'network' + ? `Everyone in ${network?.name ?? 'this network'}` : `${visibility.humanIds.length} ${ - visibility.humanIds.length === 1 ? "person" : "people" + visibility.humanIds.length === 1 ? 'person' : 'people' }`; return ( - + navigation.goBack()} @@ -126,15 +126,13 @@ export function NewStreamScreen({ > - - New stream - + New stream @@ -159,8 +157,12 @@ export function NewStreamScreen({ onPress={() => setPickerOpen(true)} 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' ? ( + ) : ( )} @@ -176,7 +178,7 @@ export function NewStreamScreen({ - Hold the button below to record a voice or video message — that's + Hold the button below to record a voice or video message — that’s the first particle in your new stream. diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index 896e7d2..ce5d5cf 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -1,17 +1,17 @@ -import { memo, useMemo } from "react"; -import { Pressable, Text, View } from "react-native"; -import { Headphones } from "lucide-react-native"; -import type { Particle, StreamProperties } from "@/api/types"; -import { isParticleDeleted } from "@/api/types"; -import { RelativeTimestamp } from "@/components/RelativeTimestamp"; -import { useLiveLatestChild } from "@/hooks/use-particle"; -import { useNetwork } from "@/hooks/use-networks"; -import { particlePath } from "@/lib/particle-path"; -import { cn, getInitials } from "@/lib/utils"; -import { useAuthStore } from "@/stores/auth-store"; +import { memo, useMemo } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Headphones } from 'lucide-react-native'; +import type { Particle, StreamProperties } from '@/api/types'; +import { isParticleDeleted } from '@/api/types'; +import { RelativeTimestamp } from '@/components/RelativeTimestamp'; +import { useLiveLatestChild } from '@/hooks/use-particle'; +import { useNetwork } from '@/hooks/use-networks'; +import { particlePath } from '@/lib/particle-path'; +import { cn, getInitials } from '@/lib/utils'; +import { useAuthStore } from '@/stores/auth-store'; interface StreamCardProps { - particle: Particle & { type: "stream"; properties: StreamProperties }; + particle: Particle & { type: 'stream'; properties: StreamProperties }; networkId: string; onPress: () => void; } @@ -28,12 +28,12 @@ export const StreamCard = memo(function StreamCard({ }: StreamCardProps) { const streamPath = particlePath(networkId, [particle.id]); const { latestChild } = useLiveLatestChild(streamPath); - const userId = useAuthStore((s) => s.user?.id) ?? ""; + const userId = useAuthStore((s) => s.user?.id) ?? ''; const network = useNetwork(networkId); const isDM = particle.visible_to.length === 2 && - particle.visible_to.every((v) => v.startsWith("human:")); + particle.visible_to.every((v) => v.startsWith('human:')); const initials = useMemo(() => { if (isDM) { @@ -41,7 +41,7 @@ export const StreamCard = memo(function StreamCard({ (v) => v !== `human:${userId}`, ); if (otherEntry) { - const otherId = otherEntry.replace("human:", ""); + const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); if (otherHuman) return getInitials(otherHuman.email); } @@ -73,45 +73,45 @@ export const StreamCard = memo(function StreamCard({ }, [latestChild, particle.playback_markers, userId]); const previewLabel = useMemo(() => { - if (!latestChild) return "No messages yet"; - if (isParticleDeleted(latestChild)) return "Message deleted"; + if (!latestChild) return 'No messages yet'; + if (isParticleDeleted(latestChild)) return 'Message deleted'; switch (latestChild.type) { - case "media": { + case 'media': { const mime = latestChild.properties.mime_type; - if (mime.startsWith("image/")) return "Photo"; + if (mime.startsWith('image/')) return 'Photo'; const transcriptText = latestChild.properties.transcript?.transcript; 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; - case "file": + case 'file': return latestChild.properties.filename; - case "quest": + case 'quest': return latestChild.properties.title; - case "paper": + case 'paper': return latestChild.properties.title; default: - return "Update"; + return 'Update'; } }, [latestChild]); return ( {initials} @@ -122,10 +122,10 @@ export const StreamCard = memo(function StreamCard({ {particle.properties.name} @@ -143,8 +143,8 @@ export const StreamCard = memo(function StreamCard({ ) : null} diff --git a/js/mobile/src/features/streams/StreamListScreen.tsx b/js/mobile/src/features/streams/StreamListScreen.tsx index 4043059..1a8a3ad 100644 --- a/js/mobile/src/features/streams/StreamListScreen.tsx +++ b/js/mobile/src/features/streams/StreamListScreen.tsx @@ -4,33 +4,33 @@ import { Pressable, Text, View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { ListSeparator } from "@/components/ListSeparator"; -import { toUserMessage } from "@/lib/errors"; -import { particlePath } from "@/lib/particle-path"; -import { useNetwork } from "@/hooks/use-networks"; -import { useStreamParticles } from "@/hooks/use-stream-particles"; -import type { RootStackScreenProps } from "@/navigation/types"; -import { StreamCard } from "./StreamCard"; +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ListSeparator } from '@/components/ListSeparator'; +import { toUserMessage } from '@/lib/errors'; +import { particlePath } from '@/lib/particle-path'; +import { useNetwork } from '@/hooks/use-networks'; +import { useStreamParticles } from '@/hooks/use-stream-particles'; +import type { RootStackScreenProps } from '@/navigation/types'; +import { StreamCard } from './StreamCard'; export function StreamListScreen({ route, navigation, -}: RootStackScreenProps<"StreamList">) { +}: RootStackScreenProps<'StreamList'>) { const { networkId } = route.params; const network = useNetwork(networkId); const path = particlePath(networkId, []); const { streams, isLoading, error } = useStreamParticles(path, { - status: "open", + status: 'open', }); return ( - +
navigation.goBack()} /> @@ -50,7 +50,7 @@ export function StreamListScreen({ particle={item} networkId={networkId} onPress={() => - navigation.navigate("StreamView", { + navigation.navigate('StreamView', { networkId, streamId: item.id, }) @@ -61,19 +61,13 @@ export function StreamListScreen({ )} navigation.navigate("NewStream", { networkId })} + onPress={() => navigation.navigate('NewStream', { networkId })} /> ); } -function Header({ - title, - onBack, -}: { - title: string; - onBack: () => void; -}) { +function Header({ title, onBack }: { title: string; onBack: () => void }) { return ( (initial.mode); + const [mode, setMode] = useState<'network' | 'custom'>(initial.mode); const [selected, setSelected] = useState>( - () => new Set(initial.mode === "custom" ? initial.humanIds : []), + () => new Set(initial.mode === 'custom' ? initial.humanIds : []), ); - useEffect(() => { - if (!open) return; - setMode(initial.mode); - setSelected( - new Set(initial.mode === "custom" ? initial.humanIds : []), - ); - }, [open, initial]); + // Re-seed from the committed value each time the sheet opens fresh. + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setMode(initial.mode); + setSelected(new Set(initial.mode === 'custom' ? initial.humanIds : [])); + } + } const others = humans.filter((h) => h.id !== selfHumanId); @@ -68,7 +70,7 @@ export function VisibilityPickerSheet({ }; const commit = () => { - if (mode === "network") { + if (mode === 'network') { onChange(buildNetworkVisibility(networkId)); } else { const ids = selfHumanId @@ -80,7 +82,7 @@ export function VisibilityPickerSheet({ }; const customCount = selected.size + (selfHumanId ? 1 : 0); - const canCommit = mode === "network" || customCount >= 2; + const canCommit = mode === 'network' || customCount >= 2; return ( @@ -92,8 +94,8 @@ export function VisibilityPickerSheet({ Done @@ -104,31 +106,31 @@ export function VisibilityPickerSheet({ } label="Everyone" - onPress={() => setMode("network")} + onPress={() => setMode('network')} /> } label="Specific people" - onPress={() => setMode("custom")} + onPress={() => setMode('custom')} /> - {mode === "network" ? ( + {mode === 'network' ? ( - Everyone in {networkName ?? "this network"} can see this stream. + Everyone in {networkName ?? 'this network'} can see this stream. ) : ( {others.length === 0 ? ( - You're the only member of this network. Invite people on desktop, + You’re the only member of this network. Invite people on desktop, then come back to choose specific viewers. ) : ( @@ -140,15 +142,11 @@ export function VisibilityPickerSheet({ key={human.id} onPress={() => toggle(human.id)} className={cn( - "flex-row items-center gap-3 px-3 py-2.5 rounded-lg", - isSelected ? "bg-white/10" : "active:bg-white/5", + 'flex-row items-center gap-3 px-3 py-2.5 rounded-lg', + isSelected ? 'bg-white/10' : 'active:bg-white/5', )} > - + {display.displayName} - + {display.email} {isSelected ? ( @@ -200,15 +193,15 @@ function ModePill({ {icon} {label} diff --git a/js/mobile/src/firebase.ts b/js/mobile/src/firebase.ts index a5bdba1..e233c62 100644 --- a/js/mobile/src/firebase.ts +++ b/js/mobile/src/firebase.ts @@ -1,4 +1,4 @@ -import { initializeApp } from "firebase/app"; +import { initializeApp } from 'firebase/app'; import { initializeAuth, // `getReactNativePersistence` is documented Firebase RN setup but Firebase @@ -7,10 +7,10 @@ import { // platform; this is the workaround the Firebase docs themselves use. // @ts-expect-error — RN-only symbol missing from public Firebase types. getReactNativePersistence, -} from "firebase/auth"; -import { initializeFirestore } from "firebase/firestore"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { appConfig } from "@/config/env"; +} from 'firebase/auth'; +import { initializeFirestore } from 'firebase/firestore'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { appConfig } from '@/config/env'; export const firebaseApp = initializeApp(appConfig.firebase); diff --git a/js/mobile/src/hooks/use-channel.ts b/js/mobile/src/hooks/use-channel.ts index 3cbc608..f78bc78 100644 --- a/js/mobile/src/hooks/use-channel.ts +++ b/js/mobile/src/hooks/use-channel.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; -import { usePusherClient } from "@/lib/pusher-provider"; -import type { ChannelMessage } from "@/lib/pusher-client"; +import { useCallback, useEffect, useState } from 'react'; +import { usePusherClient } from '@/lib/pusher-provider'; +import type { ChannelMessage } from '@/lib/pusher-client'; interface UseChannelResult { /** Current set of humanIds present in the channel */ @@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult { const [messages, setMessages] = useState([]); useEffect(() => { - if (!client || !channelId) { - setPresence([]); - setMessages([]); - return; - } + if (!client || !channelId) return; client.subscribe(channelId); @@ -58,17 +54,20 @@ export function useChannel(channelId: string | null): UseChannelResult { } }; - client.on(channelId, "subscribed", onSubscribed); - client.on(channelId, "join", onJoin); - client.on(channelId, "leave", onLeave); - client.on(channelId, "message", onMessage); + client.on(channelId, 'subscribed', onSubscribed); + client.on(channelId, 'join', onJoin); + client.on(channelId, 'leave', onLeave); + client.on(channelId, 'message', onMessage); return () => { - client.off(channelId, "subscribed", onSubscribed); - client.off(channelId, "join", onJoin); - client.off(channelId, "leave", onLeave); - client.off(channelId, "message", onMessage); + client.off(channelId, 'subscribed', onSubscribed); + client.off(channelId, 'join', onJoin); + client.off(channelId, 'leave', onLeave); + client.off(channelId, 'message', onMessage); client.unsubscribe(channelId); + // Clear on teardown so a new channel doesn't briefly show stale data. + setPresence([]); + setMessages([]); }; }, [client, channelId]); diff --git a/js/mobile/src/hooks/use-event.ts b/js/mobile/src/hooks/use-event.ts index a3ae51f..9dda518 100644 --- a/js/mobile/src/hooks/use-event.ts +++ b/js/mobile/src/hooks/use-event.ts @@ -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 // stable identity but always sees the latest closure — exactly what diff --git a/js/mobile/src/hooks/use-networks.ts b/js/mobile/src/hooks/use-networks.ts index 1ea1f38..790fc3a 100644 --- a/js/mobile/src/hooks/use-networks.ts +++ b/js/mobile/src/hooks/use-networks.ts @@ -1,10 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiClient } from "@/api/client"; -import { useAuthStore } from "@/stores/auth-store"; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import { useAuthStore } from '@/stores/auth-store'; export function useNetworks() { return useQuery({ - queryKey: ["networks"], + queryKey: ['networks'], queryFn: () => apiClient.listNetworks(), meta: { toastOnError: true }, }); diff --git a/js/mobile/src/hooks/use-particle.ts b/js/mobile/src/hooks/use-particle.ts index f66c91d..0428e7b 100644 --- a/js/mobile/src/hooks/use-particle.ts +++ b/js/mobile/src/hooks/use-particle.ts @@ -1,20 +1,20 @@ -import { useState, useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { QueryFieldFilterConstraint } from "firebase/firestore"; +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { QueryFieldFilterConstraint } from 'firebase/firestore'; import { subscribeToParticle, subscribeToParticleChildren, subscribeToLatestChild, getParticle, getParticleChildren, -} from "@/lib/firestore-particles"; -import type { Particle } from "@/api/types"; +} from '@/lib/firestore-particles'; +import type { Particle } from '@/api/types'; import { type ParticlePath, toFirestoreDocPath, toFirestoreChildrenPath, -} from "@/lib/particle-path"; -import { logError } from "@/lib/errors"; +} from '@/lib/particle-path'; +import { logError } from '@/lib/errors'; interface UseLiveParticleResult { particle: Particle | null; @@ -28,10 +28,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult { const [error, setError] = useState(null); useEffect(() => { - setIsLoading(true); - setError(null); - setParticle(null); - const docPath = toFirestoreDocPath(path); const unsubscribe = subscribeToParticle( 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]); return { particle, isLoading, error }; @@ -59,7 +61,7 @@ interface UseLiveParticleChildrenResult { interface UseLiveParticleChildrenParams { orderByField?: string; - orderDirection?: "asc" | "desc"; + orderDirection?: 'asc' | 'desc'; visibilityScopes?: string[]; onAdded?: (child: Particle) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; @@ -71,8 +73,8 @@ interface UseLiveParticleChildrenParams { export function useLiveParticleChildren( path: ParticlePath | undefined, { - orderByField = "created_at", - orderDirection = "desc", + orderByField = 'created_at', + orderDirection = 'desc', visibilityScopes, onAdded, onRemoved, @@ -85,15 +87,7 @@ export function useLiveParticleChildren( const [error, setError] = useState(null); useEffect(() => { - if (!path) { - setChildren([]); - setIsLoading(false); - return; - } - - setIsLoading(true); - setError(null); - setChildren([]); + if (!path) return; const collectionPath = toFirestoreChildrenPath(path); @@ -103,7 +97,7 @@ export function useLiveParticleChildren( setIsLoading(false); }, onError: (err) => { - logError(err, { scope: "firestore.particle-children", path }); + logError(err, { scope: 'firestore.particle-children', path }); setError(err); setIsLoading(false); }, @@ -116,13 +110,24 @@ export function useLiveParticleChildren( 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 // does the same. Visibility scope changes are absorbed by the active // listener; reordering causes a re-subscription. // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 }; } @@ -138,9 +143,6 @@ export function useLiveLatestChild( const [isLoading, setIsLoading] = useState(true); useEffect(() => { - setIsLoading(true); - setLatestChild(null); - const unsubscribe = subscribeToLatestChild( toFirestoreChildrenPath(path), (data) => { @@ -148,12 +150,17 @@ export function useLiveLatestChild( setIsLoading(false); }, (err) => { - logError(err, { scope: "firestore.latest-child", path }); + logError(err, { scope: 'firestore.latest-child', path }); setIsLoading(false); }, ); - return unsubscribe; + return () => { + unsubscribe(); + // Reset on teardown so a new path doesn't flash the previous child. + setIsLoading(true); + setLatestChild(null); + }; }, [path]); return { latestChild, isLoading }; @@ -161,7 +168,7 @@ export function useLiveLatestChild( export function useParticle(path?: ParticlePath) { return useQuery({ - queryKey: ["particle", path], + queryKey: ['particle', path], queryFn: async () => { if (!path) return null; const docPath = toFirestoreDocPath(path); @@ -174,7 +181,7 @@ export function useParticle(path?: ParticlePath) { export function useParticleChildren(path?: ParticlePath) { return useQuery({ - queryKey: ["particle-children", path], + queryKey: ['particle-children', path], queryFn: async () => { if (!path) return []; const collectionPath = toFirestoreChildrenPath(path); diff --git a/js/mobile/src/hooks/use-stream-particles.ts b/js/mobile/src/hooks/use-stream-particles.ts index 7085197..3777a91 100644 --- a/js/mobile/src/hooks/use-stream-particles.ts +++ b/js/mobile/src/hooks/use-stream-particles.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { where, type QueryFieldFilterConstraint } from "firebase/firestore"; -import { useLiveParticleChildren } from "@/hooks/use-particle"; -import { useAuthStore } from "@/stores/auth-store"; -import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; -import type { Particle, StreamProperties } from "@/api/types"; +import { useCallback, useMemo, useState } from 'react'; +import { where, type QueryFieldFilterConstraint } from 'firebase/firestore'; +import { useLiveParticleChildren } from '@/hooks/use-particle'; +import { useAuthStore } from '@/stores/auth-store'; +import { parseParticlePath, type ParticlePath } from '@/lib/particle-path'; +import type { Particle, StreamProperties } from '@/api/types'; export type StreamParticle = Particle & { - type: "stream"; + type: 'stream'; properties: StreamProperties; }; @@ -15,8 +15,8 @@ const CLOSED_PAGE_INCREMENT = 50; // Stable where-constraint references so the Firestore subscription only // re-attaches when the tab actually changes, not on every render. -const OPEN_STATUS_FILTER = where("status", "==", "open"); -const CLOSED_STATUS_FILTER = where("status", "==", "closed"); +const OPEN_STATUS_FILTER = where('status', '==', 'open'); +const CLOSED_STATUS_FILTER = where('status', '==', 'closed'); function useVisibilityScopes(userId?: string, networkId?: string) { return useMemo(() => { @@ -33,7 +33,7 @@ interface UseStreamParticlesOptions { * by active work — full realtime coverage is needed for autoplay/huddles). * Closed streams are paginated via `loadMore`. */ - status: "open" | "closed"; + status: 'open' | 'closed'; } interface UseStreamParticlesResult { @@ -56,38 +56,40 @@ export function useStreamParticles( const visibilityScopes = useVisibilityScopes(user?.id, networkId); 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 - // window. Avoids an ever-growing subscription across a long session. - useEffect(() => { - if (status === "closed") { + // Switching back to the closed tab starts a fresh window, avoiding an + // ever-growing subscription across a long session. + if (status !== prevStatus) { + setPrevStatus(status); + if (status === 'closed') { setClosedLimit(CLOSED_INITIAL_PAGE_SIZE); } - }, [status]); + } const whereFilter: QueryFieldFilterConstraint = - status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; - const limit = status === "closed" ? closedLimit : undefined; + status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; + const limit = status === 'closed' ? closedLimit : undefined; const { children, isLoading, error } = useLiveParticleChildren(path, { - orderByField: "last_child_created_at", - orderDirection: "desc", + orderByField: 'last_child_created_at', + orderDirection: 'desc', visibilityScopes, whereFilter, limit, }); const streams = useMemo( - () => children.filter((c): c is StreamParticle => c.type === "stream"), + () => children.filter((c): c is StreamParticle => c.type === 'stream'), [children], ); // 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. - const canLoadMore = status === "closed" && streams.length >= closedLimit; + const canLoadMore = status === 'closed' && streams.length >= closedLimit; const loadMore = useCallback(() => { - if (status !== "closed") return; + if (status !== 'closed') return; setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT); }, [status]); diff --git a/js/mobile/src/hooks/use-stream-playback.ts b/js/mobile/src/hooks/use-stream-playback.ts index 5bfe03b..1db5f46 100644 --- a/js/mobile/src/hooks/use-stream-playback.ts +++ b/js/mobile/src/hooks/use-stream-playback.ts @@ -1,15 +1,15 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; -import { useAuthStore } from "@/stores/auth-store"; -import type { Particle } from "@/api/types"; -import { useLiveParticleChildren } from "@/hooks/use-particle"; -import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; -import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; -import { logError } from "@/lib/errors"; -import { useEvent } from "@/hooks/use-event"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Particle } from '@/api/types'; +import { useLiveParticleChildren } from '@/hooks/use-particle'; +import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path'; +import { updateStreamPlaybackMarker } from '@/lib/firestore-particles'; +import { logError } from '@/lib/errors'; +import { useEvent } from '@/hooks/use-event'; // --- Playback reducer (ID-based) --- -type PlaybackStatus = "idle" | "playing" | "ended"; +type PlaybackStatus = 'idle' | 'playing' | 'ended'; interface PlaybackState { currentParticleId: string | null; @@ -18,19 +18,19 @@ interface PlaybackState { } type PlaybackAction = - | { type: "INIT"; particleId: string } - | { type: "SET_PARTICLE"; particleId: string } - | { type: "END" } - | { type: "PARTICLE_ADDED"; particleId: string } + | { type: 'INIT'; particleId: string } + | { type: 'SET_PARTICLE'; particleId: string } + | { type: 'END' } + | { type: 'PARTICLE_ADDED'; particleId: string } | { - type: "PARTICLE_REMOVED"; + type: 'PARTICLE_REMOVED'; removedParticleId: string; fallbackParticleId: string | null; }; const initialState: PlaybackState = { currentParticleId: null, - status: "idle", + status: 'idle', initialized: false, }; @@ -39,39 +39,39 @@ function playbackReducer( action: PlaybackAction, ): PlaybackState { switch (action.type) { - case "INIT": + case 'INIT': return { currentParticleId: action.particleId, - status: "playing", + status: 'playing', initialized: true, }; - case "SET_PARTICLE": + case 'SET_PARTICLE': return { ...state, currentParticleId: action.particleId, - status: "playing", + status: 'playing', }; - case "END": - return { ...state, status: "ended" }; - case "PARTICLE_ADDED": - if (state.status === "ended") { + case 'END': + return { ...state, status: 'ended' }; + case 'PARTICLE_ADDED': + if (state.status === 'ended') { return { ...state, currentParticleId: action.particleId, - status: "playing", + status: 'playing', }; } return state; - case "PARTICLE_REMOVED": + case 'PARTICLE_REMOVED': if (action.removedParticleId !== state.currentParticleId) return state; if (action.fallbackParticleId) { return { ...state, 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( - streamParticle: Particle & { type: "stream" }, + streamParticle: Particle & { type: 'stream' }, path: ParticlePath, ): UseStreamPlaybackResult { const userId = useAuthStore((s) => s.user?.id); const [state, dispatch] = useReducer(playbackReducer, initialState); // Track which stream we initialized for, so navigating to a sibling resets cleanly. const initializedForRef = useRef(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) => { - dispatch({ type: "PARTICLE_ADDED", particleId: particle.id }); + dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id }); }, []); - const onParticleRemoved = useEvent( + const onParticleRemoved = useCallback( (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)]; dispatch({ - type: "PARTICLE_REMOVED", + type: 'PARTICLE_REMOVED', removedParticleId: removed.id, fallbackParticleId: fallback?.id ?? null, }); }, + [], ); const { children } = useLiveParticleChildren(path, { - orderByField: "created_at", - orderDirection: "asc", + orderByField: 'created_at', + orderDirection: 'asc', onAdded: onParticleAdded, onRemoved: onParticleRemoved, }); @@ -129,10 +136,15 @@ export function useStreamPlayback( 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(() => { if (state.initialized || children.length === 0) return; 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 --- @@ -149,11 +161,11 @@ export function useStreamPlayback( if (children.length === 0) return; - const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; + const playbackPosition = streamParticle.playback_markers?.[userId ?? '']; if (!playbackPosition) { initializedForRef.current = streamParticle.id; - dispatch({ type: "INIT", particleId: children[0].id }); + dispatch({ type: 'INIT', particleId: children[0].id }); return; } @@ -163,12 +175,12 @@ export function useStreamPlayback( if (found) { initializedForRef.current = streamParticle.id; - dispatch({ type: "INIT", particleId: found.id }); + dispatch({ type: 'INIT', particleId: found.id }); return; } else { initializedForRef.current = streamParticle.id; dispatch({ - type: "INIT", + type: 'INIT', particleId: children[children.length - 1].id, }); } @@ -201,7 +213,7 @@ export function useStreamPlayback( lastPersistedMarkerRef.current = currentTime; const streamDocPath = toFirestoreDocPath(path); 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 // avoid double-writes when the snapshot we just persisted echoes back. @@ -213,18 +225,18 @@ export function useStreamPlayback( if (currentIndex === -1) return; if (currentIndex < children.length - 1) { dispatch({ - type: "SET_PARTICLE", + type: 'SET_PARTICLE', particleId: children[currentIndex + 1].id, }); } else { - dispatch({ type: "END" }); + dispatch({ type: 'END' }); } }, [children, currentIndex]); const prev = useCallback(() => { if (currentIndex <= 0) return; dispatch({ - type: "SET_PARTICLE", + type: 'SET_PARTICLE', particleId: children[currentIndex - 1].id, }); }, [children, currentIndex]); @@ -232,7 +244,7 @@ export function useStreamPlayback( const goTo = useCallback( (index: number) => { if (index >= 0 && index < children.length) { - dispatch({ type: "SET_PARTICLE", particleId: children[index].id }); + dispatch({ type: 'SET_PARTICLE', particleId: children[index].id }); } }, [children], @@ -241,7 +253,7 @@ export function useStreamPlayback( // 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. const goToParticle = useCallback((particleId: string) => { - dispatch({ type: "SET_PARTICLE", particleId }); + dispatch({ type: 'SET_PARTICLE', particleId }); }, []); return { diff --git a/js/mobile/src/hooks/use-suspend-playback.ts b/js/mobile/src/hooks/use-suspend-playback.ts index 8ca363f..43586a6 100644 --- a/js/mobile/src/hooks/use-suspend-playback.ts +++ b/js/mobile/src/hooks/use-suspend-playback.ts @@ -1,5 +1,5 @@ -import { useEffect, useId } from "react"; -import { usePlaybackPauseStore } from "@/stores/playback-pause-store"; +import { useEffect, useId } from 'react'; +import { usePlaybackPauseStore } from '@/stores/playback-pause-store'; /** * Suspend stream playback while `active` is true. The hook owns its own diff --git a/js/mobile/src/hooks/use-transcript-playback.ts b/js/mobile/src/hooks/use-transcript-playback.ts index b047362..ba4d409 100644 --- a/js/mobile/src/hooks/use-transcript-playback.ts +++ b/js/mobile/src/hooks/use-transcript-playback.ts @@ -1,7 +1,7 @@ -import { useMemo } from "react"; -import type { Transcript } from "@/api/types"; +import { useMemo } from 'react'; +import type { Transcript } from '@/api/types'; -type Sentence = Transcript["paragraphs"][number]["sentences"][number]; +type Sentence = Transcript['paragraphs'][number]['sentences'][number]; interface TranscriptPlaybackState { /** The sentence currently being spoken, or null if between sentences */ diff --git a/js/mobile/src/lib/errors.ts b/js/mobile/src/lib/errors.ts index d3ac284..c40f149 100644 --- a/js/mobile/src/lib/errors.ts +++ b/js/mobile/src/lib/errors.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { appEnv } from "@/config/env"; +import { z } from 'zod'; +import { appEnv } from '@/config/env'; export class ApiError extends Error { constructor( @@ -7,7 +7,7 @@ export class ApiError extends Error { message: string, ) { super(message); - this.name = "ApiError"; + this.name = 'ApiError'; } } @@ -18,42 +18,42 @@ export class ApiError extends Error { */ export class QuotaExceededError extends Error { constructor(public readonly networkId: string) { - super("Daily message limit reached"); - this.name = "QuotaExceededError"; + super('Daily message limit reached'); + this.name = 'QuotaExceededError'; } } function normalizeMessage(message: string): string { - return message.replace(/^Error:\s*/, "").trim(); + return message.replace(/^Error:\s*/, '').trim(); } export function toUserMessage(err: unknown): string { 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 === 404) return "Not found."; + if (err.status === 404) return 'Not found.'; 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) { - 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) { - return "Received unexpected data from the server."; + return 'Received unexpected data from the server.'; } 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) { - 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; @@ -75,16 +75,14 @@ export function installErrorSinks(sinks: { /** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */ export function logError(err: unknown, context?: ErrorContext): void { - if (appEnv === "dev") { - // eslint-disable-next-line no-console - console.error("[error]", err, context ?? {}); + if (appEnv === 'dev') { + console.error('[error]', err, context ?? {}); } breadcrumbSink?.(err, context); } /** Unexpected failures the user may not see. Always captured. */ 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); } diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts index 0044169..05424d9 100644 --- a/js/mobile/src/lib/firestore-particles.ts +++ b/js/mobile/src/lib/firestore-particles.ts @@ -21,15 +21,15 @@ import { type SnapshotOptions, type Unsubscribe, type QueryFieldFilterConstraint, -} from "firebase/firestore"; -import { firestoreDb } from "@/firebase"; -import { isContainerType, ParticleSchema } from "@/api/types"; +} from 'firebase/firestore'; +import { firestoreDb } from '@/firebase'; +import { isContainerType, ParticleSchema } from '@/api/types'; import type { Particle, ParticleType, ParticlePropertiesMap, Reactions, -} from "@/api/types"; +} from '@/api/types'; // --- Converter --- @@ -37,7 +37,7 @@ const particleConverter: FirestoreDataConverter = { toFirestore(particle: Particle): DocumentData { const { id: _id, created_at, updated_at, ...rest } = particle; const deletedAt = - "deleted_at" in particle ? particle.deleted_at : undefined; + 'deleted_at' in particle ? particle.deleted_at : undefined; return { ...rest, created_at: Timestamp.fromDate(created_at), @@ -50,12 +50,12 @@ const particleConverter: FirestoreDataConverter = { options?: SnapshotOptions, ): Particle { const raw = snap.data(options); - if (typeof raw.type !== "string") { + if (typeof raw.type !== 'string') { throw new Error(`Invalid particle type: ${raw.type}`); } const type = raw.type as ParticleType; switch (type) { - case "stream": + case 'stream': return ParticleSchema.parse({ id: snap.id, type: raw.type, @@ -81,7 +81,7 @@ const particleConverter: FirestoreDataConverter = { raw.huddle_active_participants ?? undefined, status: raw.status ?? undefined, }); - case "folder": + case 'folder': return ParticleSchema.parse({ id: snap.id, type: raw.type, @@ -93,15 +93,15 @@ const particleConverter: FirestoreDataConverter = { : undefined, visible_to: raw.visible_to, }); - case "media": - case "file": - case "text": - case "quest": - case "paper": { + case 'media': + case 'file': + case 'text': + case 'quest': + case 'paper': { // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // particles carry `properties.edited_at`, so coerce it if present. const properties = - type === "text" && raw.properties?.edited_at + type === 'text' && raw.properties?.edited_at ? { ...raw.properties, edited_at: (raw.properties.edited_at as Timestamp).toDate(), @@ -165,17 +165,17 @@ export async function getParticle(docPath: string): Promise { export interface GetParticleChildrenOptions { orderByField: string; - orderDirection: "asc" | "desc"; + orderDirection: 'asc' | 'desc'; } export async function getParticleChildren( collectionPath: string, { - orderByField = "created_at", - orderDirection = "asc", + orderByField = 'created_at', + orderDirection = 'asc', }: GetParticleChildrenOptions = { - orderByField: "created_at", - orderDirection: "asc", + orderByField: 'created_at', + orderDirection: 'asc', }, ): Promise { const q = query( @@ -191,7 +191,7 @@ export interface SubscribeToParticleChildrenOptions { onError: (error: Error) => void; visibilityScopes?: string[]; orderByField?: string; - orderDirection?: "asc" | "desc"; + orderDirection?: 'asc' | 'desc'; onAdded?: (child: Particle) => void; onRemoved?: (child: Particle, updatedChildren: Particle[]) => void; whereFilter?: QueryFieldFilterConstraint; @@ -205,8 +205,8 @@ export function subscribeToParticleChildren( onData, onError, visibilityScopes = [], - orderByField = "created_at", - orderDirection = "desc", + orderByField = 'created_at', + orderDirection = 'desc', onAdded, onRemoved, whereFilter, @@ -218,7 +218,7 @@ export function subscribeToParticleChildren( orderBy(orderByField, orderDirection), ); 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) { q = query(q, whereFilter); @@ -234,8 +234,8 @@ export function subscribeToParticleChildren( if (onAdded || onRemoved) { for (const change of snap.docChanges()) { - if (change.type === "added" && onAdded) onAdded(change.doc.data()); - if (change.type === "removed" && onRemoved) + if (change.type === 'added' && onAdded) onAdded(change.doc.data()); + if (change.type === 'removed' && onRemoved) onRemoved(change.doc.data(), updatedChildren); } } @@ -251,7 +251,7 @@ export function subscribeToLatestChild( ): Unsubscribe { const q = query( typedCollection(collectionPath), - orderBy("created_at", "desc"), + orderBy('created_at', 'desc'), limit(1), ); return onSnapshot( @@ -279,7 +279,7 @@ export async function createParticle( } 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, properties, created_at: new Date(), @@ -292,22 +292,22 @@ export async function createParticle( export async function createStreamParticle( collectionPath: string, - properties: ParticlePropertiesMap["stream"], + properties: ParticlePropertiesMap['stream'], createdByHumanId: string, visibleTo?: string[], ): Promise { 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({ - id: "", - type: "stream", + id: '', + type: 'stream', properties, created_at: new Date(), created_by_human_id: createdByHumanId, visible_to: visibleTo, - status: "open", + status: 'open', }); const ref = await addDoc(typedCollection(collectionPath), particle); return ref.id; @@ -340,8 +340,8 @@ export async function editTextParticleContent( ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { - "properties.content": content, - "properties.edited_at": serverTimestamp(), + 'properties.content': content, + 'properties.edited_at': serverTimestamp(), updated_at: serverTimestamp(), }); } @@ -383,7 +383,7 @@ export async function updateParticle( export async function updateStreamStatus( docPath: string, - status: "open" | "closed", + status: 'open' | 'closed', ): Promise { const particleRef = typedDoc(docPath); await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); @@ -426,7 +426,7 @@ export async function updateStreamPlaybackMarker( const RESERVED_REACTION_CHARS = /[~*/[\]]/g; export function sanitizeReactionText(text: string): string { - return text.replace(RESERVED_REACTION_CHARS, ""); + return text.replace(RESERVED_REACTION_CHARS, ''); } export async function toggleParticleReaction( @@ -441,9 +441,9 @@ export async function toggleParticleReaction( const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false; await updateDoc( particleRef, - new FieldPath("reactions", key), + new FieldPath('reactions', key), alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId), - "updated_at", + 'updated_at', serverTimestamp(), ); } diff --git a/js/mobile/src/lib/humans.ts b/js/mobile/src/lib/humans.ts index 543fdb6..c25284c 100644 --- a/js/mobile/src/lib/humans.ts +++ b/js/mobile/src/lib/humans.ts @@ -1,8 +1,8 @@ -import type { Human } from "@/api/types"; -import { getInitials } from "@/lib/utils"; +import type { Human } from '@/api/types'; +import { getInitials } from '@/lib/utils'; -export const REMOVED_MEMBER_LABEL = "Removed member"; -export const REMOVED_MEMBER_INITIALS = "–"; +export const REMOVED_MEMBER_LABEL = 'Removed member'; +export const REMOVED_MEMBER_INITIALS = '–'; export interface HumanDisplay { /** True when the human was found in the provided list. */ diff --git a/js/mobile/src/lib/notification-routing.ts b/js/mobile/src/lib/notification-routing.ts index 071abe4..17951ba 100644 --- a/js/mobile/src/lib/notification-routing.ts +++ b/js/mobile/src/lib/notification-routing.ts @@ -1,6 +1,6 @@ -import { createNavigationContainerRef } from "@react-navigation/native"; -import type { Notification } from "expo-notifications"; -import { logError } from "@/lib/errors"; +import { createNavigationContainerRef } from '@react-navigation/native'; +import type { Notification } from 'expo-notifications'; +import { logError } from '@/lib/errors'; // Shared ref so non-component code (notification handlers, deep links) can // 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) // puts in `Notifications.notification.request.content.data`. type ParticleCreatedData = { - kind: "particle_created"; + kind: 'particle_created'; network_id: string; stream_id: string; particle_id: string; @@ -20,11 +20,11 @@ type ParticleCreatedData = { function isParticleCreatedData(data: unknown): data is ParticleCreatedData { return ( - typeof data === "object" && + typeof data === 'object' && data !== null && - (data as { kind?: unknown }).kind === "particle_created" && - typeof (data as { network_id?: unknown }).network_id === "string" && - typeof (data as { stream_id?: unknown }).stream_id === "string" + (data as { kind?: unknown }).kind === 'particle_created' && + typeof (data as { network_id?: unknown }).network_id === 'string' && + typeof (data as { stream_id?: unknown }).stream_id === 'string' ); } @@ -48,7 +48,7 @@ export function routeNotificationTap(notification: Notification): void { } navigateToStream(data); } 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 { - navigationRef.navigate("StreamView", { + navigationRef.navigate('StreamView', { networkId: data.network_id, streamId: data.stream_id, }); diff --git a/js/mobile/src/lib/particle-path.ts b/js/mobile/src/lib/particle-path.ts index 905e1fd..aecb51c 100644 --- a/js/mobile/src/lib/particle-path.ts +++ b/js/mobile/src/lib/particle-path.ts @@ -20,7 +20,7 @@ export function particlePath( networkId: string, segments: string[] = [], ): ParticlePath { - return `/${[networkId, ...segments].join("/")}` as ParticlePath; + return `/${[networkId, ...segments].join('/')}` as ParticlePath; } /** @@ -30,7 +30,7 @@ export function parseParticlePath(path: ParticlePath): { networkId: string; segments: string[]; } { - const parts = path.split("/").filter(Boolean); + const parts = path.split('/').filter(Boolean); return { networkId: parts[0], segments: parts.slice(1) }; } @@ -49,9 +49,9 @@ export function toFirestoreDocPath(path: ParticlePath): string { const parts: string[] = [base, segments[0]]; for (let i = 1; i < segments.length; i++) { - parts.push("children", segments[i]); + parts.push('children', segments[i]); } - return parts.join("/"); + return parts.join('/'); } /** diff --git a/js/mobile/src/lib/push-notifications.ts b/js/mobile/src/lib/push-notifications.ts index b67ea3e..c1ceb36 100644 --- a/js/mobile/src/lib/push-notifications.ts +++ b/js/mobile/src/lib/push-notifications.ts @@ -1,13 +1,13 @@ -import Constants from "expo-constants"; -import * as Device from "expo-device"; -import * as Notifications from "expo-notifications"; -import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; -import { apiClient } from "@/api/client"; -import { logError } from "@/lib/errors"; -import { routeNotificationTap } from "@/lib/notification-routing"; +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; +import { apiClient } from '@/api/client'; +import { logError } from '@/lib/errors'; +import { routeNotificationTap } from '@/lib/notification-routing'; -const STORED_TOKEN_KEY = "expo_push_token"; +const STORED_TOKEN_KEY = 'expo_push_token'; let configured = false; let tokenListenerSubscription: Notifications.Subscription | null = null; @@ -76,18 +76,18 @@ async function acquirePushToken(): Promise { const existing = await Notifications.getPermissionsAsync(); let status = existing.status; - if (status !== "granted") { + if (status !== 'granted') { const requested = await Notifications.requestPermissionsAsync(); status = requested.status; } - if (status !== "granted") return null; + if (status !== 'granted') return null; const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId; if (!projectId) { - logError(new Error("EAS projectId missing — cannot fetch push token"), { - scope: "push.acquire", + logError(new Error('EAS projectId missing — cannot fetch push token'), { + scope: 'push.acquire', }); return null; } @@ -108,7 +108,7 @@ async function setStoredToken(token: string): Promise { try { await SecureStore.setItemAsync(STORED_TOKEN_KEY, token); } catch (err) { - logError(err, { scope: "push.store" }); + logError(err, { scope: 'push.store' }); } } @@ -133,8 +133,8 @@ export async function syncPushToken(token?: string | null): Promise { const stored = await getStoredToken(); if (stored === next) return; - const platform = Platform.OS === "ios" ? "ios" : "android"; - const appVersion = Constants.expoConfig?.version ?? ""; + const platform = Platform.OS === 'ios' ? 'ios' : 'android'; + const appVersion = Constants.expoConfig?.version ?? ''; await apiClient.registerPushToken({ token: next, @@ -143,7 +143,7 @@ export async function syncPushToken(token?: string | null): Promise { }); await setStoredToken(next); } catch (err) { - logError(err, { scope: "push.sync" }); + logError(err, { scope: 'push.sync' }); } } @@ -158,7 +158,7 @@ export async function unregisterPushToken(): Promise { try { await apiClient.unregisterPushToken(stored); } catch (err) { - logError(err, { scope: "push.unregister" }); + logError(err, { scope: 'push.unregister' }); } } } finally { diff --git a/js/mobile/src/lib/pusher-client.ts b/js/mobile/src/lib/pusher-client.ts index 99ce04a..2cb7be0 100644 --- a/js/mobile/src/lib/pusher-client.ts +++ b/js/mobile/src/lib/pusher-client.ts @@ -7,13 +7,13 @@ * 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 = - | "disconnected" - | "connecting" - | "connected" - | "reconnecting"; + | 'disconnected' + | 'connecting' + | 'connected' + | 'reconnecting'; export interface ChannelMessage { humanId: string; @@ -21,7 +21,7 @@ export interface ChannelMessage { } interface ServerMessage { - type: "subscribed" | "join" | "leave" | "message" | "error"; + type: 'subscribed' | 'join' | 'leave' | 'message' | 'error'; channel?: string; humanId?: string; presence?: string[]; @@ -29,7 +29,7 @@ interface ServerMessage { message?: string; } -type ChannelEventType = "subscribed" | "join" | "leave" | "message"; +type ChannelEventType = 'subscribed' | 'join' | 'leave' | 'message'; type ChannelEventCallback = (msg: ServerMessage) => void; interface PusherClientConfig { @@ -44,7 +44,7 @@ const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout export class PusherClient { private config: PusherClientConfig; private ws: WebSocket | null = null; - private state: ConnectionState = "disconnected"; + private state: ConnectionState = 'disconnected'; private stateListeners = new Set<(state: ConnectionState) => void>(); private listeners = new Map< @@ -73,20 +73,20 @@ export class PusherClient { const token = this.config.getToken(); if (!token) { - console.warn("[pusher] no token available, cannot connect"); + console.warn('[pusher] no token available, cannot connect'); return; } this.shouldReconnect = true; this.setState( - this.state === "reconnecting" ? "reconnecting" : "connecting", + this.state === 'reconnecting' ? 'reconnecting' : 'connecting', ); const url = `${this.config.url}?token=${encodeURIComponent(token)}`; this.ws = new WebSocket(url); this.ws.onopen = () => { - this.setState("connected"); + this.setState('connected'); this.reconnectDelay = INITIAL_RECONNECT_DELAY; this.startPing(); this.resubscribeAll(); @@ -101,7 +101,7 @@ export class PusherClient { this.ws.onerror = (event) => { // onclose fires after onerror — reconnection is handled there. - logError(event, { scope: "pusher.ws" }); + logError(event, { scope: 'pusher.ws' }); }; this.ws.onmessage = (event) => { @@ -114,21 +114,21 @@ export class PusherClient { this.clearReconnectTimer(); this.cleanup(); this.activeSubscriptions.clear(); - this.setState("disconnected"); + this.setState('disconnected'); } subscribe(channelId: string): void { this.activeSubscriptions.add(channelId); - this.send({ type: "subscribe", channel: channelId }); + this.send({ type: 'subscribe', channel: channelId }); } unsubscribe(channelId: string): void { this.activeSubscriptions.delete(channelId); - this.send({ type: "unsubscribe", channel: channelId }); + this.send({ type: 'unsubscribe', channel: channelId }); } sendMessage(channelId: string, payload: unknown): void { - this.send({ type: "message", channel: channelId, payload }); + this.send({ type: 'message', channel: channelId, payload }); } on( @@ -181,19 +181,19 @@ export class PusherClient { } private handleMessage(data: string): void { - if (data === "pong") return; + if (data === 'pong') return; let msg: ServerMessage; try { msg = JSON.parse(data); } catch (err) { - logError(err, { scope: "pusher.parse", data }); + logError(err, { scope: 'pusher.parse', data }); return; } - if (msg.type === "error") { - logError(new Error(msg.message ?? "pusher server error"), { - scope: "pusher.server", + if (msg.type === 'error') { + logError(new Error(msg.message ?? 'pusher server error'), { + scope: 'pusher.server', }); return; } @@ -210,19 +210,19 @@ export class PusherClient { try { cb(msg); } catch (err) { - reportError(err, { scope: "pusher.listener", channel: msg.channel }); + reportError(err, { scope: 'pusher.listener', channel: msg.channel }); } } } private resubscribeAll(): void { for (const channelId of this.activeSubscriptions) { - this.send({ type: "subscribe", channel: channelId }); + this.send({ type: 'subscribe', channel: channelId }); } } private scheduleReconnect(): void { - this.setState("reconnecting"); + this.setState('reconnecting'); const jitter = Math.random() * 0.5 + 0.75; const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY); @@ -264,7 +264,7 @@ export class PusherClient { this.stopPing(); this.pingTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send("ping"); + this.ws.send('ping'); } }, PING_INTERVAL); } diff --git a/js/mobile/src/lib/pusher-provider.tsx b/js/mobile/src/lib/pusher-provider.tsx index 00f9e0d..fef2951 100644 --- a/js/mobile/src/lib/pusher-provider.tsx +++ b/js/mobile/src/lib/pusher-provider.tsx @@ -2,39 +2,37 @@ import { createContext, useContext, useEffect, - useRef, + useMemo, useState, type ReactNode, -} from "react"; -import { PusherClient, type ConnectionState } from "./pusher-client"; -import { useAuthStore } from "@/stores/auth-store"; -import { appConfig } from "@/config/env"; +} from 'react'; +import { PusherClient, type ConnectionState } from './pusher-client'; +import { useAuthStore } from '@/stores/auth-store'; +import { appConfig } from '@/config/env'; const PusherContext = createContext(null); -const PusherStateContext = createContext("disconnected"); +const PusherStateContext = createContext('disconnected'); export function PusherProvider({ children }: { children: ReactNode }) { const token = useAuthStore((s) => s.token); - const clientRef = useRef(null); const [connectionState, setConnectionState] = - useState("disconnected"); + useState('disconnected'); - useEffect(() => { + const client = useMemo(() => { if (!token) { - if (clientRef.current) { - clientRef.current.disconnect(); - clientRef.current = null; - setConnectionState("disconnected"); - } - return; + return null; } - const client = new PusherClient({ + return new PusherClient({ url: appConfig.pusherUrl, getToken: () => useAuthStore.getState().token, }); + }, [token]); - clientRef.current = client; + useEffect(() => { + if (!client) { + return; + } const unsubscribeState = client.onStateChange((state) => { setConnectionState(state); @@ -45,12 +43,12 @@ export function PusherProvider({ children }: { children: ReactNode }) { return () => { unsubscribeState(); client.disconnect(); - clientRef.current = null; + setConnectionState('disconnected'); }; - }, [token]); + }, [client]); return ( - + {children} diff --git a/js/mobile/src/lib/query-client.ts b/js/mobile/src/lib/query-client.ts index 65125ee..0058782 100644 --- a/js/mobile/src/lib/query-client.ts +++ b/js/mobile/src/lib/query-client.ts @@ -1,13 +1,9 @@ -import { - MutationCache, - QueryCache, - QueryClient, -} from "@tanstack/react-query"; -import { toast } from "sonner-native"; -import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors"; -import { useAuthStore } from "@/stores/auth-store"; +import { MutationCache, QueryCache, QueryClient } 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 { queryMeta: { toastOnError?: boolean }; mutationMeta: { suppressToast?: boolean }; @@ -48,7 +44,7 @@ export function createQueryClient(): QueryClient { queryCache: new QueryCache({ onError: (err, query) => { handleUnauthorized(err); - logError(err, { scope: "query", queryKey: query.queryKey }); + logError(err, { scope: 'query', queryKey: query.queryKey }); if (query.meta?.toastOnError) { toast.error(toUserMessage(err)); } @@ -58,7 +54,7 @@ export function createQueryClient(): QueryClient { onError: (err, _variables, _context, mutation) => { handleUnauthorized(err); reportError(err, { - scope: "mutation", + scope: 'mutation', mutationKey: mutation.options.mutationKey, }); if (mutation.meta?.suppressToast) return; diff --git a/js/mobile/src/lib/random-name.ts b/js/mobile/src/lib/random-name.ts index 7053df5..8c57ecc 100644 --- a/js/mobile/src/lib/random-name.ts +++ b/js/mobile/src/lib/random-name.ts @@ -1,15 +1,67 @@ const ADJECTIVES = [ - "amber", "bold", "calm", "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", + 'amber', + 'bold', + 'calm', + '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 = [ - "arrow", "bloom", "cedar", "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", + 'arrow', + 'bloom', + 'cedar', + '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 { diff --git a/js/mobile/src/lib/recording-audio-session.ts b/js/mobile/src/lib/recording-audio-session.ts index 58bfb2a..47d5278 100644 --- a/js/mobile/src/lib/recording-audio-session.ts +++ b/js/mobile/src/lib/recording-audio-session.ts @@ -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 // with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple @@ -10,7 +10,7 @@ export async function acquireRecordingAudioSession() { await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true, - interruptionMode: "doNotMix", + interruptionMode: 'doNotMix', }); } @@ -18,7 +18,7 @@ export async function releaseRecordingAudioSession() { await setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, - interruptionMode: "mixWithOthers", + interruptionMode: 'mixWithOthers', }); await setIsAudioActiveAsync(false); } diff --git a/js/mobile/src/lib/stream-visibility.ts b/js/mobile/src/lib/stream-visibility.ts index c748769..10565a5 100644 --- a/js/mobile/src/lib/stream-visibility.ts +++ b/js/mobile/src/lib/stream-visibility.ts @@ -1,23 +1,23 @@ -import { removeDuplicates } from "@/lib/utils"; +import { removeDuplicates } from '@/lib/utils'; -const HUMAN_PREFIX = "human:"; -const NETWORK_PREFIX = "network:"; +const HUMAN_PREFIX = 'human:'; +const NETWORK_PREFIX = 'network:'; export type StreamVisibility = - | { mode: "network" } - | { mode: "custom"; humanIds: string[] }; + | { mode: 'network' } + | { mode: 'custom'; humanIds: string[] }; export function parseVisibleTo( visibleTo: string[], networkId: string, ): StreamVisibility { if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) { - return { mode: "network" }; + return { mode: 'network' }; } const humanIds = visibleTo .filter((v) => v.startsWith(HUMAN_PREFIX)) .map((v) => v.slice(HUMAN_PREFIX.length)); - return { mode: "custom", humanIds }; + return { mode: 'custom', humanIds }; } export function buildNetworkVisibility(networkId: string): string[] { diff --git a/js/mobile/src/lib/time-utils.ts b/js/mobile/src/lib/time-utils.ts index a4fd4a9..73225ed 100644 --- a/js/mobile/src/lib/time-utils.ts +++ b/js/mobile/src/lib/time-utils.ts @@ -6,10 +6,11 @@ const MONTH = 2592000; const YEAR = 31536000; 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); - if (seconds < 5) return "just now"; + if (seconds < 5) return 'just now'; if (seconds < MINUTE) return `${seconds}s ago`; if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`; if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`; diff --git a/js/mobile/src/lib/upload.ts b/js/mobile/src/lib/upload.ts index a6a571b..f4ea35d 100644 --- a/js/mobile/src/lib/upload.ts +++ b/js/mobile/src/lib/upload.ts @@ -2,17 +2,17 @@ import { FileSystemUploadType, getInfoAsync, uploadAsync, -} from "expo-file-system/legacy"; -import { apiClient } from "@/api/client"; +} from 'expo-file-system/legacy'; +import { apiClient } from '@/api/client'; import { createParticle, createStreamParticle, -} from "@/lib/firestore-particles"; +} from '@/lib/firestore-particles'; import { particlePath, toFirestoreChildrenPath, type ParticlePath, -} from "@/lib/particle-path"; +} from '@/lib/particle-path'; interface UploadMediaParticleParams { networkId: string; @@ -21,7 +21,7 @@ interface UploadMediaParticleParams { fileUri: string; mimeType: string; durationMs: number; - source: "camera" | "screen"; + source: 'camera' | 'screen'; createdByHumanId: string; } @@ -44,11 +44,11 @@ export async function uploadMediaParticle({ }: UploadMediaParticleParams): Promise { const info = await getInfoAsync(fileUri); 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 namePrefix = mimeType.startsWith("audio/") ? "voice" : "video"; + const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video'; const ext = extensionFromMime(mimeType); const name = `${namePrefix}-${Date.now()}${ext}`; @@ -61,15 +61,13 @@ export async function uploadMediaParticle({ }); const uploadResult = await uploadAsync(upload_url, fileUri, { - httpMethod: "PUT", + httpMethod: 'PUT', uploadType: FileSystemUploadType.BINARY_CONTENT, headers: upload_headers, }); if (uploadResult.status < 200 || uploadResult.status >= 300) { - throw new Error( - `Upload to depot failed (HTTP ${uploadResult.status}).`, - ); + throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`); } await apiClient.confirmUpload(object_id); @@ -77,7 +75,7 @@ export async function uploadMediaParticle({ const collectionPath = toFirestoreChildrenPath(targetPath); return createParticle( collectionPath, - "media", + 'media', { object_id, mime_type: mimeType, @@ -102,20 +100,15 @@ export async function createTextParticle({ createdByHumanId, }: CreateTextParticleParams): Promise { const collectionPath = toFirestoreChildrenPath(targetPath); - return createParticle( - collectionPath, - "text", - { content }, - createdByHumanId, - ); + return createParticle(collectionPath, 'text', { content }, createdByHumanId); } function extensionFromMime(mime: string): string { - if (mime === "video/mp4") return ".mp4"; - if (mime === "video/quicktime") return ".mov"; - if (mime === "audio/mp4") return ".m4a"; - if (mime === "audio/webm") return ".webm"; - return ""; + if (mime === 'video/mp4') return '.mp4'; + if (mime === 'video/quicktime') return '.mov'; + if (mime === 'audio/mp4') return '.m4a'; + if (mime === 'audio/webm') return '.webm'; + return ''; } // Helper kept here so callers can construct a fresh stream's child-path before @@ -137,13 +130,13 @@ interface CreateStreamWithFirstParticleParams { createdByHumanId: string; /** First particle to write into the new stream. Required — empty streams are not useful. */ firstParticle: - | { type: "text"; content: string } + | { type: 'text'; content: string } | { - type: "media"; + type: 'media'; fileUri: string; mimeType: string; durationMs: number; - source: "camera" | "screen"; + source: 'camera' | 'screen'; }; } @@ -178,7 +171,7 @@ export async function createStreamWithFirstParticle({ const streamPath = particlePath(networkId, [streamId]); // 2. The first child goes inside the new stream. - if (firstParticle.type === "text") { + if (firstParticle.type === 'text') { await createTextParticle({ networkId, targetPath: streamPath, diff --git a/js/mobile/src/lib/utils.ts b/js/mobile/src/lib/utils.ts index b3229a2..39c065e 100644 --- a/js/mobile/src/lib/utils.ts +++ b/js/mobile/src/lib/utils.ts @@ -1,12 +1,12 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export function getInitials(email: string): string { - const prefix = email.split("@")[0] ?? ""; + const prefix = email.split('@')[0] ?? ''; return prefix.slice(0, 2).toUpperCase(); } diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 1bd39b5..4cbb217 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -1,22 +1,22 @@ -import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { ActivityIndicator, View } from "react-native"; -import { useAuthStore } from "@/stores/auth-store"; -import { SignInScreen } from "@/features/auth/SignInScreen"; -import { NetworkListScreen } from "@/features/networks/NetworkListScreen"; -import { StreamListScreen } from "@/features/streams/StreamListScreen"; -import { NewStreamScreen } from "@/features/streams/NewStreamScreen"; -import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen"; -import { HuddleScreen } from "@/features/huddle/HuddleScreen"; -import { SettingsScreen } from "@/features/settings/SettingsScreen"; -import { AccountScreen } from "@/features/settings/AccountScreen"; -import type { RootStackParamList } from "./types"; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { ActivityIndicator, View } from 'react-native'; +import { useAuthStore } from '@/stores/auth-store'; +import { SignInScreen } from '@/features/auth/SignInScreen'; +import { NetworkListScreen } from '@/features/networks/NetworkListScreen'; +import { StreamListScreen } from '@/features/streams/StreamListScreen'; +import { NewStreamScreen } from '@/features/streams/NewStreamScreen'; +import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen'; +import { HuddleScreen } from '@/features/huddle/HuddleScreen'; +import { SettingsScreen } from '@/features/settings/SettingsScreen'; +import { AccountScreen } from '@/features/settings/AccountScreen'; +import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); export function RootNavigator() { const status = useAuthStore((s) => s.status); - if (status === "idle" || status === "restoring") { + if (status === 'idle' || status === 'restoring') { return ( @@ -24,7 +24,7 @@ export function RootNavigator() { ); } - if (status === "unauthenticated") { + if (status === 'unauthenticated') { return ( @@ -42,17 +42,17 @@ export function RootNavigator() { diff --git a/js/mobile/src/navigation/types.ts b/js/mobile/src/navigation/types.ts index 6e5bd62..f79ec26 100644 --- a/js/mobile/src/navigation/types.ts +++ b/js/mobile/src/navigation/types.ts @@ -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 // NetworkList screen itself, not the navigator — see Drawer.tsx. @@ -24,6 +24,10 @@ export type RootStackScreenProps = declare global { 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 {} } } diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index cced9da..852a61a 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -1,21 +1,21 @@ -import * as SecureStore from "expo-secure-store"; -import { create } from "zustand"; +import * as SecureStore from 'expo-secure-store'; +import { create } from 'zustand'; import { signInWithCustomToken, signOut as firebaseSignOut, -} from "firebase/auth"; -import { apiClient } from "@/api/client"; -import type { Human } from "@/api/types"; -import { firebaseAuth } from "@/firebase"; -import { logError, ApiError } from "@/lib/errors"; +} from 'firebase/auth'; +import { apiClient } from '@/api/client'; +import type { Human } from '@/api/types'; +import { firebaseAuth } from '@/firebase'; +import { logError, ApiError } from '@/lib/errors'; import { startPushTokenSync, stopPushTokenSync, syncPushToken, 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 { try { @@ -45,11 +45,11 @@ async function signInToFirebase(): Promise { } catch (err) { // Firestore subscriptions will fail until the next successful sign-in; the // 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 { status: AuthStatus; @@ -73,7 +73,7 @@ interface AuthState { } export const useAuthStore = create((set, get) => ({ - status: "idle", + status: 'idle', user: null, token: null, isRequestingCode: false, @@ -82,11 +82,11 @@ export const useAuthStore = create((set, get) => ({ error: null, restoreSession: async () => { - set({ status: "restoring" }); + set({ status: 'restoring' }); const token = await readPersistedToken(); if (!token) { - set({ status: "unauthenticated" }); + set({ status: 'unauthenticated' }); return; } @@ -96,12 +96,12 @@ export const useAuthStore = create((set, get) => ({ try { const user = await apiClient.me(); await signInToFirebase(); - set({ status: "authenticated", user }); + set({ status: 'authenticated', user }); startPushTokenSync(); void syncPushToken(); } catch (err) { // Expected on expired/invalid tokens — fall back to the login screen. - logError(err, { scope: "auth.restore" }); + logError(err, { scope: 'auth.restore' }); await get().invalidateSession(); } }, @@ -111,8 +111,7 @@ export const useAuthStore = create((set, get) => ({ try { await apiClient.requestCode({ email }); } catch (e) { - const message = - e instanceof ApiError ? e.message : "Failed to send code"; + const message = e instanceof ApiError ? e.message : 'Failed to send code'; set({ error: message }); throw e; } finally { @@ -128,11 +127,11 @@ export const useAuthStore = create((set, get) => ({ apiClient.setToken(token); set({ token }); await signInToFirebase(); - set({ status: "authenticated", user: human }); + set({ status: 'authenticated', user: human }); startPushTokenSync(); void syncPushToken(); } 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 }); throw e; } finally { @@ -150,15 +149,15 @@ export const useAuthStore = create((set, get) => ({ await apiClient.signOut(); } catch (err) { // Best-effort — sign out locally regardless. - logError(err, { scope: "auth.signOut" }); + logError(err, { scope: 'auth.signOut' }); } finally { await firebaseSignOut(firebaseAuth).catch((err) => - logError(err, { scope: "auth.firebaseSignOut" }), + logError(err, { scope: 'auth.firebaseSignOut' }), ); apiClient.setToken(null); await clearPersistedToken(); set({ - status: "unauthenticated", + status: 'unauthenticated', user: null, token: null, isSigningOut: false, @@ -171,7 +170,7 @@ export const useAuthStore = create((set, get) => ({ stopPushTokenSync(); apiClient.setToken(null); await clearPersistedToken(); - set({ status: "unauthenticated", user: null, token: null }); + set({ status: 'unauthenticated', user: null, token: null }); await unregisterPushToken(); // Best-effort }, diff --git a/js/mobile/src/stores/playback-pause-store.ts b/js/mobile/src/stores/playback-pause-store.ts index 8885adf..c931682 100644 --- a/js/mobile/src/stores/playback-pause-store.ts +++ b/js/mobile/src/stores/playback-pause-store.ts @@ -1,4 +1,4 @@ -import { create } from "zustand"; +import { create } from 'zustand'; /** * Single source of truth for "is stream playback paused." Each component that diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index 8aa9f50..bc433ea 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -21,6 +21,15 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/code-frame@~7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" @@ -33,6 +42,11 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.25.2": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" @@ -54,6 +68,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.24.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/generator@^7.20.5", "@babel/generator@^7.25.0", "@babel/generator@^7.29.0", "@babel/generator@^7.29.1": version "7.29.1" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" @@ -65,6 +100,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" + integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== + dependencies: + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": version "7.27.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -83,6 +129,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz#611ff5482da9ef0db6291bcd24303400bca170fb" @@ -121,6 +178,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + "@babel/helper-member-expression-to-functions@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150" @@ -137,6 +199,14 @@ "@babel/traverse" "^7.28.6" "@babel/types" "^7.28.6" +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/helper-module-transforms@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" @@ -146,6 +216,15 @@ "@babel/helper-validator-identifier" "^7.28.5" "@babel/traverse" "^7.28.6" +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/helper-optimise-call-expression@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200" @@ -189,16 +268,31 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + "@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + "@babel/helper-wrap-function@^7.27.1": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz#4e349ff9222dab69a93a019cc296cdd8442e279a" @@ -216,6 +310,14 @@ "@babel/template" "^7.28.6" "@babel/types" "^7.29.0" +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/highlight@^7.10.4": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.25.9.tgz#8141ce68fc73757946f983b343f1231f4691acc6" @@ -233,6 +335,13 @@ dependencies: "@babel/types" "^7.29.0" +"@babel/parser@^7.24.4", "@babel/parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + "@babel/plugin-proposal-decorators@^7.12.9": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz#d159f26f78740e47bf3ef075882b155b2d54ca81" @@ -749,6 +858,15 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" @@ -775,6 +893,19 @@ "@babel/types" "^7.29.0" debug "^4.3.1" +"@babel/traverse@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" + integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + debug "^4.3.1" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.23.0", "@babel/types@^7.25.2", "@babel/types@^7.26.0", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.3": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" @@ -783,6 +914,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@bufbuild/protobuf@^1.10.0": version "1.10.1" resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-1.10.1.tgz#1d76d15290c0212076c15ede94d15157ba0c6344" @@ -800,6 +939,96 @@ dependencies: "@types/hammerjs" "^2.0.36" +"@emnapi/core@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" + integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + +"@emnapi/runtime@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.21.2": + version "0.21.2" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6" + integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.5" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== + dependencies: + ajv "^6.14.0" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.5" + strip-json-comments "^3.1.1" + +"@eslint/js@9.39.4": + version "9.39.4" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1" + integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== + +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== + dependencies: + "@eslint/core" "^0.17.0" + levn "^0.4.1" + "@expo/cli@54.0.24": version "54.0.24" resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.24.tgz#7225d99e019f6eb85fd5ef018d8d82391be5dc82" @@ -1556,6 +1785,37 @@ protobufjs "^7.2.5" yargs "^17.7.2" +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + "@ide/backoff@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176" @@ -1772,6 +2032,13 @@ web-streams-polyfill "^4.3.0" well-known-symbols "^4.1.0" +"@napi-rs/wasm-runtime@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" + integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== + dependencies: + "@tybys/wasm-util" "^0.10.1" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1793,6 +2060,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@nolyfill/is-core-module@1.0.39": + version "1.0.39" + resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" + integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -2039,6 +2311,11 @@ dependencies: nanoid "^3.3.11" +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== + "@sinclair/typebox@^0.27.8": version "0.27.10" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.10.tgz#beefe675f1853f73676aecc915b2bd2ac98c4fc6" @@ -2070,6 +2347,13 @@ dependencies: "@tanstack/query-core" "5.100.6" +"@tybys/wasm-util@^0.10.1": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== + dependencies: + tslib "^2.4.0" + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -2103,6 +2387,11 @@ dependencies: "@babel/types" "^7.28.2" +"@types/estree@^1.0.6": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -2134,6 +2423,16 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + "@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "25.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" @@ -2165,11 +2464,221 @@ dependencies: "@types/yargs-parser" "*" +"@typescript-eslint/eslint-plugin@^8.59.0": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76" + integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.60.1" + "@typescript-eslint/type-utils" "8.60.1" + "@typescript-eslint/utils" "8.60.1" + "@typescript-eslint/visitor-keys" "8.60.1" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@^8.59.0": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289" + integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA== + dependencies: + "@typescript-eslint/scope-manager" "8.60.1" + "@typescript-eslint/types" "8.60.1" + "@typescript-eslint/typescript-estree" "8.60.1" + "@typescript-eslint/visitor-keys" "8.60.1" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b" + integrity sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.60.1" + "@typescript-eslint/types" "^8.60.1" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8" + integrity sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w== + dependencies: + "@typescript-eslint/types" "8.60.1" + "@typescript-eslint/visitor-keys" "8.60.1" + +"@typescript-eslint/tsconfig-utils@8.60.1", "@typescript-eslint/tsconfig-utils@^8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93" + integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA== + +"@typescript-eslint/type-utils@8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379" + integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A== + dependencies: + "@typescript-eslint/types" "8.60.1" + "@typescript-eslint/typescript-estree" "8.60.1" + "@typescript-eslint/utils" "8.60.1" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.60.1", "@typescript-eslint/types@^8.29.1", "@typescript-eslint/types@^8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4" + integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w== + +"@typescript-eslint/typescript-estree@8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74" + integrity sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew== + dependencies: + "@typescript-eslint/project-service" "8.60.1" + "@typescript-eslint/tsconfig-utils" "8.60.1" + "@typescript-eslint/types" "8.60.1" + "@typescript-eslint/visitor-keys" "8.60.1" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.60.1", "@typescript-eslint/utils@^8.29.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b" + integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.60.1" + "@typescript-eslint/types" "8.60.1" + "@typescript-eslint/typescript-estree" "8.60.1" + +"@typescript-eslint/visitor-keys@8.60.1": + version "8.60.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz#165d1d8901137b944efaf18f00ab5ecb57f06995" + integrity sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag== + dependencies: + "@typescript-eslint/types" "8.60.1" + eslint-visitor-keys "^5.0.0" + "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@unrs/resolver-binding-android-arm-eabi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz#98a9fee62c01f209747a4ab5855f1ced38a6d03a" + integrity sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w== + +"@unrs/resolver-binding-android-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz#46b7e8a1393f907462324f1576e8883529acf066" + integrity sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ== + +"@unrs/resolver-binding-darwin-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz#0ea07b00e2583ab004b853d4c02ec5f0745d490c" + integrity sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w== + +"@unrs/resolver-binding-darwin-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz#a2a6901ed58449b91b4438e582f6890cba956049" + integrity sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA== + +"@unrs/resolver-binding-freebsd-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz#ebe6fe7f6706b7378ea4a48a024602e9c2f48f89" + integrity sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz#e6040fedaa240124419d35b25b69c5fa15ddb499" + integrity sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz#d217a8fb59f659c131539326c140e7b62e3e3c6a" + integrity sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g== + +"@unrs/resolver-binding-linux-arm64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz#edab13c46a45783a7e01351e113825c04f352e24" + integrity sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg== + +"@unrs/resolver-binding-linux-arm64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz#e5e195db1130f7d3b6aa2fd67b3c9fe1ea4859a0" + integrity sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA== + +"@unrs/resolver-binding-linux-loong64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz#f01d22e091bae13016f4636698d9dcbbda775c3e" + integrity sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q== + +"@unrs/resolver-binding-linux-loong64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz#7d23efcb98adf076bfbcecc27b4212c36aa6697d" + integrity sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz#1f35f1eaa322f33cf2d96dac27f0626a93ffe2f6" + integrity sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz#674faa696f5ce96f214873946a1e2d6ca96723dd" + integrity sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A== + +"@unrs/resolver-binding-linux-riscv64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz#37835fdd0b472ecdcffccd4288f19018454b138c" + integrity sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w== + +"@unrs/resolver-binding-linux-s390x-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz#b6edf13db4bb0accdcd1ad482a4eea0301de9224" + integrity sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw== + +"@unrs/resolver-binding-linux-x64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz#daddad00bf65a405202284da1eb1db8eb83b218f" + integrity sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ== + +"@unrs/resolver-binding-linux-x64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz#dfdff1e0c2bad25420b41c76a746011c3983b9bb" + integrity sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A== + +"@unrs/resolver-binding-openharmony-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz#ce07c4f5e7b42f7bfce45e7629b8659063aefefe" + integrity sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ== + +"@unrs/resolver-binding-wasm32-wasi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz#82514f0506cfaf65f17fe16095f92d450e487183" + integrity sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A== + dependencies: + "@emnapi/core" "1.10.0" + "@emnapi/runtime" "1.10.0" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@unrs/resolver-binding-win32-arm64-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz#521427dd59a8f4740ddd1dc7c3bc6af1aa1d260d" + integrity sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g== + +"@unrs/resolver-binding-win32-ia32-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz#05b63286ff2da37e0ce3083b8390884385efff62" + integrity sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g== + +"@unrs/resolver-binding-win32-x64-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz#72da0da48d72b1e87831b9c0308931d3f4669027" + integrity sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA== + "@urql/core@^5.0.6", "@urql/core@^5.1.2": version "5.2.0" resolved "https://registry.yarnpkg.com/@urql/core/-/core-5.2.0.tgz#77ee41e192e261fea30c2ca6c2f340410b45d214" @@ -2219,6 +2728,11 @@ accepts@^2.0.0: mime-types "^3.0.0" negotiator "^1.0.0" +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + acorn@^8.15.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" @@ -2229,6 +2743,16 @@ agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^8.11.0: version "8.20.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" @@ -2318,6 +2842,20 @@ array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: call-bound "^1.0.3" is-array-buffer "^3.0.5" +array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + array-timsort@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" @@ -2334,6 +2872,51 @@ array.prototype.at@^1.1.1: es-object-atoms "^1.0.0" es-shim-unscopables "^1.0.2" +array.prototype.findlast@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.1, array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" + array.prototype.map@^1.0.5: version "1.0.8" resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.8.tgz#22f4aae44d3081ce3cd1dd6fd37532e7a3433451" @@ -2347,6 +2930,17 @@ array.prototype.map@^1.0.5: es-object-atoms "^1.0.0" is-string "^1.1.1" +array.prototype.tosorted@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" + integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-shim-unscopables "^1.0.2" + arraybuffer.prototype.slice@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" @@ -2693,6 +3287,11 @@ call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + camelcase-css@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" @@ -2928,7 +3527,7 @@ core-js-compat@^3.43.0: dependencies: browserslist "^4.28.1" -cross-spawn@^7.0.3: +cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -3019,7 +3618,7 @@ debug@4.3.4: dependencies: ms "2.1.2" -debug@^3.1.0: +debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== @@ -3036,6 +3635,11 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" @@ -3101,6 +3705,13 @@ dlv@^1.1.3: resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -3199,7 +3810,7 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" -es-abstract@^1.22.1, es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9: +es-abstract@^1.17.5, es-abstract@^1.22.1, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9, es-abstract@^1.24.0, es-abstract@^1.24.2: version "1.24.2" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.2.tgz#2dbd38c180735ee983f77585140a2706a963ed9a" integrity sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg== @@ -3289,6 +3900,28 @@ es-get-iterator@^1.0.2: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" +es-iterator-helpers@^1.2.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz#8f4ff1f3603cbd09fbdb72c747a679779a65cc7f" + integrity sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw== + dependencies: + call-bind "^1.0.9" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.2" + es-errors "^1.3.0" + es-set-tostringtag "^2.1.0" + function-bind "^1.1.2" + get-intrinsic "^1.3.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + internal-slot "^1.1.0" + iterator.prototype "^1.1.5" + math-intrinsics "^1.1.0" + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -3306,7 +3939,7 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -es-shim-unscopables@^1.0.2: +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== @@ -3347,11 +3980,224 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +eslint-config-expo@^56.0.4: + version "56.0.4" + resolved "https://registry.yarnpkg.com/eslint-config-expo/-/eslint-config-expo-56.0.4.tgz#e1d4973c5ed3d4649c93280e0c93844cd003c38d" + integrity sha512-1OD7rJMxCchKHxq+U+OQsAxVtzAxeUb9875g6+15KsSD9fqKTgq7DEEWYwunzU9r9E8kYJ+mh7+j86vF9m9NMw== + dependencies: + "@typescript-eslint/eslint-plugin" "^8.59.0" + "@typescript-eslint/parser" "^8.59.0" + eslint-import-resolver-typescript "^3.6.3" + eslint-plugin-expo "^1.0.1" + eslint-plugin-import "^2.30.0" + eslint-plugin-react "^7.37.3" + eslint-plugin-react-hooks "^7.0.0" + globals "^16.0.0" + +eslint-config-prettier@^10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-import-resolver-node@^0.3.9: + version "0.3.10" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz#84ce3005abfc300588cf23bbac1aabec1fc6e8c1" + integrity sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ== + dependencies: + debug "^3.2.7" + is-core-module "^2.16.1" + resolve "^2.0.0-next.6" + +eslint-import-resolver-typescript@^3.6.3: + version "3.10.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz#23dac32efa86a88e2b8232eb244ac499ad636db2" + integrity sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ== + dependencies: + "@nolyfill/is-core-module" "1.0.39" + debug "^4.4.0" + get-tsconfig "^4.10.0" + is-bun-module "^2.0.0" + stable-hash "^0.0.5" + tinyglobby "^0.2.13" + unrs-resolver "^1.6.2" + +eslint-module-utils@^2.12.1: + version "2.13.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz#882beaf64927567358816bf4dc0f050fd52e0fb9" + integrity sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ== + dependencies: + debug "^3.2.7" + +eslint-plugin-expo@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-expo/-/eslint-plugin-expo-1.0.3.tgz#952722b4491fe46814856dbb7928b1a912dd0dc5" + integrity sha512-C1v9NPvpDET36+7Klpp/+53Jl+VzOfpbDxpKtL/pAPhCDwTX0kW6Swo425PT0uc4AMT5jpQbB7hSKFjKOGMl4A== + dependencies: + "@typescript-eslint/types" "^8.29.1" + "@typescript-eslint/utils" "^8.29.1" + eslint "^9.24.0" + +eslint-plugin-import@^2.30.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== + dependencies: + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" + debug "^3.2.7" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.9" + eslint-module-utils "^2.12.1" + hasown "^2.0.2" + is-core-module "^2.16.1" + is-glob "^4.0.3" + minimatch "^3.1.2" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.1" + semver "^6.3.1" + string.prototype.trimend "^1.0.9" + tsconfig-paths "^3.15.0" + +eslint-plugin-react-hooks@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz#e6742cad75d970c0a3f30d7d3fa80a4784f55927" + integrity sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + hermes-parser "^0.25.1" + zod "^3.25.0 || ^4.0.0" + zod-validation-error "^3.5.0 || ^4.0.0" + +eslint-plugin-react@^7.37.3: + version "7.37.5" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz#2975511472bdda1b272b34d779335c9b0e877065" + integrity sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA== + dependencies: + array-includes "^3.1.8" + array.prototype.findlast "^1.2.5" + array.prototype.flatmap "^1.3.3" + array.prototype.tosorted "^1.1.4" + doctrine "^2.1.0" + es-iterator-helpers "^1.2.1" + estraverse "^5.3.0" + hasown "^2.0.2" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.9" + object.fromentries "^2.0.8" + object.values "^1.2.1" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.12" + string.prototype.repeat "^1.0.0" + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint-visitor-keys@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@^9, eslint@^9.24.0: + version "9.39.4" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5" + integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.2" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.5" + "@eslint/js" "9.39.4" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.5" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== +esquery@^1.5.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" @@ -3522,7 +4368,7 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA== -fast-deep-equal@^3.1.3: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -3538,11 +4384,16 @@ fast-glob@^3.3.2: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + fast-uri@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" @@ -3574,6 +4425,13 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -3607,6 +4465,14 @@ find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + firebase@^12.10.0: version "12.12.1" resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.12.1.tgz#4c5145ce819509b1e547d27aef584ab719809d29" @@ -3641,6 +4507,19 @@ firebase@^12.10.0: "@firebase/storage-compat" "0.4.2" "@firebase/util" "1.15.0" +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + flow-enums-runtime@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787" @@ -3736,7 +4615,7 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-proto@^1.0.1: +get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== @@ -3753,6 +4632,13 @@ get-symbol-description@^1.1.0: es-errors "^1.3.0" get-intrinsic "^1.2.6" +get-tsconfig@^4.10.0: + version "4.14.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8" + integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA== + dependencies: + resolve-pkg-maps "^1.0.0" + getenv@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" @@ -3798,6 +4684,16 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^16.0.0: + version "16.5.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-16.5.0.tgz#ccf1594a437b97653b2be13ed4d8f5c9f850cac1" + integrity sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ== + globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -3864,6 +4760,18 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + hermes-estree@0.29.1: version "0.29.1" resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.29.1.tgz#043c7db076e0e8ef8c5f6ed23828d1ba463ebcc5" @@ -3900,6 +4808,13 @@ hermes-parser@0.35.0: dependencies: hermes-estree "0.35.0" +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + hoist-non-react-statics@^3.3.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -3953,11 +4868,16 @@ ieee754@^1.1.13: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^5.3.1: +ignore@^5.2.0, ignore@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + image-size@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.2.1.tgz#ee118aedfe666db1a6ee12bed5821cde3740276d" @@ -3965,6 +4885,14 @@ image-size@^1.0.2: dependencies: queue "6.0.2" +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -4059,6 +4987,13 @@ is-boolean-object@^1.2.1: call-bound "^1.0.3" has-tostringtag "^1.0.2" +is-bun-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" + integrity sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ== + dependencies: + semver "^7.7.1" + is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -4071,6 +5006,13 @@ is-core-module@^2.16.1: dependencies: hasown "^2.0.2" +is-core-module@^2.16.2: + version "2.16.2" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" + integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== + dependencies: + hasown "^2.0.3" + is-data-view@^1.0.1, is-data-view@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" @@ -4121,7 +5063,7 @@ is-generator-function@^1.0.10, is-generator-function@^1.0.7: has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" -is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -4276,6 +5218,18 @@ iterate-value@^1.0.2: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +iterator.prototype@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz#12c959a29de32de0aa3bbbb801f4d777066dae39" + integrity sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g== + dependencies: + define-data-property "^1.1.4" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.6" + get-proto "^1.0.0" + has-symbols "^1.1.0" + set-function-name "^2.0.2" + jest-environment-node@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" @@ -4410,6 +5364,13 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +js-yaml@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" + integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== + dependencies: + argparse "^2.0.1" + jsc-safe-url@^0.2.2, jsc-safe-url@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz#141c14fbb43791e88d5dc64e85a374575a83477a" @@ -4420,16 +5381,55 @@ jsesc@^3.0.2, jsesc@~3.1.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + json-schema-traverse@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -4445,6 +5445,14 @@ leven@^3.1.0: resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + lighthouse-logger@^1.0.0: version "1.4.2" resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa" @@ -4627,6 +5635,13 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -4637,6 +5652,11 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" @@ -4664,7 +5684,7 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -5176,7 +6196,7 @@ minimatch@^10.2.2: dependencies: brace-expansion "^5.0.5" -minimatch@^3.0.4, minimatch@^3.1.1: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== @@ -5190,7 +6210,7 @@ minimatch@^9.0.0: dependencies: brace-expansion "^2.0.2" -minimist@^1.2.0: +minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -5241,6 +6261,11 @@ nanoid@^3.3.11, nanoid@^3.3.7: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +napi-postinstall@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + nativewind@^4.1.23: version "4.2.3" resolved "https://registry.yarnpkg.com/nativewind/-/nativewind-4.2.3.tgz#ad7880bd2b5ac55f041d34918b9c67b00340088c" @@ -5250,6 +6275,11 @@ nativewind@^4.1.23: debug "^4.3.7" react-native-css-interop "0.2.3" +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -5270,6 +6300,16 @@ nested-error-stacks@~2.0.1: resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b" integrity sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A== +node-exports-info@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/node-exports-info/-/node-exports-info-1.6.0.tgz#1aedafb01a966059c9a5e791a94a94d93f5c2a13" + integrity sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw== + dependencies: + array.prototype.flatmap "^1.3.3" + es-errors "^1.3.0" + object.entries "^1.1.9" + semver "^6.3.1" + node-forge@^1.3.3: version "1.4.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" @@ -5326,7 +6366,7 @@ ob1@0.83.7: dependencies: flow-enums-runtime "^0.0.6" -object-assign@^4.0.1: +object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -5366,6 +6406,45 @@ object.assign@^4.1.4, object.assign@^4.1.7: has-symbols "^1.1.0" object-keys "^1.1.1" +object.entries@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.9.tgz#e4770a6a1444afb61bd39f984018b5bede25f8b3" + integrity sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-object-atoms "^1.1.1" + +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + +object.values@^1.1.6, object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -5416,6 +6495,18 @@ open@^8.0.4: is-docker "^2.1.1" is-wsl "^2.2.0" +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + ora@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/ora/-/ora-3.4.0.tgz#bf0752491059a3ef3ed4c85097531de9fdbcd318" @@ -5444,7 +6535,7 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^3.1.0: +p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -5458,11 +6549,25 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + parse-png@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/parse-png/-/parse-png-2.1.0.tgz#2a42ad719fedf90f81c59ebee7ae59b280d6b338" @@ -5608,6 +6713,16 @@ postcss@~8.4.32: picocolors "^1.1.1" source-map-js "^1.2.1" +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0" + integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== + pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -5659,6 +6774,15 @@ prompts@^2.3.2: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + protobufjs@^7.2.5: version "7.5.6" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.6.tgz#11af832ebc4b4326f658a5b1308e6141eb57edfd" @@ -5677,7 +6801,7 @@ protobufjs@^7.2.5: "@types/node" ">=13.7.0" long "^5.0.0" -punycode@^2.1.1: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -5737,7 +6861,7 @@ react-freeze@^1.0.0: resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.4.tgz#cbbea2762b0368b05cbe407ddc9d518c57c6f3ad" integrity sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA== -react-is@^16.7.0: +react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -5946,7 +7070,7 @@ regenerator-runtime@^0.13.2: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== -regexp.prototype.flags@^1.5.4: +regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== @@ -6001,11 +7125,21 @@ requireg@^0.2.2: rc "~1.2.7" resolve "~1.7.1" +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + resolve-workspace-root@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz#9cbbf8321ebccaaf0e4ffea5274aa26b611ccd62" @@ -6026,6 +7160,18 @@ resolve@^1.1.7, resolve@^1.22.11, resolve@^1.22.2, resolve@^1.22.8: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^2.0.0-next.5, resolve@^2.0.0-next.6: + version "2.0.0-next.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.7.tgz#ba3b035d4b1ee7c522426eee73cabcb0fd5515dd" + integrity sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ== + dependencies: + es-errors "^1.3.0" + is-core-module "^2.16.2" + node-exports-info "^1.6.0" + object-keys "^1.1.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@~1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" @@ -6135,6 +7281,11 @@ semver@^7.1.3, semver@^7.3.5, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +semver@^7.7.1, semver@^7.7.3: + version "7.8.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" + integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== + send@^0.19.0, send@~0.19.1: version "0.19.2" resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" @@ -6341,6 +7492,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +stable-hash@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/stable-hash/-/stable-hash-0.0.5.tgz#94e8837aaeac5b4d0f631d2972adef2924b40269" + integrity sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA== + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -6397,6 +7553,33 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string.prototype.matchall@^4.0.12: + version "4.0.12" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz#6c88740e49ad4956b1332a911e949583a275d4c0" + integrity sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-abstract "^1.23.6" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.6" + gopd "^1.2.0" + has-symbols "^1.1.0" + internal-slot "^1.1.0" + regexp.prototype.flags "^1.5.3" + set-function-name "^2.0.2" + side-channel "^1.1.0" + +string.prototype.repeat@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a" + integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trim@^1.2.10: version "1.2.10" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" @@ -6443,6 +7626,16 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" @@ -6603,6 +7796,14 @@ tinyglobby@^0.2.11: fdir "^6.5.0" picomatch "^4.0.4" +tinyglobby@^0.2.13, tinyglobby@^0.2.15: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -6620,16 +7821,38 @@ toidentifier@~1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + ts-interface-checker@^0.1.9: version "0.1.13" resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@2.8.1, tslib@^2.1.0: +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@2.8.1, tslib@^2.1.0, tslib@^2.4.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -6755,6 +7978,36 @@ unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== +unrs-resolver@^1.6.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.12.2.tgz#a6c6888396abba5adaac4cab6587df866f1d7afd" + integrity sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ== + dependencies: + napi-postinstall "^0.3.4" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.12.2" + "@unrs/resolver-binding-android-arm64" "1.12.2" + "@unrs/resolver-binding-darwin-arm64" "1.12.2" + "@unrs/resolver-binding-darwin-x64" "1.12.2" + "@unrs/resolver-binding-freebsd-x64" "1.12.2" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.12.2" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.12.2" + "@unrs/resolver-binding-linux-arm64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-arm64-musl" "1.12.2" + "@unrs/resolver-binding-linux-loong64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-loong64-musl" "1.12.2" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-riscv64-musl" "1.12.2" + "@unrs/resolver-binding-linux-s390x-gnu" "1.12.2" + "@unrs/resolver-binding-linux-x64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-x64-musl" "1.12.2" + "@unrs/resolver-binding-openharmony-arm64" "1.12.2" + "@unrs/resolver-binding-wasm32-wasi" "1.12.2" + "@unrs/resolver-binding-win32-arm64-msvc" "1.12.2" + "@unrs/resolver-binding-win32-ia32-msvc" "1.12.2" + "@unrs/resolver-binding-win32-x64-msvc" "1.12.2" + update-browserslist-db@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" @@ -6763,6 +8016,13 @@ update-browserslist-db@^1.2.3: escalade "^3.2.0" picocolors "^1.1.1" +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + use-latest-callback@^0.2.4: version "0.2.6" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77" @@ -6976,6 +8236,11 @@ wonka@^6.3.2: resolved "https://registry.yarnpkg.com/wonka/-/wonka-6.3.6.tgz#a70e2e54ed6aaa8e20bb57d916166cdc3d385f2d" integrity sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag== +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -7084,6 +8349,16 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +"zod-validation-error@^3.5.0 || ^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918" + integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== + +"zod@^3.25.0 || ^4.0.0": + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + zod@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"